Trends, hashtag pages, and search results are high-read, moderately-stale data. They don't need real-time freshness — caching aggressively with short TTLs balances freshness and performance.
Trending Topics
data class Trend(
val displayName: String, // "Android"
val query: String, // "#Android" or "Android"
val tweetVolume: Long?, // null if < threshold
val category: TrendCategory, // NEWS, SPORTS, ENTERTAINMENT, TECHNOLOGY
val woeid: Int // Where On Earth ID — location-based
)
enum class TrendCategory { NEWS, SPORTS, ENTERTAINMENT, TECHNOLOGY, GENERAL }
// Fetch trends for user's location — cached for 5 minutes
class TrendsRepository(
private val api: TrendsApi,
private val cache: TrendsCache
) {
suspend fun getTrends(woeid: Int): List<Trend> {
val cached = cache.get(woeid)
if (cached != null && !cached.isStale(ttlMs = 5 * 60 * 1000)) return cached.trends
val fresh = api.getTrends(woeid)
cache.set(woeid, CachedTrends(trends = fresh, fetchedAt = System.currentTimeMillis()))
return fresh
}
}
Trending UI
@Composable
fun TrendsSection(trends: List<Trend>, onTrendTapped: (Trend) -> Unit) {
Column {
Text("Trending", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
trends.take(10).forEachIndexed { index, trend ->
Row(
modifier = Modifier
.fillMaxWidth()
.clickable { onTrendTapped(trend) }
.padding(horizontal = 16.dp, vertical = 12.dp),
horizontalArrangement = Arrangement.SpaceBetween
) {
Column(Modifier.weight(1f)) {
Text(
trend.category.name.lowercase().replaceFirstChar { it.uppercase() },
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(trend.displayName, style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.Medium)
trend.tweetVolume?.let {
Text(
"${formatVolume(it)} tweets",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
Text("${index + 1}", color = MaterialTheme.colorScheme.onSurfaceVariant)
}
Divider()
}
}
}
private fun formatVolume(volume: Long): String = when {
volume >= 1_000_000 -> "${volume / 1_000_000}M"
volume >= 1_000 -> "${volume / 1_000}K"
else -> volume.toString()
}
Hashtag Page
data class HashtagPage(
val hashtag: String,
val tweetCount: Long,
val topTweets: List<Tweet>, // curated by engagement
val recentTweets: PagingData<Tweet> // chronological; Paging 3
)
@HiltViewModel
class HashtagViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
private val api: HashtagApi
) : ViewModel() {
private val hashtag = savedStateHandle.get<String>("hashtag") ?: ""
val topTweets: StateFlow<List<Tweet>> = flow {
emit(api.getTopTweets(hashtag, limit = 5))
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
val recentTweets: Flow<PagingData<Tweet>> = Pager(
config = PagingConfig(pageSize = 20),
pagingSourceFactory = { HashtagPagingSource(api, hashtag) }
).flow.cachedIn(viewModelScope)
}
Search
@HiltViewModel
class SearchViewModel @Inject constructor(
private val api: SearchApi
) : ViewModel() {
private val _query = MutableStateFlow("")
val query: StateFlow<String> = _query
val suggestions: StateFlow<List<SearchSuggestion>> = _query
.debounce(200)
.filter { it.isNotBlank() }
.flatMapLatest { q ->
flow {
emit(api.getSuggestions(q))
}.catch { emit(emptyList()) }
}
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
// Paging source for full search results
fun search(query: String): Flow<PagingData<Tweet>> = Pager(
config = PagingConfig(pageSize = 20),
pagingSourceFactory = { SearchPagingSource(api, query) }
).flow.cachedIn(viewModelScope)
}
data class SearchSuggestion(
val type: SuggestionType,
val displayText: String,
val query: String
)
enum class SuggestionType { HASHTAG, USER, SAVED_SEARCH }
@Composable
fun SearchSuggestions(suggestions: List<SearchSuggestion>, onTap: (SearchSuggestion) -> Unit) {
LazyColumn {
items(suggestions) { suggestion ->
Row(
modifier = Modifier.fillMaxWidth().clickable { onTap(suggestion) }.padding(16.dp)
) {
Icon(
imageVector = when (suggestion.type) {
SuggestionType.HASHTAG -> Icons.Default.Tag
SuggestionType.USER -> Icons.Default.Person
SuggestionType.SAVED_SEARCH -> Icons.Default.History
},
contentDescription = null,
modifier = Modifier.padding(end = 12.dp)
)
Text(suggestion.displayText, style = MaterialTheme.typography.bodyLarge)
}
}
}
}
Key Takeaways
| Pattern | Rule |
|---|---|
| Trends cache TTL | 5 minutes; trends change hourly — no need for realtime |
| WOEID location | Fetch trends for user's nearest city; fallback to worldwide |
| Hashtag page layout | Top tweets (engagement-ranked) above recent tweets (chronological) |
| Search debounce | 200ms — faster than tweet composition (300ms); search feels more responsive |
| Search suggestions | Local history + server autocomplete; show before user finishes typing |
| Pagination | All search results use PagingSource + cursor |