feat(settings): add settings catalog data model + filterSettings

feat(settings): add search box that filters settings rows by title + keywords
refactor(settings): expose legal section as legalSettingsCategory factory
feat(settings): add search placeholder, empty-state, and keyword strings
test(settings): cover filterSettings (blank/title/keyword/category/empty/danger)
refactor(settings): match category title in search; use data classes
This commit is contained in:
davotoula
2026-06-03 09:20:54 +02:00
parent a4aff84897
commit 4446b12972
7 changed files with 519 additions and 229 deletions
@@ -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
@@ -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
@@ -40,14 +47,17 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
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 +87,32 @@ 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()
val catalog =
buildSettingsCatalog(
nav = nav,
uriHandler = uriHandler,
hasPrivateKey = hasPrivateKey,
onResetMarmot = { if (!isResettingMarmot) showResetMarmotDialog = true },
)
val filtered =
filterSettings(
catalog = catalog,
query = query,
titleLookup = { stringRes(context, it) },
keywordsLookup = { stringRes(context, it) },
)
Scaffold(
topBar = {
TopBarWithBackButton(stringRes(id = R.string.settings), nav)
@@ -97,202 +127,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 +184,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,
@@ -0,0 +1,84 @@
/*
* 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 comma-separated
* list of localized search terms describing the destination sub-screen. */
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<SettingsEntry>,
)
/**
* Filters [catalog] by [query] (case-insensitive substring over category title + entry title + keywords).
* Blank/whitespace query returns [catalog] unchanged. Categories left with no
* matching entries are dropped. Pure — no Compose/Android — so it is unit-testable;
* string resolution is injected via [titleLookup] / [keywordsLookup].
*/
fun filterSettings(
catalog: List<SettingsCategory>,
query: String,
titleLookup: (Int) -> String,
keywordsLookup: (Int) -> String,
): List<SettingsCategory> {
val needle = query.trim().lowercase()
if (needle.isEmpty()) return catalog
return catalog.mapNotNull { category ->
val categoryTitle = titleLookup(category.titleRes)
val matched =
category.entries.filter { entry ->
val keywords = entry.keywordsRes?.let { keywordsLookup(it) } ?: ""
val haystack = (categoryTitle + " " + titleLookup(entry.titleRes) + " " + keywords).lowercase()
haystack.contains(needle)
}
if (matched.isEmpty()) {
null
} else {
SettingsCategory(category.titleRes, category.isDanger, matched)
}
}
}
@@ -0,0 +1,121 @@
/*
* 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.compose.ui.platform.UriHandler
import com.vitorpamplona.amethyst.R
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<SettingsCategory> {
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) },
SettingsEntry(R.string.event_sync_title, SettingsIcon.Symbol(MaterialSymbols.Sync)) { nav.nav(Route.EventSync) },
SettingsEntry(R.string.route_import_follows, SettingsIcon.Symbol(MaterialSymbols.GroupAdd)) { nav.nav(Route.ImportFollowsSelectUser) },
SettingsEntry(R.string.media_servers, SettingsIcon.Symbol(MaterialSymbols.CloudUpload)) { nav.nav(Route.EditMediaServers) },
SettingsEntry(R.string.nests_servers_title, SettingsIcon.Symbol(MaterialSymbols.CloudUpload)) { nav.nav(Route.EditNestsServers) },
SettingsEntry(R.string.profile_badges_title, SettingsIcon.Symbol(MaterialSymbols.MilitaryTech)) { nav.nav(Route.ProfileBadges) },
SettingsEntry(R.string.favorite_dvms_title, SettingsIcon.Symbol(MaterialSymbols.AutoAwesome)) { nav.nav(Route.EditFavoriteAlgoFeeds) },
SettingsEntry(R.string.reactions, SettingsIcon.Symbol(MaterialSymbols.FavoriteBorder)) { nav.nav(Route.UpdateReactionType) },
SettingsEntry(R.string.video_player_settings, SettingsIcon.Symbol(MaterialSymbols.VideoSettings)) { nav.nav(Route.VideoPlayerSettings) },
SettingsEntry(
titleRes = R.string.zaps,
icon = SettingsIcon.Symbol(MaterialSymbols.Bolt),
keywordsRes = R.string.zaps_search_keywords,
) { nav.nav(Route.UpdateZapAmount()) },
SettingsEntry(R.string.payment_targets, SettingsIcon.Symbol(MaterialSymbols.Payment)) { nav.nav(Route.EditPaymentTargets) },
SettingsEntry(
titleRes = R.string.security_filters,
icon = SettingsIcon.Symbol(MaterialSymbols.Security),
keywordsRes = R.string.security_filters_search_keywords,
) { nav.nav(Route.SecurityFilters) },
SettingsEntry(R.string.call_settings, SettingsIcon.Symbol(MaterialSymbols.Phone)) { nav.nav(Route.CallSettings) },
SettingsEntry(R.string.translations, SettingsIcon.Symbol(MaterialSymbols.Translate)) { nav.nav(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) },
SettingsEntry(R.string.ots_explorer_settings, SettingsIcon.Symbol(MaterialSymbols.Search)) { nav.nav(Route.OtsSettings) },
SettingsEntry(R.string.namecoin_settings, SettingsIcon.Symbol(MaterialSymbols.Security)) { nav.nav(Route.NamecoinSettings) },
SettingsEntry(
titleRes = R.string.ui_preferences,
icon = SettingsIcon.Symbol(MaterialSymbols.Settings),
keywordsRes = R.string.ui_preferences_search_keywords,
) { nav.nav(Route.Settings) },
SettingsEntry(
titleRes = R.string.notification_settings,
icon = SettingsIcon.Symbol(MaterialSymbols.Notifications),
keywordsRes = R.string.notification_settings_search_keywords,
) { nav.nav(Route.NotificationSettings) },
SettingsEntry(R.string.calendar_reminder_settings_title, SettingsIcon.Symbol(MaterialSymbols.CalendarMonth)) { nav.nav(Route.CalendarReminderSettings) },
SettingsEntry(R.string.compose_settings, SettingsIcon.Symbol(MaterialSymbols.Edit)) { nav.nav(Route.ComposeSettings) },
SettingsEntry(R.string.reactions_settings, SettingsIcon.Symbol(MaterialSymbols.ThumbUp)) { nav.nav(Route.ReactionsSettings) },
SettingsEntry(R.string.bottom_bar_settings, SettingsIcon.Symbol(MaterialSymbols.Dashboard)) { nav.nav(Route.BottomBarSettings) },
SettingsEntry(R.string.home_tabs_settings, SettingsIcon.Symbol(MaterialSymbols.Home)) { nav.nav(Route.HomeTabsSettings) },
SettingsEntry(R.string.profile_ui_settings, SettingsIcon.Symbol(MaterialSymbols.AccountCircle)) { nav.nav(Route.ProfileUiSettings) },
),
)
val danger =
SettingsCategory(
titleRes = R.string.danger_zone,
isDanger = true,
entries =
buildList {
if (hasPrivateKey) {
add(SettingsEntry(R.string.backup_keys, SettingsIcon.Symbol(MaterialSymbols.Key), isDanger = true) { nav.nav(Route.AccountBackup) })
add(SettingsEntry(R.string.request_to_vanish, SettingsIcon.Symbol(MaterialSymbols.DeleteForever), isDanger = true) { nav.nav(Route.RequestToVanish) })
}
add(SettingsEntry(R.string.vanish_history, SettingsIcon.Symbol(MaterialSymbols.History), isDanger = true) { nav.nav(Route.VanishEvents) })
add(SettingsEntry(R.string.reset_marmot_state, SettingsIcon.Symbol(MaterialSymbols.DeleteSweep), isDanger = true, onClick = onResetMarmot))
},
)
return listOfNotNull(account, app, legalSettingsCategory(uriHandler), danger)
}
+13
View File
@@ -1504,6 +1504,19 @@
<string name="settings">Settings</string>
<string name="account_settings">Account Settings</string>
<string name="app_settings">App Settings</string>
<!-- Settings search -->
<string name="settings_search_placeholder">Search settings</string>
<string name="settings_search_no_results">No settings found for \"%1$s\"</string>
<!-- Settings search keywords (comma-separated, localizable) -->
<string name="relay_setup_search_keywords">relays, inbox, outbox, connections, servers</string>
<string name="privacy_options_search_keywords">tor, orbot, proxy, privacy, anonymous</string>
<string name="ui_preferences_search_keywords">dark mode, light mode, theme, font size, language, appearance</string>
<string name="notification_settings_search_keywords">push, alerts, sounds, vibration</string>
<string name="security_filters_search_keywords">spam, block, mute, filter, warnings</string>
<string name="zaps_search_keywords">lightning, sats, tips, wallet, amount</string>
<string name="danger_zone">Danger Zone</string>
<string name="reset_marmot_state">Reset Marmot State</string>
<string name="reset_marmot_confirm_title">Reset Marmot State?</string>
@@ -20,38 +20,37 @@
*/
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),
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),
onClick = {
runCatching {
uriHandler.openUri(
"https://github.com/vitorpamplona/amethyst/blob/main/PRIVACY.md#child-safety-standards",
)
}
},
),
),
)
@@ -0,0 +1,133 @@
/*
* 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 titles =
mapOf(
100 to "Account Settings",
200 to "Danger Zone",
1 to "Relay Setup",
2 to "UI Preferences",
3 to "Backup Keys",
)
private val keywords =
mapOf(
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,
titleLookup = { titles.getValue(it) },
keywordsLookup = { keywords.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)
}
}