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 } }