diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchBarViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchBarViewModel.kt
index bd7e2f5f5..a021dfd78 100644
--- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchBarViewModel.kt
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchBarViewModel.kt
@@ -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
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchScreen.kt
index e5479e836..6d2a9cad7 100644
--- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchScreen.kt
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchScreen.kt
@@ -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,
diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml
index 3db5b9529..ac4486157 100644
--- a/amethyst/src/main/res/values/strings.xml
+++ b/amethyst/src/main/res/values/strings.xml
@@ -1880,6 +1880,11 @@
Oldest
Relevance
Popular
+ Filters
+ Reset
+ Source
+ Sort by
+ Filters
Don\'t Translate From
Languages shown here will not be translated. Select a language to remove it and have it translated again.
diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/AdvancedSearchBarState.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/AdvancedSearchBarState.kt
index 88702439c..44b16a601 100644
--- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/AdvancedSearchBarState.kt
+++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/AdvancedSearchBarState.kt
@@ -84,10 +84,10 @@ class AdvancedSearchBarState(
val noteResults: StateFlow> = _noteResults.asStateFlow()
// Sort orders
- private val _eventSortOrder = MutableStateFlow(SearchSortOrder.DEFAULT_EVENT)
+ private val _eventSortOrder = MutableStateFlow(SearchSortOrder.EVENT_DEFAULT)
val eventSortOrder: StateFlow = _eventSortOrder.asStateFlow()
- private val _peopleSortOrder = MutableStateFlow(SearchSortOrder.DEFAULT_PEOPLE)
+ private val _peopleSortOrder = MutableStateFlow(SearchSortOrder.PEOPLE_DEFAULT)
val peopleSortOrder: StateFlow = _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()
}
diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/SearchSortOrder.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/SearchSortOrder.kt
index 490dc0e52..283af1dbf 100644
--- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/SearchSortOrder.kt
+++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/SearchSortOrder.kt
@@ -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
}
}