mirror of
https://github.com/pawelorzech/Swoosh.git
synced 2026-03-31 20:15:41 +00:00
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:
parent
11b20fd42a
commit
aaf29f1512
2 changed files with 95 additions and 29 deletions
|
|
@ -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,24 +1571,20 @@ 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)
|
|
||||||
FlowRow(
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
|
||||||
verticalArrangement = Arrangement.spacedBy(4.dp)
|
|
||||||
) {
|
|
||||||
post.tags.forEach { tag ->
|
|
||||||
Text(
|
Text(
|
||||||
text = "#$tag",
|
text = post.tags.joinToString(" \u00B7 ") { "#$it" },
|
||||||
style = MaterialTheme.typography.labelMedium.copy(fontWeight = FontWeight.Bold),
|
style = MaterialTheme.typography.labelSmall,
|
||||||
color = MaterialTheme.colorScheme.primary,
|
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
|
// Queue status
|
||||||
if (post.queueStatus != QueueStatus.NONE) {
|
if (post.queueStatus != QueueStatus.NONE) {
|
||||||
|
|
|
||||||
|
|
@ -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() }
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue