app

Local-first trade for farms and co-ops
git clone https://radroots.dev/git/app.git
Log | Files | Refs | README | LICENSE

commit c0d2e3970a1e69b1f6dfb4f4a98540a906af5108
parent 141e3ae1fa95978c705fde47ad59444d84d96a78
Author: triesap <tyson@radroots.org>
Date:   Sun,  2 Aug 2026 19:59:08 +0000

ui(accounts): render saved Nostr accounts

- list public identities selection and credential availability
- forward explicit select and activate intents to the Rust store
- require a one-time removal request before destructive confirmation
- cover multi-account activation and cancelable removal interactions

Diffstat:
Mapp/desktop/src/main/kotlin/org/radroots/studio/accounts/ui/StudioScreen.kt | 70++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mapp/desktop/src/main/kotlin/org/radroots/studio/application/RadrootsApplication.kt | 5+++++
Mapp/desktop/src/test/kotlin/org/radroots/studio/accounts/ui/StudioScreenTest.kt | 49+++++++++++++++++++++++++++++++++++++++++++++++++
Mdocs/implementation/nostr-runtime-rcld.md | 2+-
4 files changed, 125 insertions(+), 1 deletion(-)

diff --git a/app/desktop/src/main/kotlin/org/radroots/studio/accounts/ui/StudioScreen.kt b/app/desktop/src/main/kotlin/org/radroots/studio/accounts/ui/StudioScreen.kt @@ -7,6 +7,8 @@ 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.lazy.LazyColumn +import androidx.compose.foundation.lazy.items import androidx.compose.foundation.text.BasicText import androidx.compose.foundation.text.BasicTextField import androidx.compose.runtime.Composable @@ -18,6 +20,7 @@ import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.disabled import androidx.compose.ui.semantics.password import androidx.compose.ui.semantics.role +import androidx.compose.ui.semantics.selected import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.unit.dp @@ -32,6 +35,11 @@ data class StudioUiActions( val importSecretKey: () -> Unit = {}, val copyText: (String) -> Unit = {}, val acknowledgeGeneratedKeyBackup: () -> Unit = {}, + val selectAccount: (String) -> Unit = {}, + val activateAccount: (String) -> Unit = {}, + val requestAccountRemoval: (String) -> Unit = {}, + val cancelAccountRemoval: () -> Unit = {}, + val confirmAccountRemoval: () -> Unit = {}, ) @Composable @@ -103,6 +111,68 @@ private fun InactiveAccountsScreen( if (model.accounts.isEmpty()) { BasicText("No saved accounts.", Modifier.testTag("accounts-empty")) + } else { + SavedAccountList(model, actions) + } + } +} + +@Composable +private fun SavedAccountList( + model: StudioUiModel, + actions: StudioUiActions, +) { + LazyColumn( + modifier = Modifier + .fillMaxWidth() + .testTag("saved-account-list"), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + items(model.accounts, key = AccountUiModel::publicKeyHex) { account -> + Column( + modifier = Modifier + .fillMaxWidth() + .semantics { selected = account.selected } + .testTag("account-row:${account.publicKeyHex}") + .clickable { actions.selectAccount(account.publicKeyHex) } + .background(InputBackgroundColor) + .padding(12.dp), + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + BasicText(account.label) + BasicText(account.npub) + BasicText("Key: ${account.keyAvailability}") + if (account.selected) BasicText("Selected") + TextAction( + text = "Activate", + testTag = "activate-account:${account.publicKeyHex}", + contentDescription = "Activate ${account.label}", + enabled = !model.busy, + onClick = { actions.activateAccount(account.publicKeyHex) }, + ) + TextAction( + text = "Remove", + testTag = "remove-account:${account.publicKeyHex}", + contentDescription = "Remove ${account.label}", + enabled = !model.busy, + onClick = { actions.requestAccountRemoval(account.publicKeyHex) }, + ) + if (model.pendingRemovalPublicKeyHex == account.publicKeyHex) { + BasicText("Remove this saved account and its local credential?") + TextAction( + text = "Cancel", + testTag = "remove-cancel", + contentDescription = "Cancel account removal", + onClick = actions.cancelAccountRemoval, + ) + TextAction( + text = "Confirm removal", + testTag = "remove-confirm", + contentDescription = "Confirm account removal", + onClick = actions.confirmAccountRemoval, + ) + } + } } } } diff --git a/app/desktop/src/main/kotlin/org/radroots/studio/application/RadrootsApplication.kt b/app/desktop/src/main/kotlin/org/radroots/studio/application/RadrootsApplication.kt @@ -35,6 +35,11 @@ fun RadrootsApplication( Toolkit.getDefaultToolkit().systemClipboard.setContents(StringSelection(value), null) }, acknowledgeGeneratedKeyBackup = store::acknowledgeGeneratedKeyBackup, + selectAccount = store::selectAccount, + activateAccount = store::activateAccount, + requestAccountRemoval = store::requestAccountRemoval, + cancelAccountRemoval = store::cancelAccountRemoval, + confirmAccountRemoval = store::confirmAccountRemoval, ), ) } diff --git a/app/desktop/src/test/kotlin/org/radroots/studio/accounts/ui/StudioScreenTest.kt b/app/desktop/src/test/kotlin/org/radroots/studio/accounts/ui/StudioScreenTest.kt @@ -5,6 +5,7 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.compose.ui.test.ExperimentalTestApi import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsSelected import androidx.compose.ui.test.assertCountEquals import androidx.compose.ui.test.onAllNodesWithTag import androidx.compose.ui.test.onNodeWithTag @@ -87,6 +88,45 @@ class StudioScreenTest { onAllNodesWithTag("generated-key-backup").assertCountEquals(0) onAllNodesWithTag("generated-nsec").assertCountEquals(0) } + + @Test + fun savedAccountsSelectActivateAndRequireRemovalConfirmation() = runComposeUiTest { + val first = accountUi("11".repeat(32), selected = true) + val second = accountUi("22".repeat(32), selected = false) + var pendingRemoval: String? by mutableStateOf(null) + val selected = mutableListOf<String>() + val activated = mutableListOf<String>() + var confirmations = 0 + setContent { + StudioScreen( + model = emptyUiModel().copy( + accounts = listOf(first, second), + pendingRemovalPublicKeyHex = pendingRemoval, + ), + actions = StudioUiActions( + selectAccount = selected::add, + activateAccount = activated::add, + requestAccountRemoval = { pendingRemoval = it }, + cancelAccountRemoval = { pendingRemoval = null }, + confirmAccountRemoval = { confirmations += 1 }, + ), + ) + } + + onNodeWithTag("saved-account-list").assertIsDisplayed() + onNodeWithTag("account-row:${first.publicKeyHex}").assertIsSelected() + onNodeWithTag("account-row:${second.publicKeyHex}").performClick() + onNodeWithTag("activate-account:${second.publicKeyHex}", useUnmergedTree = true).performClick() + assertEquals(listOf(second.publicKeyHex), selected) + assertEquals(listOf(second.publicKeyHex), activated) + + onNodeWithTag("remove-account:${second.publicKeyHex}", useUnmergedTree = true).performClick() + onNodeWithTag("remove-cancel", useUnmergedTree = true).performClick() + assertEquals(null, pendingRemoval) + onNodeWithTag("remove-account:${second.publicKeyHex}", useUnmergedTree = true).performClick() + onNodeWithTag("remove-confirm", useUnmergedTree = true).performClick() + assertEquals(1, confirmations) + } } private fun emptyUiModel( @@ -103,3 +143,12 @@ private fun emptyUiModel( busy = false, problem = problem, ) + +private fun accountUi(publicKeyHex: String, selected: Boolean) = AccountUiModel( + publicKeyHex = publicKeyHex, + npub = "npub1${publicKeyHex.take(12)}", + shortNpub = "npub1${publicKeyHex.take(12)}", + label = "Account ${publicKeyHex.take(2)}", + keyAvailability = "available", + selected = selected, +) diff --git a/docs/implementation/nostr-runtime-rcld.md b/docs/implementation/nostr-runtime-rcld.md @@ -536,7 +536,7 @@ handoff commit sequence. - [x] 50. Create UI model mapping helpers without server fields. - [x] 51. Implement inactive account screen generate/import controls. - [x] 52. Implement generated-key backup panel. -- [ ] 53. Implement saved-account list, activation, and removal controls. +- [x] 53. Implement saved-account list, activation, and removal controls. - [ ] 54. Implement active account home screen. - [ ] 55. Implement switch account flow in UI. - [ ] 56. Remove canonical Kotlin account reducer/store usage.