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:
+1
-1
@@ -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
|
||||
|
||||
+196
-78
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user