compact filter row with bottom sheet for source/follows/sort

Replaces the two-row, mixed-control filter UI under the search bar with a
single segmented row for scope (All/People/Notes) and a Tune icon button
that opens a ModalBottomSheet containing Source, Follows-only, and Sort
controls. A primary-color dot on the icon indicates non-default filter
state.

Also renames SearchSortOrder.DEFAULT_EVENT/DEFAULT_PEOPLE to
EVENT_DEFAULT/PEOPLE_DEFAULT for consistency with EVENT_OPTIONS/
PEOPLE_OPTIONS, and routes the sheet's reset button through these
canonical constants so the UI default cannot drift from the ViewModel.
This commit is contained in:
davotoula
2026-04-23 14:17:42 +02:00
parent 3b0bf07e58
commit 458d9bcbab
5 changed files with 208 additions and 85 deletions
@@ -89,7 +89,7 @@ class SearchBarViewModel(
val scope = MutableStateFlow(SearchScope.ALL)
val source = MutableStateFlow(SearchSource.RELAYS)
val followsOnly = MutableStateFlow(false)
val sortOrder = MutableStateFlow(SearchSortOrder.DEFAULT_EVENT)
val sortOrder = MutableStateFlow(SearchSortOrder.EVENT_DEFAULT)
val searchTerm =
searchValueFlow
@@ -20,33 +20,43 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.search
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.selection.selectable
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Tune
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilterChip
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.RadioButton
import androidx.compose.material3.SegmentedButton
import androidx.compose.material3.SegmentedButtonDefaults
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TextField
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
@@ -189,6 +199,21 @@ private fun SearchBar(
}
}
// Must match SearchBarViewModel.source's initial value.
private val DEFAULT_SOURCE = SearchSource.RELAYS
private fun hasNonDefaultFilters(
scope: SearchScope,
source: SearchSource,
followsOnly: Boolean,
sort: SearchSortOrder,
): Boolean {
if (source != DEFAULT_SOURCE) return true
if (followsOnly) return true
if (scope != SearchScope.PEOPLE && sort != SearchSortOrder.EVENT_DEFAULT) return true
return false
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun SearchFilterRow(searchBarViewModel: SearchBarViewModel) {
@@ -197,6 +222,8 @@ private fun SearchFilterRow(searchBarViewModel: SearchBarViewModel) {
val currentFollowsOnly by searchBarViewModel.followsOnly.collectAsStateWithLifecycle()
val currentSort by searchBarViewModel.sortOrder.collectAsStateWithLifecycle()
var sheetOpen by remember { mutableStateOf(false) }
Row(
modifier =
Modifier
@@ -225,95 +252,186 @@ private fun SearchFilterRow(searchBarViewModel: SearchBarViewModel) {
}
}
SortMenu(
currentSort = currentSort,
enabled = currentScope != SearchScope.PEOPLE,
onSelect = { searchBarViewModel.updateSortOrder(it) },
FilterIconButton(
hasBadge =
hasNonDefaultFilters(
scope = currentScope,
source = currentSource,
followsOnly = currentFollowsOnly,
sort = currentSort,
),
onClick = { sheetOpen = true },
)
}
Row(
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 10.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
SingleChoiceSegmentedButtonRow {
val sources = listOf(SearchSource.LOCAL, SearchSource.RELAYS)
sources.forEachIndexed { index, s ->
SegmentedButton(
selected = currentSource == s,
onClick = { searchBarViewModel.updateSource(s) },
shape = SegmentedButtonDefaults.itemShape(index = index, count = sources.size),
) {
Text(
text =
when (s) {
SearchSource.LOCAL -> stringRes(R.string.search_source_local)
SearchSource.RELAYS -> stringRes(R.string.search_source_relays)
},
)
}
}
}
FilterChip(
selected = currentFollowsOnly,
onClick = { searchBarViewModel.updateFollowsOnly(!currentFollowsOnly) },
label = { Text(stringRes(R.string.search_follows_only)) },
if (sheetOpen) {
SearchFiltersSheet(
scope = currentScope,
source = currentSource,
followsOnly = currentFollowsOnly,
sort = currentSort,
onSourceChange = searchBarViewModel::updateSource,
onFollowsOnlyChange = searchBarViewModel::updateFollowsOnly,
onSortChange = searchBarViewModel::updateSortOrder,
onReset = {
searchBarViewModel.updateSource(DEFAULT_SOURCE)
searchBarViewModel.updateFollowsOnly(false)
searchBarViewModel.updateSortOrder(SearchSortOrder.EVENT_DEFAULT)
},
onDismiss = { sheetOpen = false },
)
}
}
@Composable
private fun SortMenu(
currentSort: SearchSortOrder,
enabled: Boolean,
onSelect: (SearchSortOrder) -> Unit,
private fun FilterIconButton(
hasBadge: Boolean,
onClick: () -> Unit,
) {
var expanded by remember { mutableStateOf(false) }
val options = SearchSortOrder.EVENT_OPTIONS
TextButton(
onClick = { if (enabled) expanded = true },
enabled = enabled,
) {
Text(
text =
when (currentSort) {
SearchSortOrder.NEWEST -> stringRes(R.string.search_sort_newest)
SearchSortOrder.RELEVANCE -> stringRes(R.string.search_sort_relevance)
SearchSortOrder.POPULAR -> stringRes(R.string.search_sort_popular)
SearchSortOrder.OLDEST -> stringRes(R.string.search_sort_oldest)
else -> stringRes(R.string.search_sort_newest)
},
)
}
DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
options.forEach { opt ->
DropdownMenuItem(
text = {
Text(
when (opt) {
SearchSortOrder.NEWEST -> stringRes(R.string.search_sort_newest)
SearchSortOrder.RELEVANCE -> stringRes(R.string.search_sort_relevance)
SearchSortOrder.POPULAR -> stringRes(R.string.search_sort_popular)
SearchSortOrder.OLDEST -> stringRes(R.string.search_sort_oldest)
else -> opt.label
},
)
},
onClick = {
onSelect(opt)
expanded = false
},
Box {
IconButton(onClick = onClick) {
Icon(
imageVector = Icons.Outlined.Tune,
contentDescription = stringRes(R.string.search_filters_open),
)
}
if (hasBadge) {
Box(
modifier =
Modifier
.align(Alignment.TopEnd)
.offset(x = (-10).dp, y = 10.dp)
.size(8.dp)
.background(MaterialTheme.colorScheme.primary, CircleShape),
)
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun SearchFiltersSheet(
scope: SearchScope,
source: SearchSource,
followsOnly: Boolean,
sort: SearchSortOrder,
onSourceChange: (SearchSource) -> Unit,
onFollowsOnlyChange: (Boolean) -> Unit,
onSortChange: (SearchSortOrder) -> Unit,
onReset: () -> Unit,
onDismiss: () -> Unit,
) {
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
ModalBottomSheet(
onDismissRequest = onDismiss,
sheetState = sheetState,
) {
Column(
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 20.dp)
.padding(bottom = 24.dp),
verticalArrangement = Arrangement.spacedBy(20.dp),
) {
Text(
text = stringRes(R.string.search_filters_title),
style = MaterialTheme.typography.titleMedium,
)
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(
text = stringRes(R.string.search_filters_section_source),
style = MaterialTheme.typography.labelLarge,
)
SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) {
val sources = listOf(SearchSource.LOCAL, SearchSource.RELAYS)
sources.forEachIndexed { index, s ->
SegmentedButton(
selected = source == s,
onClick = { onSourceChange(s) },
shape = SegmentedButtonDefaults.itemShape(index = index, count = sources.size),
) {
Text(
text =
when (s) {
SearchSource.LOCAL -> stringRes(R.string.search_source_local)
SearchSource.RELAYS -> stringRes(R.string.search_source_relays)
},
)
}
}
}
}
Row(
modifier =
Modifier
.fillMaxWidth()
.clickable { onFollowsOnlyChange(!followsOnly) }
.padding(vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = stringRes(R.string.search_follows_only),
modifier = Modifier.weight(1f),
style = MaterialTheme.typography.bodyLarge,
)
Switch(
checked = followsOnly,
onCheckedChange = onFollowsOnlyChange,
)
}
if (scope != SearchScope.PEOPLE) {
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text(
text = stringRes(R.string.search_filters_section_sort),
style = MaterialTheme.typography.labelLarge,
)
SearchSortOrder.EVENT_OPTIONS.forEach { opt ->
Row(
modifier =
Modifier
.fillMaxWidth()
.selectable(
selected = sort == opt,
onClick = { onSortChange(opt) },
).padding(vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
RadioButton(
selected = sort == opt,
onClick = { onSortChange(opt) },
)
Text(
text = sortLabel(opt),
modifier = Modifier.padding(start = 8.dp),
style = MaterialTheme.typography.bodyLarge,
)
}
}
}
}
TextButton(onClick = onReset) {
Text(stringRes(R.string.search_filters_reset))
}
}
}
}
@Composable
private fun sortLabel(opt: SearchSortOrder): String =
when (opt) {
SearchSortOrder.NEWEST -> stringRes(R.string.search_sort_newest)
SearchSortOrder.RELEVANCE -> stringRes(R.string.search_sort_relevance)
SearchSortOrder.POPULAR -> stringRes(R.string.search_sort_popular)
SearchSortOrder.OLDEST -> stringRes(R.string.search_sort_oldest)
SearchSortOrder.NAME_AZ, SearchSortOrder.NAME_ZA -> opt.label
}
@Composable
private fun SearchTextField(
searchBarViewModel: SearchBarViewModel,
+5
View File
@@ -1880,6 +1880,11 @@
<string name="search_sort_oldest">Oldest</string>
<string name="search_sort_relevance">Relevance</string>
<string name="search_sort_popular">Popular</string>
<string name="search_filters_title">Filters</string>
<string name="search_filters_reset">Reset</string>
<string name="search_filters_section_source">Source</string>
<string name="search_filters_section_sort">Sort by</string>
<string name="search_filters_open">Filters</string>
<string name="dont_translate_from">Don\'t Translate From</string>
<string name="dont_translate_from_description">Languages shown here will not be translated. Select a language to remove it and have it translated again.</string>
@@ -84,10 +84,10 @@ class AdvancedSearchBarState(
val noteResults: StateFlow<ImmutableList<Event>> = _noteResults.asStateFlow()
// Sort orders
private val _eventSortOrder = MutableStateFlow(SearchSortOrder.DEFAULT_EVENT)
private val _eventSortOrder = MutableStateFlow(SearchSortOrder.EVENT_DEFAULT)
val eventSortOrder: StateFlow<SearchSortOrder> = _eventSortOrder.asStateFlow()
private val _peopleSortOrder = MutableStateFlow(SearchSortOrder.DEFAULT_PEOPLE)
private val _peopleSortOrder = MutableStateFlow(SearchSortOrder.PEOPLE_DEFAULT)
val peopleSortOrder: StateFlow<SearchSortOrder> = _peopleSortOrder.asStateFlow()
// Derived sorted results
@@ -266,8 +266,8 @@ class AdvancedSearchBarState(
_peopleResults.value = persistentListOf()
_noteResults.value = persistentListOf()
_relayStates.value = persistentListOf()
_eventSortOrder.value = SearchSortOrder.DEFAULT_EVENT
_peopleSortOrder.value = SearchSortOrder.DEFAULT_PEOPLE
_eventSortOrder.value = SearchSortOrder.EVENT_DEFAULT
_peopleSortOrder.value = SearchSortOrder.PEOPLE_DEFAULT
activeSubIds.value = emptySet()
eventDeduplicator.clear()
}
@@ -34,7 +34,7 @@ enum class SearchSortOrder(
companion object {
val EVENT_OPTIONS = listOf(RELEVANCE, NEWEST, POPULAR)
val PEOPLE_OPTIONS = listOf(NAME_AZ, NAME_ZA)
val DEFAULT_EVENT = NEWEST
val DEFAULT_PEOPLE = NAME_AZ
val EVENT_DEFAULT = NEWEST
val PEOPLE_DEFAULT = NAME_AZ
}
}