diff --git a/amethyst/src/fdroid/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/LegalSettingsSection.kt b/amethyst/src/fdroid/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/LegalSettingsSection.kt index 65bf2ce996..a4ee5ec78a 100644 --- a/amethyst/src/fdroid/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/LegalSettingsSection.kt +++ b/amethyst/src/fdroid/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/LegalSettingsSection.kt @@ -20,10 +20,8 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.settings -import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.UriHandler // F-Droid distributes Amethyst as MIT-licensed free software; the build must // not surface links to external (e.g. GitHub-hosted) policy documents. -@Composable -fun LegalSettingsSection() { -} +fun legalSettingsCategory(uriHandler: UriHandler): SettingsCategory? = null 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 7e4a5fc789..a402702772 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,14 +22,21 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.settings import android.widget.Toast import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +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.rememberScrollState +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.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Text @@ -39,15 +46,19 @@ 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.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.text.style.TextAlign 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 @@ -77,12 +88,37 @@ fun AllSettingsScreen( nav: INav, ) { val context = LocalContext.current + val uriHandler = LocalUriHandler.current val scope = rememberCoroutineScope() var showResetMarmotDialog by remember { mutableStateOf(false) } var isResettingMarmot by remember { mutableStateOf(false) } val scrollState = rememberScrollState() val hasPrivateKey = accountViewModel.account.settings.keyPair.privKey != null + val searchState = rememberTextFieldState() + val query = searchState.text.toString() + + // 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 + // `isResettingMarmot` through `rememberUpdatedState` so the memoized closure never goes stale. + val onResetMarmot by rememberUpdatedState(newValue = { if (!isResettingMarmot) showResetMarmotDialog = true }) + val catalog = + remember(hasPrivateKey, nav, uriHandler) { + buildSettingsCatalog( + nav = nav, + uriHandler = uriHandler, + hasPrivateKey = hasPrivateKey, + onResetMarmot = { onResetMarmot() }, + ) + } + + val filtered = + filterSettings( + catalog = catalog, + query = query, + stringLookup = { stringRes(context, it) }, + ) + Scaffold( topBar = { TopBarWithBackButton(stringRes(id = R.string.settings), nav) @@ -97,202 +133,31 @@ fun AllSettingsScreen( } }, ) { padding -> - Column( - modifier = - Modifier - .padding(padding) - .verticalScroll(scrollState) - .padding(horizontal = 16.dp, vertical = 12.dp), - verticalArrangement = Arrangement.spacedBy(20.dp), - ) { - SettingsSection(R.string.account_settings) { - SettingsItem( - title = R.string.relay_setup, - iconPainter = R.drawable.relays, - iconPainterRef = 4, - onClick = { nav.nav(Route.EditRelays) }, - ) - SettingsDivider() - SettingsItem( - title = R.string.event_sync_title, - icon = MaterialSymbols.Sync, - onClick = { nav.nav(Route.EventSync) }, - ) - SettingsDivider() - SettingsItem( - title = R.string.route_import_follows, - icon = MaterialSymbols.GroupAdd, - onClick = { nav.nav(Route.ImportFollowsSelectUser) }, - ) - SettingsDivider() - SettingsItem( - title = R.string.media_servers, - icon = MaterialSymbols.CloudUpload, - onClick = { nav.nav(Route.EditMediaServers) }, - ) - SettingsDivider() - SettingsItem( - title = R.string.nests_servers_title, - icon = MaterialSymbols.CloudUpload, - onClick = { nav.nav(Route.EditNestsServers) }, - ) - SettingsDivider() - SettingsItem( - title = R.string.profile_badges_title, - icon = MaterialSymbols.MilitaryTech, - onClick = { nav.nav(Route.ProfileBadges) }, - ) - SettingsDivider() - SettingsItem( - title = R.string.favorite_dvms_title, - icon = MaterialSymbols.AutoAwesome, - onClick = { nav.nav(Route.EditFavoriteAlgoFeeds) }, - ) - SettingsDivider() - SettingsItem( - title = R.string.reactions, - icon = MaterialSymbols.FavoriteBorder, - onClick = { nav.nav(Route.UpdateReactionType) }, - ) - SettingsDivider() - SettingsItem( - title = R.string.video_player_settings, - icon = MaterialSymbols.VideoSettings, - onClick = { nav.nav(Route.VideoPlayerSettings) }, - ) - SettingsDivider() - SettingsItem( - title = R.string.zaps, - icon = MaterialSymbols.Bolt, - onClick = { nav.nav(Route.UpdateZapAmount()) }, - ) - SettingsDivider() - SettingsItem( - title = R.string.payment_targets, - icon = MaterialSymbols.Payment, - onClick = { nav.nav(Route.EditPaymentTargets) }, - ) - SettingsDivider() - SettingsItem( - title = R.string.security_filters, - icon = MaterialSymbols.Security, - onClick = { nav.nav(Route.SecurityFilters) }, - ) - SettingsDivider() - SettingsItem( - title = R.string.call_settings, - icon = MaterialSymbols.Phone, - onClick = { nav.nav(Route.CallSettings) }, - ) - SettingsDivider() - SettingsItem( - title = R.string.translations, - icon = MaterialSymbols.Translate, - onClick = { nav.nav(Route.UserSettings) }, - ) - } + Column(modifier = Modifier.padding(padding).fillMaxSize()) { + SettingsSearchField( + state = searchState, + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 12.dp), + ) - SettingsSection(R.string.app_settings) { - SettingsItem( - title = R.string.privacy_options, - iconPainter = R.drawable.ic_tor, - iconPainterRef = 1, - onClick = { nav.nav(Route.PrivacyOptions) }, + if (filtered.isEmpty()) { + SettingsSearchEmptyState( + query = query, + modifier = Modifier.fillMaxSize(), ) - SettingsDivider() - SettingsItem( - title = R.string.ots_explorer_settings, - icon = MaterialSymbols.Search, - onClick = { nav.nav(Route.OtsSettings) }, - ) - SettingsDivider() - SettingsItem( - title = R.string.namecoin_settings, - icon = MaterialSymbols.Security, - onClick = { nav.nav(Route.NamecoinSettings) }, - ) - SettingsDivider() - SettingsItem( - title = R.string.ui_preferences, - icon = MaterialSymbols.Settings, - onClick = { nav.nav(Route.Settings) }, - ) - SettingsDivider() - SettingsItem( - title = R.string.notification_settings, - icon = MaterialSymbols.Notifications, - onClick = { nav.nav(Route.NotificationSettings) }, - ) - SettingsDivider() - SettingsItem( - title = R.string.calendar_reminder_settings_title, - icon = MaterialSymbols.CalendarMonth, - onClick = { nav.nav(Route.CalendarReminderSettings) }, - ) - SettingsDivider() - SettingsItem( - title = R.string.compose_settings, - icon = MaterialSymbols.Edit, - onClick = { nav.nav(Route.ComposeSettings) }, - ) - SettingsDivider() - SettingsItem( - title = R.string.reactions_settings, - icon = MaterialSymbols.ThumbUp, - onClick = { nav.nav(Route.ReactionsSettings) }, - ) - SettingsDivider() - SettingsItem( - title = R.string.bottom_bar_settings, - icon = MaterialSymbols.Dashboard, - onClick = { nav.nav(Route.BottomBarSettings) }, - ) - SettingsDivider() - SettingsItem( - title = R.string.home_tabs_settings, - icon = MaterialSymbols.Home, - onClick = { nav.nav(Route.HomeTabsSettings) }, - ) - SettingsDivider() - SettingsItem( - title = R.string.profile_ui_settings, - icon = MaterialSymbols.AccountCircle, - onClick = { nav.nav(Route.ProfileUiSettings) }, - ) - } - - LegalSettingsSection() - - SettingsSection(R.string.danger_zone, isDanger = true) { - if (hasPrivateKey) { - SettingsItem( - title = R.string.backup_keys, - icon = MaterialSymbols.Key, - isDanger = true, - onClick = { nav.nav(Route.AccountBackup) }, - ) - SettingsDivider() - SettingsItem( - title = R.string.request_to_vanish, - icon = MaterialSymbols.DeleteForever, - isDanger = true, - onClick = { nav.nav(Route.RequestToVanish) }, - ) - SettingsDivider() + } else { + Column( + modifier = + Modifier + .verticalScroll(scrollState) + .padding(horizontal = 16.dp) + .padding(bottom = 12.dp), + verticalArrangement = Arrangement.spacedBy(20.dp), + ) { + filtered.forEach { category -> SettingsCategoryCard(category) } } - SettingsItem( - title = R.string.vanish_history, - icon = MaterialSymbols.History, - isDanger = true, - onClick = { nav.nav(Route.VanishEvents) }, - ) - SettingsDivider() - SettingsItem( - title = R.string.reset_marmot_state, - icon = MaterialSymbols.DeleteSweep, - isDanger = true, - onClick = { if (!isResettingMarmot) showResetMarmotDialog = true }, - ) } } } @@ -325,6 +190,89 @@ fun AllSettingsScreen( } } +@Composable +private fun SettingsSearchField( + state: TextFieldState, + modifier: Modifier = Modifier, +) { + 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, + ) + }, + trailingIcon = + if (state.text.isNotEmpty()) { + { + IconButton(onClick = { state.clearText() }) { + Icon( + symbol = MaterialSymbols.Close, + contentDescription = stringRes(R.string.clear), + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } else { + null + }, + ) +} + +@Composable +private fun SettingsSearchEmptyState( + query: String, + modifier: Modifier = Modifier, +) { + Box(modifier = modifier, contentAlignment = Alignment.Center) { + Text( + text = stringRes(R.string.settings_search_no_results, query), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + modifier = Modifier.padding(32.dp), + ) + } +} + +@Composable +private fun SettingsCategoryCard(category: SettingsCategory) { + SettingsSection(category.titleRes, category.isDanger) { + category.entries.forEachIndexed { index, entry -> + if (index > 0) SettingsDivider() + SettingsEntryRow(entry) + } + } +} + +@Composable +private fun SettingsEntryRow(entry: SettingsEntry) { + when (val icon = entry.icon) { + is SettingsIcon.Symbol -> + SettingsItem( + title = entry.titleRes, + icon = icon.symbol, + isDanger = entry.isDanger, + onClick = entry.onClick, + ) + is SettingsIcon.Painter -> + SettingsItem( + title = entry.titleRes, + iconPainter = icon.iconPainter, + iconPainterRef = icon.iconPainterRef, + isDanger = entry.isDanger, + onClick = entry.onClick, + ) + } +} + @Composable private fun ResetMarmotStateDialog( onConfirm: () -> Unit, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SettingsCatalog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SettingsCatalog.kt new file mode 100644 index 0000000000..8d84d780db --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SettingsCatalog.kt @@ -0,0 +1,92 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.settings + +import androidx.annotation.StringRes +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol + +/** Leading-icon representation mirroring the two [SettingsItem] overloads. */ +sealed interface SettingsIcon { + data class Symbol( + val symbol: MaterialSymbol, + ) : SettingsIcon + + data class Painter( + val iconPainter: Int, + val iconPainterRef: Int, + ) : SettingsIcon +} + +/** + * One row on the settings screen. [keywordsRes] is an optional resource of extra search + * terms for the destination sub-screen (English, non-translated). The value is split into + * words on any whitespace/punctuation run and each word is independently prefix-matched, so + * separators are interchangeable and multi-word phrases ("zap split") carry no phrase grouping. + */ +data class SettingsEntry( + @StringRes val titleRes: Int, + val icon: SettingsIcon, + @StringRes val keywordsRes: Int? = null, + val isDanger: Boolean = false, + val onClick: () -> Unit, +) + +/** One category card on the settings screen. */ +data class SettingsCategory( + @StringRes val titleRes: Int, + val isDanger: Boolean = false, + val entries: List, +) + +private val SEARCH_DELIMITERS = Regex("[^\\p{L}\\p{N}]+") + +/** Lowercases and splits text into searchable words on any non-letter/digit run. */ +private fun String.searchWords(): List = lowercase().split(SEARCH_DELIMITERS).filter { it.isNotEmpty() } + +/** + * Filters [catalog] by [query] using case-insensitive **word-prefix** matching over + * category title + entry title + keywords: an entry matches when every word in the + * query is the prefix of some word in that haystack (so `dark mo` matches "dark mode", + * but `tor` does not match "his**tor**y"). Blank/whitespace query returns [catalog] + * unchanged. Categories left with no matching entries are dropped. Pure — no + * Compose/Android — so it is unit-testable; string-resource resolution is injected + * via [stringLookup]. + */ +fun filterSettings( + catalog: List, + query: String, + stringLookup: (Int) -> String, +): List { + val terms = query.searchWords() + if (terms.isEmpty()) return catalog + + return catalog.mapNotNull { category -> + val categoryWords = stringLookup(category.titleRes).searchWords() + val matched = + category.entries.filter { entry -> + val words = + categoryWords + stringLookup(entry.titleRes).searchWords() + + entry.keywordsRes?.let { stringLookup(it).searchWords() }.orEmpty() + terms.all { term -> words.any { it.startsWith(term) } } + } + if (matched.isEmpty()) null else category.copy(entries = matched) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SettingsCatalogBuilder.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SettingsCatalogBuilder.kt new file mode 100644 index 0000000000..d6c9909f5a --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SettingsCatalogBuilder.kt @@ -0,0 +1,150 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.settings + +import androidx.annotation.StringRes +import androidx.compose.ui.platform.UriHandler +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.Route + +/** + * Assembles the full settings catalog. Not composable: actions close over [nav], + * [uriHandler], and [onResetMarmot]; conditional rows are included via [hasPrivateKey]. + * The blank-query render of this catalog must match the legacy hardcoded screen. + */ +fun buildSettingsCatalog( + nav: INav, + uriHandler: UriHandler, + hasPrivateKey: Boolean, + onResetMarmot: () -> Unit, +): List { + // Most rows are a symbol icon + a keyword blob that navigates to a route. This local + // helper collapses that shape to one line per row and makes a mismatched keyword/route + // obvious. Painter-icon rows and the danger rows below are spelled out explicitly. + fun symEntry( + @StringRes titleRes: Int, + symbol: MaterialSymbol, + @StringRes keywordsRes: Int, + route: Route, + ) = SettingsEntry( + titleRes = titleRes, + icon = SettingsIcon.Symbol(symbol), + keywordsRes = keywordsRes, + ) { nav.nav(route) } + + val account = + SettingsCategory( + titleRes = R.string.account_settings, + entries = + listOf( + SettingsEntry( + titleRes = R.string.relay_setup, + icon = SettingsIcon.Painter(R.drawable.relays, 4), + keywordsRes = R.string.relay_setup_search_keywords, + ) { nav.nav(Route.EditRelays) }, + symEntry(R.string.event_sync_title, MaterialSymbols.Sync, R.string.event_sync_search_keywords, Route.EventSync), + symEntry(R.string.route_import_follows, MaterialSymbols.GroupAdd, R.string.import_follows_search_keywords, Route.ImportFollowsSelectUser), + symEntry(R.string.media_servers, MaterialSymbols.CloudUpload, R.string.media_servers_search_keywords, Route.EditMediaServers), + symEntry(R.string.nests_servers_title, MaterialSymbols.CloudUpload, R.string.nests_servers_search_keywords, Route.EditNestsServers), + symEntry(R.string.profile_badges_title, MaterialSymbols.MilitaryTech, R.string.profile_badges_search_keywords, Route.ProfileBadges), + symEntry(R.string.favorite_dvms_title, MaterialSymbols.AutoAwesome, R.string.favorite_dvms_search_keywords, Route.EditFavoriteAlgoFeeds), + symEntry(R.string.reactions, MaterialSymbols.FavoriteBorder, R.string.reactions_search_keywords, Route.UpdateReactionType), + symEntry(R.string.video_player_settings, MaterialSymbols.VideoSettings, R.string.video_player_search_keywords, Route.VideoPlayerSettings), + symEntry(R.string.zaps, MaterialSymbols.Bolt, R.string.zaps_search_keywords, Route.UpdateZapAmount()), + symEntry(R.string.payment_targets, MaterialSymbols.Payment, R.string.payment_targets_search_keywords, Route.EditPaymentTargets), + symEntry(R.string.security_filters, MaterialSymbols.Security, R.string.security_filters_search_keywords, Route.SecurityFilters), + symEntry(R.string.call_settings, MaterialSymbols.Phone, R.string.call_settings_search_keywords, Route.CallSettings), + symEntry(R.string.translations, MaterialSymbols.Translate, R.string.translations_search_keywords, Route.UserSettings), + ), + ) + + val app = + SettingsCategory( + titleRes = R.string.app_settings, + entries = + listOf( + SettingsEntry( + titleRes = R.string.privacy_options, + icon = SettingsIcon.Painter(R.drawable.ic_tor, 1), + keywordsRes = R.string.privacy_options_search_keywords, + ) { nav.nav(Route.PrivacyOptions) }, + symEntry(R.string.ots_explorer_settings, MaterialSymbols.Search, R.string.ots_explorer_search_keywords, Route.OtsSettings), + symEntry(R.string.namecoin_settings, MaterialSymbols.Security, R.string.namecoin_search_keywords, Route.NamecoinSettings), + symEntry(R.string.ui_preferences, MaterialSymbols.Settings, R.string.ui_preferences_search_keywords, Route.Settings), + symEntry(R.string.notification_settings, MaterialSymbols.Notifications, R.string.notification_settings_search_keywords, Route.NotificationSettings), + symEntry(R.string.calendar_reminder_settings_title, MaterialSymbols.CalendarMonth, R.string.calendar_reminder_search_keywords, Route.CalendarReminderSettings), + symEntry(R.string.compose_settings, MaterialSymbols.Edit, R.string.compose_search_keywords, Route.ComposeSettings), + symEntry(R.string.reactions_settings, MaterialSymbols.ThumbUp, R.string.reactions_settings_search_keywords, Route.ReactionsSettings), + symEntry(R.string.bottom_bar_settings, MaterialSymbols.Dashboard, R.string.bottom_bar_search_keywords, Route.BottomBarSettings), + symEntry(R.string.home_tabs_settings, MaterialSymbols.Home, R.string.home_tabs_search_keywords, Route.HomeTabsSettings), + symEntry(R.string.profile_ui_settings, MaterialSymbols.AccountCircle, R.string.profile_ui_search_keywords, Route.ProfileUiSettings), + ), + ) + + val danger = + SettingsCategory( + titleRes = R.string.danger_zone, + isDanger = true, + entries = + buildList { + if (hasPrivateKey) { + add( + SettingsEntry( + titleRes = R.string.backup_keys, + icon = SettingsIcon.Symbol(MaterialSymbols.Key), + keywordsRes = R.string.backup_keys_search_keywords, + isDanger = true, + ) { nav.nav(Route.AccountBackup) }, + ) + add( + SettingsEntry( + titleRes = R.string.request_to_vanish, + icon = SettingsIcon.Symbol(MaterialSymbols.DeleteForever), + keywordsRes = R.string.request_to_vanish_search_keywords, + isDanger = true, + ) { nav.nav(Route.RequestToVanish) }, + ) + } + add( + SettingsEntry( + titleRes = R.string.vanish_history, + icon = SettingsIcon.Symbol(MaterialSymbols.History), + keywordsRes = R.string.vanish_history_search_keywords, + isDanger = true, + ) { nav.nav(Route.VanishEvents) }, + ) + add( + SettingsEntry( + titleRes = R.string.reset_marmot_state, + icon = SettingsIcon.Symbol(MaterialSymbols.DeleteSweep), + keywordsRes = R.string.reset_marmot_search_keywords, + isDanger = true, + onClick = onResetMarmot, + ), + ) + }, + ) + + return listOfNotNull(account, app, legalSettingsCategory(uriHandler), danger) +} diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 7da1484f55..eb9f7602a7 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -1504,6 +1504,46 @@ Settings Account Settings App Settings + + + Search settings + No settings found for \"%1$s\" + + + relays, inbox, outbox, connections, servers + tor, orbot, proxy, privacy, anonymous + dark mode, light mode, theme, font size, language, appearance + push, alerts, sounds, vibration + spam, block, mute, filter, warnings + lightning, sats, tips, wallet, amount + blossom, uploads, images, photos, files, cdn, storage + negentropy, sync, reconcile, backfill + contacts, follows, follow list, import + audio rooms, live, spaces, rooms + badges, awards + dvm, data vending machine, algo, algorithm, feeds + emoji, like, reaction + video, player, playback, autoplay, mute + zap split, split, recipients, forward zaps + webrtc, video call, voice call, calls + language, translate, locale + opentimestamps, timestamp, ots, proof + namecoin, dns, identity, name + calendar, events, reminders, rsvp + draft, posting, editor, auto-save + emoji, reactions, like + navigation, tabs, nav bar + tabs, feeds, threads, conversations + profile, layout + nsec, private key, seed, mnemonic, export + delete account, vanish, gdpr + deletion, delete events + mls, group chat, messaging, reset + legal, terms, tos, policy, gdpr + legal, safety, abuse, csae, child protection + Danger Zone Reset Marmot State Reset Marmot State? diff --git a/amethyst/src/play/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/LegalSettingsSection.kt b/amethyst/src/play/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/LegalSettingsSection.kt index 0f17e6ca28..8c9bf6f5d9 100644 --- a/amethyst/src/play/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/LegalSettingsSection.kt +++ b/amethyst/src/play/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/LegalSettingsSection.kt @@ -20,38 +20,39 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.settings -import androidx.compose.runtime.Composable -import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.platform.UriHandler import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols -@Composable -fun LegalSettingsSection() { - val uriHandler = LocalUriHandler.current - - SettingsSection(R.string.about_legal) { - SettingsItem( - title = R.string.privacy_policy, - icon = MaterialSymbols.Lock, - onClick = { - runCatching { - uriHandler.openUri( - "https://github.com/vitorpamplona/amethyst/blob/main/PRIVACY.md", - ) - } - }, - ) - SettingsDivider() - SettingsItem( - title = R.string.child_safety_standards, - icon = MaterialSymbols.Shield, - onClick = { - runCatching { - uriHandler.openUri( - "https://github.com/vitorpamplona/amethyst/blob/main/PRIVACY.md#child-safety-standards", - ) - } - }, - ) - } -} +/** Play build surfaces the GitHub-hosted legal policy links. */ +fun legalSettingsCategory(uriHandler: UriHandler): SettingsCategory? = + SettingsCategory( + titleRes = R.string.about_legal, + entries = + listOf( + SettingsEntry( + titleRes = R.string.privacy_policy, + icon = SettingsIcon.Symbol(MaterialSymbols.Lock), + keywordsRes = R.string.privacy_policy_search_keywords, + onClick = { + runCatching { + uriHandler.openUri( + "https://github.com/vitorpamplona/amethyst/blob/main/PRIVACY.md", + ) + } + }, + ), + SettingsEntry( + titleRes = R.string.child_safety_standards, + icon = SettingsIcon.Symbol(MaterialSymbols.Shield), + keywordsRes = R.string.child_safety_search_keywords, + onClick = { + runCatching { + uriHandler.openUri( + "https://github.com/vitorpamplona/amethyst/blob/main/PRIVACY.md#child-safety-standards", + ) + } + }, + ), + ), + ) diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SettingsCatalogFilterTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SettingsCatalogFilterTest.kt new file mode 100644 index 0000000000..f044f28fd7 --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SettingsCatalogFilterTest.kt @@ -0,0 +1,148 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.settings + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class SettingsCatalogFilterTest { + private val strings = + mapOf( + 100 to "Account Settings", + 200 to "Danger Zone", + 1 to "Relay Setup", + 2 to "UI Preferences", + 3 to "Backup Keys", + 20 to "dark mode, theme, font size", + ) + + private fun entry( + titleRes: Int, + keywordsRes: Int? = null, + isDanger: Boolean = false, + ) = SettingsEntry( + titleRes = titleRes, + icon = SettingsIcon.Painter(0, 0), + keywordsRes = keywordsRes, + isDanger = isDanger, + onClick = {}, + ) + + private val catalog = + listOf( + SettingsCategory( + titleRes = 100, + entries = + listOf( + entry(1), + entry(2, keywordsRes = 20), + ), + ), + SettingsCategory( + titleRes = 200, + isDanger = true, + entries = listOf(entry(3, isDanger = true)), + ), + ) + + private fun run(query: String) = + filterSettings( + catalog = catalog, + query = query, + stringLookup = { strings.getValue(it) }, + ) + + @Test + fun blankQueryReturnsFullCatalog() { + val result = run("") + assertEquals(2, result.size) + assertEquals(2, result[0].entries.size) + assertEquals(1, result[1].entries.size) + } + + @Test + fun whitespaceQueryReturnsFullCatalog() { + assertEquals(2, run(" ").size) + } + + @Test + fun titleMatchIsCaseInsensitive() { + val result = run("relay") + assertEquals(1, result.size) + assertEquals(100, result[0].titleRes) + assertEquals(1, result[0].entries.size) + assertEquals(1, result[0].entries[0].titleRes) + } + + @Test + fun keywordMatchSurfacesEntryWhoseTitleDoesNotMatch() { + val result = run("dark mode") + assertEquals(1, result.size) + assertEquals(2, result[0].entries[0].titleRes) // UI Preferences, matched via keywords + } + + @Test + fun categoryTitleMatchSurfacesWholeCategory() { + val result = run("account") + assertEquals(1, result.size) + assertEquals(100, result[0].titleRes) + assertEquals(2, result[0].entries.size) // both rows shown because the category name matched + } + + @Test + fun categoryWithNoMatchesIsDropped() { + val result = run("relay") + assertTrue(result.none { it.titleRes == 200 }) + } + + @Test + fun noMatchesReturnsEmptyList() { + assertTrue(run("zzzznomatch").isEmpty()) + } + + @Test + fun dangerFlagsPreservedThroughFiltering() { + val result = run("backup") + assertEquals(1, result.size) + assertTrue(result[0].isDanger) + assertTrue(result[0].entries[0].isDanger) + } + + @Test + fun prefixOfAWordMatches() { + // "rel" is a prefix of "Relay" (title); "the" is a prefix of "theme" (keyword). + assertEquals(1, run("rel")[0].entries[0].titleRes) + assertEquals(2, run("the")[0].entries[0].titleRes) + } + + @Test + fun midWordTermDoesNotMatch() { + // Word-prefix, not substring: "ackup" is inside "Backup" but not a prefix of any word. + assertTrue(run("ackup").isEmpty()) + } + + @Test + fun everyQueryTermMustPrefixSomeWord() { + assertEquals(2, run("dark size")[0].entries[0].titleRes) // both terms hit UI Preferences + assertTrue(run("dark zzz").isEmpty()) // second term matches nothing + } +}