From e581f9c6da59827c9557d805ec180fda806a20b5 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 17 Jul 2026 04:17:29 +0000 Subject: [PATCH 1/3] feat: collapse settings search into an expandable top-bar icon The Settings screen previously showed a permanent search text field below the top bar. Replace it with a search icon in the top bar's actions slot that expands into an inline text field when pressed. - Collapsed: shows the "Settings" title plus a search icon action. - Expanded: the title becomes an auto-focused inline search field, the back arrow (and system back) collapses it and clears the query, and a clear (X) action appears while there is text. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01PQYnKXff1esD1bdPGNzyHp --- .../loggedIn/settings/AllSettingsScreen.kt | 138 ++++++++++++++---- 1 file changed, 106 insertions(+), 32 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/AllSettingsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/AllSettingsScreen.kt index a402702772..b7814ec753 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/AllSettingsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/AllSettingsScreen.kt @@ -21,6 +21,8 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.settings import android.widget.Toast +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -29,6 +31,8 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.input.TextFieldLineLimits import androidx.compose.foundation.text.input.TextFieldState import androidx.compose.foundation.text.input.clearText import androidx.compose.foundation.text.input.rememberTextFieldState @@ -36,12 +40,15 @@ import androidx.compose.foundation.verticalScroll import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.IconButton +import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -50,24 +57,29 @@ import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols -import com.vitorpamplona.amethyst.ui.components.OutlinedThinPaddingTextField import com.vitorpamplona.amethyst.ui.navigation.bottombars.AppBottomBar import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.routes.Route -import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton +import com.vitorpamplona.amethyst.ui.navigation.topbars.ShorterTopAppBar +import com.vitorpamplona.amethyst.ui.note.ArrowBackIcon import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn +import com.vitorpamplona.amethyst.ui.theme.placeholderText import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -97,6 +109,17 @@ fun AllSettingsScreen( val searchState = rememberTextFieldState() val query = searchState.text.toString() + var isSearchExpanded by remember { mutableStateOf(false) } + + val collapseSearch = { + isSearchExpanded = false + searchState.clearText() + } + + // While the field is expanded, the system back gesture collapses it (and clears + // the query) instead of leaving the screen. Disabled otherwise so back behaves + // normally. + BackHandler(enabled = isSearchExpanded) { collapseSearch() } // The catalog is structurally stable for the screen's lifetime, so it is rebuilt only when // an input actually changes — not on every keystroke. `onResetMarmot` reads the volatile @@ -121,7 +144,13 @@ fun AllSettingsScreen( Scaffold( topBar = { - TopBarWithBackButton(stringRes(id = R.string.settings), nav) + SettingsSearchTopBar( + searchState = searchState, + isSearchExpanded = isSearchExpanded, + onExpandSearch = { isSearchExpanded = true }, + onCollapseSearch = collapseSearch, + nav = nav, + ) }, bottomBar = { AppBottomBar(Route.AllSettings, nav, accountViewModel) { route -> @@ -134,14 +163,6 @@ fun AllSettingsScreen( }, ) { padding -> Column(modifier = Modifier.padding(padding).fillMaxSize()) { - SettingsSearchField( - state = searchState, - modifier = - Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 12.dp), - ) - if (filtered.isEmpty()) { SettingsSearchEmptyState( query = query, @@ -190,28 +211,45 @@ fun AllSettingsScreen( } } +@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) @Composable -private fun SettingsSearchField( - state: TextFieldState, - modifier: Modifier = Modifier, +private fun SettingsSearchTopBar( + searchState: TextFieldState, + isSearchExpanded: Boolean, + onExpandSearch: () -> Unit, + onCollapseSearch: () -> Unit, + nav: INav, ) { - OutlinedThinPaddingTextField( - state = state, - modifier = modifier, - singleLine = true, - placeholder = { Text(stringRes(R.string.settings_search_placeholder)) }, - leadingIcon = { - Icon( - symbol = MaterialSymbols.Search, - contentDescription = null, - modifier = Modifier.size(20.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) + ShorterTopAppBar( + title = { + if (isSearchExpanded) { + SettingsSearchInlineField(searchState) + } else { + Text( + text = stringRes(id = R.string.settings), + overflow = TextOverflow.Ellipsis, + maxLines = 1, + ) + } }, - trailingIcon = - if (state.text.isNotEmpty()) { - { - IconButton(onClick = { state.clearText() }) { + navigationIcon = { + // When searching, the back arrow collapses the field. Otherwise it pops the + // back stack (suppressed when this screen sits at the bottom of the stack, + // e.g. reached via the bottom nav). + if (isSearchExpanded) { + IconButton(onClick = onCollapseSearch) { + ArrowBackIcon() + } + } else if (nav.canPop()) { + IconButton(nav::popBack) { + ArrowBackIcon() + } + } + }, + actions = { + if (isSearchExpanded) { + if (searchState.text.isNotEmpty()) { + IconButton(onClick = { searchState.clearText() }) { Icon( symbol = MaterialSymbols.Close, contentDescription = stringRes(R.string.clear), @@ -221,8 +259,44 @@ private fun SettingsSearchField( } } } else { - null - }, + IconButton(onClick = onExpandSearch) { + Icon( + symbol = MaterialSymbols.Search, + contentDescription = stringRes(R.string.settings_search_placeholder), + modifier = Modifier.size(20.dp), + ) + } + } + }, + ) +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun SettingsSearchInlineField(searchState: TextFieldState) { + val focusRequester = remember { FocusRequester() } + + // Auto-focus and open the keyboard the moment the field expands. + LaunchedEffect(Unit) { focusRequester.requestFocus() } + + BasicTextField( + state = searchState, + modifier = Modifier.fillMaxWidth().focusRequester(focusRequester), + textStyle = LocalTextStyle.current.copy(color = MaterialTheme.colorScheme.onSurface), + lineLimits = TextFieldLineLimits.SingleLine, + cursorBrush = SolidColor(MaterialTheme.colorScheme.primary), + decorator = { innerTextField -> + Box(contentAlignment = Alignment.CenterStart) { + if (searchState.text.isEmpty()) { + Text( + text = stringRes(R.string.settings_search_placeholder), + style = LocalTextStyle.current, + color = MaterialTheme.colorScheme.placeholderText, + ) + } + innerTextField() + } + }, ) } From d11fb74e4fdf37b70fc637482f2e4123fc1025ec Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 17 Jul 2026 13:04:48 +0000 Subject: [PATCH 2/3] feat: use Material 3 DockedSearchBar for settings search Switch the Settings search from a custom expand-into-the-top-bar icon to the Material 3 DockedSearchBar component, matching the modern in-page search convention (e.g. Android Settings). - A persistent search pill sits below the "Settings" title bar. - Tapping it expands the docked results dropdown, listing the filtered settings (a blank query lists everything, narrowing as the user types). - The back arrow, the system back gesture, and picking a result all collapse the bar and clear the query. - The full categorized settings list shows below the pill while collapsed. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01PQYnKXff1esD1bdPGNzyHp --- .../loggedIn/settings/AllSettingsScreen.kt | 229 ++++++++---------- 1 file changed, 101 insertions(+), 128 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/AllSettingsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/AllSettingsScreen.kt index b7814ec753..b031fd881d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/AllSettingsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/AllSettingsScreen.kt @@ -22,33 +22,32 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.settings import android.widget.Toast import androidx.activity.compose.BackHandler -import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.text.BasicTextField -import androidx.compose.foundation.text.input.TextFieldLineLimits -import androidx.compose.foundation.text.input.TextFieldState import androidx.compose.foundation.text.input.clearText import androidx.compose.foundation.text.input.rememberTextFieldState import androidx.compose.foundation.verticalScroll import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.DockedSearchBar import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.IconButton -import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold +import androidx.compose.material3.SearchBarDefaults import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -57,13 +56,9 @@ import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.R @@ -73,13 +68,12 @@ import com.vitorpamplona.amethyst.ui.navigation.bottombars.AppBottomBar import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.routes.Route -import com.vitorpamplona.amethyst.ui.navigation.topbars.ShorterTopAppBar +import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton import com.vitorpamplona.amethyst.ui.note.ArrowBackIcon import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn -import com.vitorpamplona.amethyst.ui.theme.placeholderText import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -94,6 +88,7 @@ fun AllSettingsScreenPreview() { } } +@OptIn(ExperimentalMaterial3Api::class) @Composable fun AllSettingsScreen( accountViewModel: AccountViewModel, @@ -109,17 +104,19 @@ fun AllSettingsScreen( val searchState = rememberTextFieldState() val query = searchState.text.toString() - var isSearchExpanded by remember { mutableStateOf(false) } + var searchExpanded by remember { mutableStateOf(false) } - val collapseSearch = { - isSearchExpanded = false - searchState.clearText() + // Single source of truth for the search bar's expanded/collapsed transitions. Collapsing — + // whether by the back arrow, the system back gesture, or picking a result — always clears the + // query so the field returns empty the next time it opens. + val onExpandedChange: (Boolean) -> Unit = { expanded -> + searchExpanded = expanded + if (!expanded) searchState.clearText() } - // While the field is expanded, the system back gesture collapses it (and clears - // the query) instead of leaving the screen. Disabled otherwise so back behaves - // normally. - BackHandler(enabled = isSearchExpanded) { collapseSearch() } + // While the results are open, the system back gesture collapses the bar instead of leaving the + // screen. Disabled otherwise so back behaves normally. + BackHandler(enabled = searchExpanded) { onExpandedChange(false) } // The catalog is structurally stable for the screen's lifetime, so it is rebuilt only when // an input actually changes — not on every keystroke. `onResetMarmot` reads the volatile @@ -144,13 +141,7 @@ fun AllSettingsScreen( Scaffold( topBar = { - SettingsSearchTopBar( - searchState = searchState, - isSearchExpanded = isSearchExpanded, - onExpandSearch = { isSearchExpanded = true }, - onCollapseSearch = collapseSearch, - nav = nav, - ) + TopBarWithBackButton(stringRes(id = R.string.settings), nav) }, bottomBar = { AppBottomBar(Route.AllSettings, nav, accountViewModel) { route -> @@ -163,12 +154,73 @@ fun AllSettingsScreen( }, ) { padding -> Column(modifier = Modifier.padding(padding).fillMaxSize()) { - if (filtered.isEmpty()) { - SettingsSearchEmptyState( - query = query, - modifier = Modifier.fillMaxSize(), - ) - } else { + DockedSearchBar( + inputField = { + SearchBarDefaults.InputField( + state = searchState, + onSearch = {}, + expanded = searchExpanded, + onExpandedChange = onExpandedChange, + placeholder = { Text(stringRes(R.string.settings_search_placeholder)) }, + leadingIcon = { + if (searchExpanded) { + IconButton(onClick = { onExpandedChange(false) }) { + ArrowBackIcon() + } + } else { + Icon( + symbol = MaterialSymbols.Search, + contentDescription = null, + modifier = Modifier.size(20.dp), + ) + } + }, + trailingIcon = { + if (query.isNotEmpty()) { + IconButton(onClick = { searchState.clearText() }) { + Icon( + symbol = MaterialSymbols.Close, + contentDescription = stringRes(R.string.clear), + modifier = Modifier.size(20.dp), + ) + } + } + }, + ) + }, + expanded = searchExpanded, + onExpandedChange = onExpandedChange, + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 8.dp), + ) { + // Expanded: filtered results are shown inside the docked bar's dropdown. A blank + // query lists the whole catalog, narrowing as the user types. + if (filtered.isEmpty()) { + SettingsSearchEmptyState( + query = query, + modifier = Modifier.fillMaxWidth().padding(vertical = 24.dp), + ) + } else { + LazyColumn( + modifier = Modifier.fillMaxWidth(), + contentPadding = PaddingValues(horizontal = 16.dp, vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(20.dp), + ) { + items(filtered) { category -> + SettingsCategoryCard( + category = category, + onEntryClick = { onExpandedChange(false) }, + ) + } + } + } + } + + // Collapsed: the full categorized settings list. Hidden while the results dropdown is + // open so it doesn't peek out beneath it. + if (!searchExpanded) { Column( modifier = Modifier @@ -177,7 +229,7 @@ fun AllSettingsScreen( .padding(bottom = 12.dp), verticalArrangement = Arrangement.spacedBy(20.dp), ) { - filtered.forEach { category -> SettingsCategoryCard(category) } + catalog.forEach { category -> SettingsCategoryCard(category) } } } } @@ -211,95 +263,6 @@ fun AllSettingsScreen( } } -@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) -@Composable -private fun SettingsSearchTopBar( - searchState: TextFieldState, - isSearchExpanded: Boolean, - onExpandSearch: () -> Unit, - onCollapseSearch: () -> Unit, - nav: INav, -) { - ShorterTopAppBar( - title = { - if (isSearchExpanded) { - SettingsSearchInlineField(searchState) - } else { - Text( - text = stringRes(id = R.string.settings), - overflow = TextOverflow.Ellipsis, - maxLines = 1, - ) - } - }, - navigationIcon = { - // When searching, the back arrow collapses the field. Otherwise it pops the - // back stack (suppressed when this screen sits at the bottom of the stack, - // e.g. reached via the bottom nav). - if (isSearchExpanded) { - IconButton(onClick = onCollapseSearch) { - ArrowBackIcon() - } - } else if (nav.canPop()) { - IconButton(nav::popBack) { - ArrowBackIcon() - } - } - }, - actions = { - if (isSearchExpanded) { - if (searchState.text.isNotEmpty()) { - IconButton(onClick = { searchState.clearText() }) { - Icon( - symbol = MaterialSymbols.Close, - contentDescription = stringRes(R.string.clear), - modifier = Modifier.size(20.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } - } else { - IconButton(onClick = onExpandSearch) { - Icon( - symbol = MaterialSymbols.Search, - contentDescription = stringRes(R.string.settings_search_placeholder), - modifier = Modifier.size(20.dp), - ) - } - } - }, - ) -} - -@OptIn(ExperimentalFoundationApi::class) -@Composable -private fun SettingsSearchInlineField(searchState: TextFieldState) { - val focusRequester = remember { FocusRequester() } - - // Auto-focus and open the keyboard the moment the field expands. - LaunchedEffect(Unit) { focusRequester.requestFocus() } - - BasicTextField( - state = searchState, - modifier = Modifier.fillMaxWidth().focusRequester(focusRequester), - textStyle = LocalTextStyle.current.copy(color = MaterialTheme.colorScheme.onSurface), - lineLimits = TextFieldLineLimits.SingleLine, - cursorBrush = SolidColor(MaterialTheme.colorScheme.primary), - decorator = { innerTextField -> - Box(contentAlignment = Alignment.CenterStart) { - if (searchState.text.isEmpty()) { - Text( - text = stringRes(R.string.settings_search_placeholder), - style = LocalTextStyle.current, - color = MaterialTheme.colorScheme.placeholderText, - ) - } - innerTextField() - } - }, - ) -} - @Composable private fun SettingsSearchEmptyState( query: String, @@ -317,24 +280,34 @@ private fun SettingsSearchEmptyState( } @Composable -private fun SettingsCategoryCard(category: SettingsCategory) { +private fun SettingsCategoryCard( + category: SettingsCategory, + onEntryClick: () -> Unit = {}, +) { SettingsSection(category.titleRes, category.isDanger) { category.entries.forEachIndexed { index, entry -> if (index > 0) SettingsDivider() - SettingsEntryRow(entry) + SettingsEntryRow(entry, onEntryClick) } } } @Composable -private fun SettingsEntryRow(entry: SettingsEntry) { +private fun SettingsEntryRow( + entry: SettingsEntry, + onEntryClick: () -> Unit = {}, +) { + val onClick = { + onEntryClick() + entry.onClick() + } when (val icon = entry.icon) { is SettingsIcon.Symbol -> SettingsItem( title = entry.titleRes, icon = icon.symbol, isDanger = entry.isDanger, - onClick = entry.onClick, + onClick = onClick, ) is SettingsIcon.Painter -> SettingsItem( @@ -342,7 +315,7 @@ private fun SettingsEntryRow(entry: SettingsEntry) { iconPainter = icon.iconPainter, iconPainterRef = icon.iconPainterRef, isDanger = entry.isDanger, - onClick = entry.onClick, + onClick = onClick, ) } } From 6f86c035b0fb1907c7a5bacb788ec9ccf656d07d Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 17 Jul 2026 14:12:04 +0000 Subject: [PATCH 3/3] feat: filter settings in place with a persistent search pill MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the Material 3 DockedSearchBar with a persistent, pill-shaped search field that filters the settings list in place instead of opening a results dropdown. The docked component is aimed at a search surface with its own results view (and the tablet/desktop form factor); an in-page filter — as in the Android system Settings app — is a better fit here. - Rounded, tonal search field pinned above the list (Material 3 look). - Typing narrows the categorized list directly; a blank query shows all. - Clear (X) resets the query; "no results" state when nothing matches. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01PQYnKXff1esD1bdPGNzyHp --- .../loggedIn/settings/AllSettingsScreen.kt | 186 ++++++++---------- 1 file changed, 81 insertions(+), 105 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/AllSettingsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/AllSettingsScreen.kt index b031fd881d..bc55763eee 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/AllSettingsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/AllSettingsScreen.kt @@ -21,41 +21,37 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.settings import android.widget.Toast -import androidx.activity.compose.BackHandler import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.text.input.clearText -import androidx.compose.foundation.text.input.rememberTextFieldState +import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.verticalScroll import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.DockedSearchBar -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold -import androidx.compose.material3.SearchBarDefaults import androidx.compose.material3.Text import androidx.compose.material3.TextButton +import androidx.compose.material3.TextField +import androidx.compose.material3.TextFieldDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.text.style.TextAlign @@ -69,7 +65,6 @@ import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton -import com.vitorpamplona.amethyst.ui.note.ArrowBackIcon import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel import com.vitorpamplona.amethyst.ui.stringRes @@ -88,7 +83,6 @@ fun AllSettingsScreenPreview() { } } -@OptIn(ExperimentalMaterial3Api::class) @Composable fun AllSettingsScreen( accountViewModel: AccountViewModel, @@ -102,21 +96,7 @@ fun AllSettingsScreen( val scrollState = rememberScrollState() val hasPrivateKey = accountViewModel.account.settings.keyPair.privKey != null - val searchState = rememberTextFieldState() - val query = searchState.text.toString() - var searchExpanded by remember { mutableStateOf(false) } - - // Single source of truth for the search bar's expanded/collapsed transitions. Collapsing — - // whether by the back arrow, the system back gesture, or picking a result — always clears the - // query so the field returns empty the next time it opens. - val onExpandedChange: (Boolean) -> Unit = { expanded -> - searchExpanded = expanded - if (!expanded) searchState.clearText() - } - - // While the results are open, the system back gesture collapses the bar instead of leaving the - // screen. Disabled otherwise so back behaves normally. - BackHandler(enabled = searchExpanded) { onExpandedChange(false) } + var query by rememberSaveable { mutableStateOf("") } // The catalog is structurally stable for the screen's lifetime, so it is rebuilt only when // an input actually changes — not on every keystroke. `onResetMarmot` reads the volatile @@ -154,73 +134,24 @@ fun AllSettingsScreen( }, ) { padding -> Column(modifier = Modifier.padding(padding).fillMaxSize()) { - DockedSearchBar( - inputField = { - SearchBarDefaults.InputField( - state = searchState, - onSearch = {}, - expanded = searchExpanded, - onExpandedChange = onExpandedChange, - placeholder = { Text(stringRes(R.string.settings_search_placeholder)) }, - leadingIcon = { - if (searchExpanded) { - IconButton(onClick = { onExpandedChange(false) }) { - ArrowBackIcon() - } - } else { - Icon( - symbol = MaterialSymbols.Search, - contentDescription = null, - modifier = Modifier.size(20.dp), - ) - } - }, - trailingIcon = { - if (query.isNotEmpty()) { - IconButton(onClick = { searchState.clearText() }) { - Icon( - symbol = MaterialSymbols.Close, - contentDescription = stringRes(R.string.clear), - modifier = Modifier.size(20.dp), - ) - } - } - }, - ) - }, - expanded = searchExpanded, - onExpandedChange = onExpandedChange, + SettingsSearchField( + query = query, + onQueryChange = { query = it }, + onClear = { query = "" }, modifier = Modifier .fillMaxWidth() - .padding(horizontal = 12.dp, vertical = 8.dp), - ) { - // Expanded: filtered results are shown inside the docked bar's dropdown. A blank - // query lists the whole catalog, narrowing as the user types. - if (filtered.isEmpty()) { - SettingsSearchEmptyState( - query = query, - modifier = Modifier.fillMaxWidth().padding(vertical = 24.dp), - ) - } else { - LazyColumn( - modifier = Modifier.fillMaxWidth(), - contentPadding = PaddingValues(horizontal = 16.dp, vertical = 12.dp), - verticalArrangement = Arrangement.spacedBy(20.dp), - ) { - items(filtered) { category -> - SettingsCategoryCard( - category = category, - onEntryClick = { onExpandedChange(false) }, - ) - } - } - } - } + .padding(horizontal = 16.dp, vertical = 12.dp), + ) - // Collapsed: the full categorized settings list. Hidden while the results dropdown is - // open so it doesn't peek out beneath it. - if (!searchExpanded) { + // Filtering happens in place: the list below is the search result. A blank query + // yields the whole catalog, narrowing as the user types. + if (filtered.isEmpty()) { + SettingsSearchEmptyState( + query = query, + modifier = Modifier.fillMaxSize(), + ) + } else { Column( modifier = Modifier @@ -229,7 +160,7 @@ fun AllSettingsScreen( .padding(bottom = 12.dp), verticalArrangement = Arrangement.spacedBy(20.dp), ) { - catalog.forEach { category -> SettingsCategoryCard(category) } + filtered.forEach { category -> SettingsCategoryCard(category) } } } } @@ -263,6 +194,61 @@ fun AllSettingsScreen( } } +/** + * Persistent, pill-shaped search field styled after the Material 3 search bar. It filters the + * settings list in place rather than opening a results dropdown — appropriate for an in-page + * filter (see the Android system Settings app). + */ +@Composable +private fun SettingsSearchField( + query: String, + onQueryChange: (String) -> Unit, + onClear: () -> Unit, + modifier: Modifier = Modifier, +) { + TextField( + value = query, + onValueChange = onQueryChange, + modifier = modifier, + singleLine = true, + shape = CircleShape, + placeholder = { Text(stringRes(R.string.settings_search_placeholder)) }, + leadingIcon = { + Icon( + symbol = MaterialSymbols.Search, + contentDescription = null, + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + }, + trailingIcon = + if (query.isNotEmpty()) { + { + IconButton(onClick = onClear) { + Icon( + symbol = MaterialSymbols.Close, + contentDescription = stringRes(R.string.clear), + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } else { + null + }, + colors = + TextFieldDefaults.colors( + focusedContainerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + unfocusedContainerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + disabledContainerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + focusedIndicatorColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent, + disabledIndicatorColor = Color.Transparent, + errorIndicatorColor = Color.Transparent, + ), + ) +} + @Composable private fun SettingsSearchEmptyState( query: String, @@ -280,34 +266,24 @@ private fun SettingsSearchEmptyState( } @Composable -private fun SettingsCategoryCard( - category: SettingsCategory, - onEntryClick: () -> Unit = {}, -) { +private fun SettingsCategoryCard(category: SettingsCategory) { SettingsSection(category.titleRes, category.isDanger) { category.entries.forEachIndexed { index, entry -> if (index > 0) SettingsDivider() - SettingsEntryRow(entry, onEntryClick) + SettingsEntryRow(entry) } } } @Composable -private fun SettingsEntryRow( - entry: SettingsEntry, - onEntryClick: () -> Unit = {}, -) { - val onClick = { - onEntryClick() - entry.onClick() - } +private fun SettingsEntryRow(entry: SettingsEntry) { when (val icon = entry.icon) { is SettingsIcon.Symbol -> SettingsItem( title = entry.titleRes, icon = icon.symbol, isDanger = entry.isDanger, - onClick = onClick, + onClick = entry.onClick, ) is SettingsIcon.Painter -> SettingsItem( @@ -315,7 +291,7 @@ private fun SettingsEntryRow( iconPainter = icon.iconPainter, iconPainterRef = icon.iconPainterRef, isDanger = entry.isDanger, - onClick = onClick, + onClick = entry.onClick, ) } }