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

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.db.Converters
import com.swoosh.microblog.data.model.* import com.swoosh.microblog.data.model.*
import com.swoosh.microblog.data.repository.PostRepository import com.swoosh.microblog.data.repository.PostRepository
import com.swoosh.microblog.data.repository.TagRepository
import com.google.gson.Gson import com.google.gson.Gson
import com.google.gson.reflect.TypeToken import com.google.gson.reflect.TypeToken
import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.FlowPreview
@ -38,6 +39,7 @@ class FeedViewModel(application: Application) : AndroidViewModel(application) {
private val accountManager = AccountManager(application) private val accountManager = AccountManager(application)
private var repository = PostRepository(application) private var repository = PostRepository(application)
private var tagRepository = TagRepository(application)
private val feedPreferences = FeedPreferences(application) private val feedPreferences = FeedPreferences(application)
private val searchHistoryManager = SearchHistoryManager(application) private val searchHistoryManager = SearchHistoryManager(application)
@ -71,6 +73,9 @@ class FeedViewModel(application: Application) : AndroidViewModel(application) {
private val _recentSearches = MutableStateFlow<List<String>>(emptyList()) private val _recentSearches = MutableStateFlow<List<String>>(emptyList())
val recentSearches: StateFlow<List<String>> = _recentSearches.asStateFlow() 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()) private val _accounts = MutableStateFlow<List<GhostAccount>>(emptyList())
val accounts: StateFlow<List<GhostAccount>> = _accounts.asStateFlow() val accounts: StateFlow<List<GhostAccount>> = _accounts.asStateFlow()
@ -237,8 +242,9 @@ class FeedViewModel(application: Application) : AndroidViewModel(application) {
accountManager.setActiveAccount(accountId) accountManager.setActiveAccount(accountId)
ApiClient.reset() ApiClient.reset()
// Re-create repository to pick up new account // Re-create repositories to pick up new account
repository = PostRepository(getApplication()) repository = PostRepository(getApplication())
tagRepository = TagRepository(getApplication())
refreshAccountsList() refreshAccountsList()
@ -296,6 +302,18 @@ class FeedViewModel(application: Application) : AndroidViewModel(application) {
val sort = _sortOrder.value val sort = _sortOrder.value
val tagFilter = _uiState.value.activeTagFilter 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( repository.fetchPosts(page = 1, filter = filter, sortOrder = sort, tagFilter = tagFilter).fold(
onSuccess = { response -> onSuccess = { response ->
remotePosts = response.posts.map { it.toFeedPost() } remotePosts = response.posts.map { it.toFeedPost() }