feat: Implement multi-tag filtering with dynamic SQL queries and improved UI

- Add RawQuery method getLinksByTags() to LinkDao for flexible tag filtering
- Implement dynamic SQL query generation for AND-based multi-tag filtering in LinkRepositoryImpl
- Parse and normalize tag input with whitespace handling and deduplication
- Replace single tag chip with FlowRow layout to display multiple selected tags
- Add individual tag removal capability while maintaining filter state
- Improve tag filter banner
This commit is contained in:
Bruno Charest 2026-01-29 20:42:52 -05:00
parent 7277342d4a
commit fdacf2248a
3 changed files with 60 additions and 17 deletions

View File

@ -68,6 +68,9 @@ interface LinkDao {
""")
fun getLinksByMultipleTags(tag1: String, tag2: String): PagingSource<Int, LinkEntity>
@RawQuery(observedEntities = [LinkEntity::class])
fun getLinksByTags(query: SupportSQLiteQuery): PagingSource<Int, LinkEntity>
// ====== Filtres temporels ======
@Query("""

View File

@ -25,6 +25,7 @@ import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.map
import retrofit2.HttpException
import androidx.sqlite.db.SimpleSQLiteQuery
import javax.inject.Inject
import javax.inject.Singleton
@ -56,12 +57,22 @@ constructor(
when {
!searchTerm.isNullOrBlank() -> linkDao.searchLinks(searchTerm)
!searchTags.isNullOrBlank() -> {
val tags = searchTags.split(" ").filter { it.isNotBlank() }
if (tags.size == 1) {
linkDao.getLinksByTag(tags.first())
val tags =
searchTags
.trim()
.split(Regex("\\s+"))
.map { it.trim() }
.filter { it.isNotBlank() }
.distinct()
if (tags.isEmpty()) {
linkDao.getAllLinksPaged()
} else {
// Pour plusieurs tags, on prend les liens qui ont au moins un des tags
linkDao.getLinksByTag(tags.first())
val whereClause = tags.joinToString(" AND ") { "tags LIKE ?" }
val sql =
"SELECT * FROM links WHERE $whereClause ORDER BY is_pinned DESC, created_at DESC"
val args: Array<Any> = tags.map { "%\"$it\"%" }.toTypedArray()
linkDao.getLinksByTags(SimpleSQLiteQuery(sql, args))
}
}
else -> linkDao.getAllLinksPaged()

View File

@ -8,6 +8,8 @@ import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.lazy.items
import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
@ -15,6 +17,8 @@ import androidx.compose.material.pullrefresh.PullRefreshIndicator
import androidx.compose.material.pullrefresh.pullRefresh
import androidx.compose.material.pullrefresh.rememberPullRefreshState
import androidx.compose.material3.*
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@ -31,7 +35,7 @@ import com.shaarit.ui.components.PremiumTextField
import com.shaarit.ui.components.TagChip
import com.shaarit.ui.theme.*
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterialApi::class)
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterialApi::class, ExperimentalLayoutApi::class)
@Composable
fun FeedScreen(
onNavigateToAdd: () -> Unit,
@ -241,27 +245,52 @@ fun FeedScreen(
modifier = Modifier
.fillMaxWidth()
.background(DarkNavy)
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
.padding(horizontal = 16.dp, vertical = 8.dp),
verticalAlignment = Alignment.Top,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Text(
"Filtering by:",
style = MaterialTheme.typography.bodyMedium,
color = TextSecondary
color = TextSecondary,
modifier = Modifier.padding(top = 6.dp)
)
val selectedTags =
remember(searchTags) {
searchTags
.orEmpty()
.trim()
.split(Regex("\\s+"))
.map { it.trim() }
.filter { it.isNotBlank() }
.distinct()
}
FlowRow(
modifier = Modifier.weight(1f),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
maxItemsInEachRow = 3
) {
selectedTags.forEach { tag ->
TagChip(
tag = searchTags!!,
tag = tag,
isSelected = true,
onClick = { viewModel.clearTagFilter() }
onClick = { viewModel.onTagClicked(tag) }
)
Spacer(modifier = Modifier.weight(1f))
IconButton(onClick = { viewModel.clearTagFilter() }) {
}
}
IconButton(
onClick = { viewModel.clearTagFilter() },
modifier = Modifier.size(32.dp)
) {
Icon(
Icons.Default.Close,
contentDescription = "Clear filter",
tint = TextMuted,
modifier = Modifier.size(20.dp)
modifier = Modifier.size(18.dp)
)
}
}