mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-05 19:28:25 +00:00
feat(desktop): search + reveal for settings accordion
Add a pinned, auto-focused search field above the Settings accordion. Typing filters cards case-insensitively across title, subtitle and curated keywords (which include action synonyms like "reconnect"/"connect wallet"); matches are force-expanded and the list scrolls to the top hit. Esc and the clear button reset the query and collapse everything. A no-match query shows a placeholder; the Logout footer hides while searching. - Filtering is a plain in-memory filter over the ~11 entries (no debounce, no derivedStateOf needed for a rebuilt list this small). - SettingsMetaTest covers the pure matcher (blank→all, title/subtitle/keyword hits, case-insensitivity, trimming, non-match). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
0f0157b8f0
commit
136983f130
@@ -0,0 +1,50 @@
|
||||
# Manual Testing — Desktop Settings Searchable Accordion
|
||||
|
||||
Feature branch: `feat/desktop-settings-search-accordion`
|
||||
Run: `./gradlew :desktopApp:run` → open **Settings** (sidebar, File → Settings, or Cmd/Ctrl+,).
|
||||
|
||||
Legend: ☐ untested · ✅ pass · ❌ fail (note issue)
|
||||
|
||||
## Accordion basics
|
||||
- ✅ Settings opens as a list of **collapsed** cards (icon + bold title + subtitle + chevron), matching the mockups.
|
||||
- ✅ Clicking a card header expands it; the chevron flips (▼→▲); content animates in.
|
||||
- ✅ Multiple cards can be open at once.
|
||||
- ✅ Each card header shows a **hand cursor** on hover and a subtle hover highlight.
|
||||
- ✅ No card shows a duplicated title inside its body (card header owns the title).
|
||||
- ✅ Leaving Settings and returning **resets** all cards to collapsed.
|
||||
|
||||
## Expand / Collapse all
|
||||
- ✅ "Expand all" opens every card; "Collapse all" closes every card.
|
||||
|
||||
## Search (filter + reveal)
|
||||
- ✅ The search field is **auto-focused** on open (typing goes straight into it).
|
||||
- ✅ Typing filters cards case-insensitively by title (e.g. `wallet` → Wallet Connect).
|
||||
- ✅ Matching cards are **auto-expanded** and the list scrolls to the top match.
|
||||
- ✅ Action keywords surface the right card: `reconnect` → Relay Settings; `connect wallet` → Wallet Connect; `.bit` → Namecoin; `exif` → Image Compression; `sqlite` → Local Relay; `nsec`/`backup` → Account Keys / Backup; `mute`/`block` → Moderation.
|
||||
- ✅ A no-match query (e.g. `zzzzz`) shows the "No settings match …" placeholder and no cards.
|
||||
- ✅ The clear (✕) button and **Esc** both clear the query and return to the full collapsed list.
|
||||
- ✅ Global shortcuts still work while the field is focused: **Cmd/Ctrl+K** (app drawer), **Cmd/Ctrl+,** (settings).
|
||||
|
||||
## Each section still works (functional parity)
|
||||
- ✅ **Account Keys / Backup:** `BackupKeysCard` renders; PrivacyLock-gated nsec reveal + NIP-49 encrypted backup work (absorbed from upstream — verify unchanged).
|
||||
- ✅ **Moderation:** muted/blocked lists render; mute/unmute/unblock work (absorbed from upstream — verify unchanged).
|
||||
- ✅ **Wallet Connect (NWC):** connect with a `nostr+walletconnect://…` string; shows "Wallet Connected"; Disconnect works.
|
||||
- ✅ **Namecoin Resolution:** enable toggle + server list render; add/remove/test server works (shared with Android — verify unchanged).
|
||||
- ✅ **Media Servers (Blossom):** server list + Add + Check All work; health status appears on expand.
|
||||
- ✅ **Image Compression:** quality presets + EXIF toggle work.
|
||||
- ✅ **Relay Settings:** connection count + Reconnect; Add relay; per-relay remove; Reset to Defaults. Relay list scrolls with the page (no nested-scroll crash).
|
||||
- ✅ **Local Relay:** stats/storage/export sub-sections render and function.
|
||||
- ✅ **Tor:** status indicator + Advanced… dialog + mode selector work.
|
||||
- ✅ **Privacy Lock:** lock/inactivity/redaction cards work.
|
||||
- ✅ **Content Filters:** hashtag-spam switch + threshold slider work.
|
||||
- ✅ **Developer Settings:** present only in debug builds.
|
||||
- ✅ **Logout** button appears at the bottom (hidden while searching) and logs out.
|
||||
|
||||
## Regression
|
||||
- ✅ No `ConcurrentModificationException` / crash opening or scrolling Settings.
|
||||
- ✅ Long sections (Namecoin, Local Relay) expand and scroll into view without layout glitches.
|
||||
|
||||
## PoW
|
||||
- Manually exercised on macOS (Compose Desktop) with a live account — app boots
|
||||
cleanly, local relay hydrates, no exceptions; all rows above pass.
|
||||
- Screenshots (collapsed grid · expanded card · search-filtered · no-match) attached in the PR description.
|
||||
@@ -35,9 +35,12 @@ import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.Surface
|
||||
@@ -58,11 +61,18 @@ import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.input.key.Key
|
||||
import androidx.compose.ui.input.key.KeyEventType
|
||||
import androidx.compose.ui.input.key.KeyShortcut
|
||||
import androidx.compose.ui.input.key.key
|
||||
import androidx.compose.ui.input.key.onPreviewKeyEvent
|
||||
import androidx.compose.ui.input.key.type
|
||||
import androidx.compose.ui.input.pointer.PointerIcon
|
||||
import androidx.compose.ui.input.pointer.pointerHoverIcon
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.MenuBar
|
||||
import androidx.compose.ui.window.Window
|
||||
@@ -2421,6 +2431,16 @@ fun SettingsScreen(
|
||||
val expandedIds = remember { mutableStateMapOf<String, Boolean>() }
|
||||
val logoutScope = rememberCoroutineScope()
|
||||
|
||||
var query by remember { mutableStateOf("") }
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
val listState = rememberLazyListState()
|
||||
// Tiny list; a plain filter is cheaper than derivedStateOf here.
|
||||
val visible = if (query.isBlank()) entries else entries.filter { it.meta.matches(query) }
|
||||
|
||||
LaunchedEffect(Unit) { focusRequester.requestFocus() }
|
||||
// A non-blank query reveals matches at the top of the list.
|
||||
LaunchedEffect(query) { if (query.isNotBlank()) listState.animateScrollToItem(0) }
|
||||
|
||||
com.vitorpamplona.amethyst.desktop.ui.ReadingColumn {
|
||||
val sidePadding =
|
||||
com.vitorpamplona.amethyst.desktop.ui
|
||||
@@ -2462,15 +2482,56 @@ fun SettingsScreen(
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(8.dp))
|
||||
|
||||
OutlinedTextField(
|
||||
value = query,
|
||||
onValueChange = { query = it },
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.focusRequester(focusRequester)
|
||||
.onPreviewKeyEvent { event ->
|
||||
if (event.type == KeyEventType.KeyDown &&
|
||||
event.key == Key.Escape &&
|
||||
query.isNotEmpty()
|
||||
) {
|
||||
query = ""
|
||||
expandedIds.clear()
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
},
|
||||
placeholder = { Text("Search settings…") },
|
||||
leadingIcon = { Icon(MaterialSymbols.Search, contentDescription = null) },
|
||||
trailingIcon = {
|
||||
if (query.isNotEmpty()) {
|
||||
IconButton(
|
||||
onClick = {
|
||||
query = ""
|
||||
expandedIds.clear()
|
||||
},
|
||||
modifier = Modifier.pointerHoverIcon(PointerIcon.Hand),
|
||||
) {
|
||||
Icon(MaterialSymbols.Clear, contentDescription = "Clear search")
|
||||
}
|
||||
}
|
||||
},
|
||||
singleLine = true,
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(12.dp))
|
||||
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
modifier = Modifier.fillMaxWidth().weight(1f),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
items(entries, key = { it.meta.id }) { entry ->
|
||||
items(visible, key = { it.meta.id }) { entry ->
|
||||
SettingsAccordionCard(
|
||||
expanded = expandedIds[entry.meta.id] == true,
|
||||
// A non-blank query force-expands matches to reveal them.
|
||||
expanded = if (query.isBlank()) expandedIds[entry.meta.id] == true else true,
|
||||
onToggle = {
|
||||
expandedIds[entry.meta.id] = !(expandedIds[entry.meta.id] ?: false)
|
||||
},
|
||||
@@ -2487,18 +2548,32 @@ fun SettingsScreen(
|
||||
)
|
||||
}
|
||||
|
||||
item(key = "__logout__") {
|
||||
Spacer(Modifier.height(4.dp))
|
||||
OutlinedButton(
|
||||
onClick = { logoutScope.launch { accountManager.logout(deleteKey = true) } },
|
||||
colors =
|
||||
ButtonDefaults.outlinedButtonColors(
|
||||
contentColor = MaterialTheme.colorScheme.error,
|
||||
),
|
||||
) {
|
||||
Text("Logout")
|
||||
if (query.isNotBlank() && visible.isEmpty()) {
|
||||
item(key = "__empty__") {
|
||||
Text(
|
||||
"No settings match \"$query\"",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = 32.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (query.isBlank()) {
|
||||
item(key = "__logout__") {
|
||||
Spacer(Modifier.height(4.dp))
|
||||
OutlinedButton(
|
||||
onClick = { logoutScope.launch { accountManager.logout(deleteKey = true) } },
|
||||
colors =
|
||||
ButtonDefaults.outlinedButtonColors(
|
||||
contentColor = MaterialTheme.colorScheme.error,
|
||||
),
|
||||
) {
|
||||
Text("Logout")
|
||||
}
|
||||
Spacer(Modifier.height(24.dp))
|
||||
}
|
||||
Spacer(Modifier.height(24.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+74
@@ -0,0 +1,74 @@
|
||||
/*
|
||||
* 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.desktop.ui.settings
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class SettingsMetaTest {
|
||||
private val nwc =
|
||||
SettingsMeta(
|
||||
id = "nwc",
|
||||
icon = MaterialSymbols.Bolt,
|
||||
title = "Wallet Connect (NWC)",
|
||||
subtitle = "Connect a Lightning wallet",
|
||||
keywords = persistentListOf("lightning", "zap", "connect wallet", "alby"),
|
||||
)
|
||||
|
||||
@Test
|
||||
fun blankQueryMatchesEverything() {
|
||||
assertTrue(nwc.matches(""))
|
||||
assertTrue(nwc.matches(" "))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun matchesByTitleCaseInsensitively() {
|
||||
assertTrue(nwc.matches("wallet"))
|
||||
assertTrue(nwc.matches("WALLET"))
|
||||
assertTrue(nwc.matches("nwc"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun matchesBySubtitle() {
|
||||
assertTrue(nwc.matches("lightning"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun matchesByActionKeyword() {
|
||||
// A user typing an action term surfaces the card that hosts it.
|
||||
assertTrue(nwc.matches("zap"))
|
||||
assertTrue(nwc.matches("Alby"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun surroundingWhitespaceIsTrimmed() {
|
||||
assertTrue(nwc.matches(" zap "))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun nonMatchingQueryReturnsFalse() {
|
||||
assertFalse(nwc.matches("relay"))
|
||||
assertFalse(nwc.matches("blossom"))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user