feat: add tag filter chips in Feed with popular tags LazyRow

FeedViewModel fetches tags on refresh(), takes top 10 by post count.
FeedScreen shows LazyRow of FilterChip below status filter: "All tags"
first, then popular tags with post counts. Tapping filters posts by tag.
Post cards now show tags in compact labelSmall format joined by dots.
This commit is contained in:
Paweł Orzech 2026-03-20 00:33:57 +01:00
parent 11b20fd42a
commit aaf29f1512
2 changed files with 95 additions and 29 deletions

View file

@ -33,6 +33,7 @@ import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.*
import kotlinx.coroutines.launch
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.pager.HorizontalPager
@ -83,6 +84,7 @@ import com.swoosh.microblog.data.CredentialsManager
import com.swoosh.microblog.data.ShareUtils
import com.swoosh.microblog.data.model.FeedPost
import com.swoosh.microblog.data.model.GhostAccount
import com.swoosh.microblog.data.model.GhostTagFull
import com.swoosh.microblog.data.model.PostFilter
import com.swoosh.microblog.data.model.PostStats
import com.swoosh.microblog.data.model.QueueStatus
@ -109,6 +111,7 @@ fun FeedScreen(
val recentSearches by viewModel.recentSearches.collectAsStateWithLifecycle()
val accounts by viewModel.accounts.collectAsStateWithLifecycle()
val activeAccount by viewModel.activeAccount.collectAsStateWithLifecycle()
val popularTags by viewModel.popularTags.collectAsStateWithLifecycle()
val listState = rememberLazyListState()
val context = LocalContext.current
val snackbarHostState = remember { SnackbarHostState() }
@ -305,20 +308,17 @@ fun FeedScreen(
)
}
// Active tag filter bar
if (state.activeTagFilter != null) {
FilterChip(
onClick = { viewModel.clearTagFilter() },
label = { Text("#${state.activeTagFilter}") },
selected = true,
leadingIcon = {
Icon(Icons.Default.Tag, contentDescription = null, modifier = Modifier.size(16.dp))
},
trailingIcon = {
Icon(Icons.Default.Close, contentDescription = "Clear filter", modifier = Modifier.size(16.dp))
},
modifier = Modifier
.padding(horizontal = 16.dp, vertical = 4.dp)
// Tag filter chips
AnimatedVisibility(
visible = !isSearchActive && popularTags.isNotEmpty(),
enter = fadeIn(SwooshMotion.quick()) + expandVertically(),
exit = fadeOut(SwooshMotion.quick()) + shrinkVertically()
) {
TagFilterChipsBar(
tags = popularTags,
activeTagFilter = state.activeTagFilter,
onTagSelected = { viewModel.filterByTag(it) },
onClearFilter = { viewModel.clearTagFilter() }
)
}
@ -775,6 +775,58 @@ fun FilterChipsBar(
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun TagFilterChipsBar(
tags: List<GhostTagFull>,
activeTagFilter: String?,
onTagSelected: (String) -> Unit,
onClearFilter: () -> Unit
) {
LazyRow(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 4.dp),
contentPadding = PaddingValues(horizontal = 16.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
// "All tags" chip
item {
val isAllSelected = activeTagFilter == null
FilterChip(
selected = isAllSelected,
onClick = { onClearFilter() },
label = { Text("All tags") },
colors = FilterChipDefaults.filterChipColors(
selectedContainerColor = MaterialTheme.colorScheme.primaryContainer
)
)
}
// Tag chips
items(tags, key = { it.id ?: it.name }) { tag ->
val isSelected = activeTagFilter != null && activeTagFilter.equals(tag.name, ignoreCase = true)
FilterChip(
selected = isSelected,
onClick = {
if (isSelected) onClearFilter() else onTagSelected(tag.name)
},
label = {
val postCount = tag.count?.posts
if (postCount != null) {
Text("${tag.name} ($postCount)")
} else {
Text(tag.name)
}
},
colors = FilterChipDefaults.filterChipColors(
selectedContainerColor = MaterialTheme.colorScheme.primaryContainer
)
)
}
}
}
@Composable
fun SortButton(
currentSort: SortOrder,
@ -1519,24 +1571,20 @@ fun PostCardContent(
}
}
// Hashtag tags (bold colored text, not chips)
// Tags display
if (post.tags.isNotEmpty()) {
Spacer(modifier = Modifier.height(10.dp))
@OptIn(ExperimentalLayoutApi::class)
FlowRow(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
post.tags.forEach { tag ->
Text(
text = "#$tag",
style = MaterialTheme.typography.labelMedium.copy(fontWeight = FontWeight.Bold),
text = post.tags.joinToString(" \u00B7 ") { "#$it" },
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.clickable { onTagClick(tag) }
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.clickable {
post.tags.firstOrNull()?.let { onTagClick(it) }
}
)
}
}
}
// Queue status
if (post.queueStatus != QueueStatus.NONE) {

View file

@ -13,6 +13,7 @@ import com.swoosh.microblog.data.api.ApiClient
import com.swoosh.microblog.data.db.Converters
import com.swoosh.microblog.data.model.*
import com.swoosh.microblog.data.repository.PostRepository
import com.swoosh.microblog.data.repository.TagRepository
import com.google.gson.Gson
import com.google.gson.reflect.TypeToken
import kotlinx.coroutines.FlowPreview
@ -38,6 +39,7 @@ class FeedViewModel(application: Application) : AndroidViewModel(application) {
private val accountManager = AccountManager(application)
private var repository = PostRepository(application)
private var tagRepository = TagRepository(application)
private val feedPreferences = FeedPreferences(application)
private val searchHistoryManager = SearchHistoryManager(application)
@ -71,6 +73,9 @@ class FeedViewModel(application: Application) : AndroidViewModel(application) {
private val _recentSearches = MutableStateFlow<List<String>>(emptyList())
val recentSearches: StateFlow<List<String>> = _recentSearches.asStateFlow()
private val _popularTags = MutableStateFlow<List<GhostTagFull>>(emptyList())
val popularTags: StateFlow<List<GhostTagFull>> = _popularTags.asStateFlow()
private val _accounts = MutableStateFlow<List<GhostAccount>>(emptyList())
val accounts: StateFlow<List<GhostAccount>> = _accounts.asStateFlow()
@ -237,8 +242,9 @@ class FeedViewModel(application: Application) : AndroidViewModel(application) {
accountManager.setActiveAccount(accountId)
ApiClient.reset()
// Re-create repository to pick up new account
// Re-create repositories to pick up new account
repository = PostRepository(getApplication())
tagRepository = TagRepository(getApplication())
refreshAccountsList()
@ -296,6 +302,18 @@ class FeedViewModel(application: Application) : AndroidViewModel(application) {
val sort = _sortOrder.value
val tagFilter = _uiState.value.activeTagFilter
// Fetch popular tags in parallel
launch {
tagRepository.fetchTags().fold(
onSuccess = { tags ->
_popularTags.value = tags
.sortedByDescending { it.count?.posts ?: 0 }
.take(10)
},
onFailure = { /* silently ignore tag fetch failures */ }
)
}
repository.fetchPosts(page = 1, filter = filter, sortOrder = sort, tagFilter = tagFilter).fold(
onSuccess = { response ->
remotePosts = response.posts.map { it.toFeedPost() }