mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-10 00:16:59 +00:00
Merge pull request #3127 from davotoula/feat/settings-search
Searchable, data-driven settings screen
This commit is contained in:
+2
-4
@@ -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
|
||||
|
||||
+141
-193
@@ -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,
|
||||
|
||||
+92
@@ -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<SettingsEntry>,
|
||||
)
|
||||
|
||||
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<String> = 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<SettingsCategory>,
|
||||
query: String,
|
||||
stringLookup: (Int) -> String,
|
||||
): List<SettingsCategory> {
|
||||
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)
|
||||
}
|
||||
}
|
||||
+150
@@ -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<SettingsCategory> {
|
||||
// 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)
|
||||
}
|
||||
@@ -1504,6 +1504,46 @@
|
||||
<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: an English concept/protocol index that supplements the
|
||||
(translated) row titles. Marked translatable="false" so protocol terms (blossom, nsec,
|
||||
negentropy, …) stay searchable in every locale and aren't sent to translators. -->
|
||||
<string name="relay_setup_search_keywords" translatable="false">relays, inbox, outbox, connections, servers</string>
|
||||
<string name="privacy_options_search_keywords" translatable="false">tor, orbot, proxy, privacy, anonymous</string>
|
||||
<string name="ui_preferences_search_keywords" translatable="false">dark mode, light mode, theme, font size, language, appearance</string>
|
||||
<string name="notification_settings_search_keywords" translatable="false">push, alerts, sounds, vibration</string>
|
||||
<string name="security_filters_search_keywords" translatable="false">spam, block, mute, filter, warnings</string>
|
||||
<string name="zaps_search_keywords" translatable="false">lightning, sats, tips, wallet, amount</string>
|
||||
<string name="media_servers_search_keywords" translatable="false">blossom, uploads, images, photos, files, cdn, storage</string>
|
||||
<string name="event_sync_search_keywords" translatable="false">negentropy, sync, reconcile, backfill</string>
|
||||
<string name="import_follows_search_keywords" translatable="false">contacts, follows, follow list, import</string>
|
||||
<string name="nests_servers_search_keywords" translatable="false">audio rooms, live, spaces, rooms</string>
|
||||
<string name="profile_badges_search_keywords" translatable="false">badges, awards</string>
|
||||
<string name="favorite_dvms_search_keywords" translatable="false">dvm, data vending machine, algo, algorithm, feeds</string>
|
||||
<string name="reactions_search_keywords" translatable="false">emoji, like, reaction</string>
|
||||
<string name="video_player_search_keywords" translatable="false">video, player, playback, autoplay, mute</string>
|
||||
<string name="payment_targets_search_keywords" translatable="false">zap split, split, recipients, forward zaps</string>
|
||||
<string name="call_settings_search_keywords" translatable="false">webrtc, video call, voice call, calls</string>
|
||||
<string name="translations_search_keywords" translatable="false">language, translate, locale</string>
|
||||
<string name="ots_explorer_search_keywords" translatable="false">opentimestamps, timestamp, ots, proof</string>
|
||||
<string name="namecoin_search_keywords" translatable="false">namecoin, dns, identity, name</string>
|
||||
<string name="calendar_reminder_search_keywords" translatable="false">calendar, events, reminders, rsvp</string>
|
||||
<string name="compose_search_keywords" translatable="false">draft, posting, editor, auto-save</string>
|
||||
<string name="reactions_settings_search_keywords" translatable="false">emoji, reactions, like</string>
|
||||
<string name="bottom_bar_search_keywords" translatable="false">navigation, tabs, nav bar</string>
|
||||
<string name="home_tabs_search_keywords" translatable="false">tabs, feeds, threads, conversations</string>
|
||||
<string name="profile_ui_search_keywords" translatable="false">profile, layout</string>
|
||||
<string name="backup_keys_search_keywords" translatable="false">nsec, private key, seed, mnemonic, export</string>
|
||||
<string name="request_to_vanish_search_keywords" translatable="false">delete account, vanish, gdpr</string>
|
||||
<string name="vanish_history_search_keywords" translatable="false">deletion, delete events</string>
|
||||
<string name="reset_marmot_search_keywords" translatable="false">mls, group chat, messaging, reset</string>
|
||||
<string name="privacy_policy_search_keywords" translatable="false">legal, terms, tos, policy, gdpr</string>
|
||||
<string name="child_safety_search_keywords" translatable="false">legal, safety, abuse, csae, child protection</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>
|
||||
|
||||
+33
-32
@@ -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",
|
||||
)
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
+148
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user