app

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

commit 647baa5e653a7d7bd4837963396f2ad1d31e0470
parent ae8e238394cc742b9187aee8c6069301ef2e8a53
Author: triesap <tyson@radroots.org>
Date:   Sun,  2 Aug 2026 19:56:59 +0000

ui(accounts): add generate and import controls

- render the signed-out account shell from Rust-backed UI models
- expose explicit generate and existing-key import actions
- mask secret-key entry and disable unsafe concurrent commands
- cover the minimal empty failure and interaction states in Compose

Diffstat:
Aapp/desktop/src/main/kotlin/org/radroots/studio/accounts/ui/StudioScreen.kt | 125+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mapp/desktop/src/main/kotlin/org/radroots/studio/application/RadrootsApplication.kt | 13+++++++++++--
Aapp/desktop/src/test/kotlin/org/radroots/studio/accounts/ui/StudioScreenTest.kt | 77+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mdocs/implementation/nostr-runtime-rcld.md | 2+-
4 files changed, 214 insertions(+), 3 deletions(-)

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 @@ -0,0 +1,125 @@ +package org.radroots.studio.accounts.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +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.text.BasicText +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.semantics.Role +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.semantics +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.unit.dp + +private val WindowBackgroundColor = Color(0xFFF5F5F2) +private val ButtonBackgroundColor = Color(0xFFE7E7E2) +private val InputBackgroundColor = Color(0xFFFEFDF8) + +data class StudioUiActions( + val editImportDraft: (String) -> Unit = {}, + val generateAccount: () -> Unit = {}, + val importSecretKey: () -> Unit = {}, +) + +@Composable +fun StudioScreen( + model: StudioUiModel, + actions: StudioUiActions, +) { + InactiveAccountsScreen(model, actions) +} + +@Composable +private fun InactiveAccountsScreen( + model: StudioUiModel, + actions: StudioUiActions, +) { + Column( + modifier = Modifier + .fillMaxSize() + .background(WindowBackgroundColor) + .padding(24.dp) + .testTag("accounts-screen"), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + BasicText("radroots") + BasicText("Accounts") + + TextAction( + text = "Generate new key", + testTag = "generate-key", + contentDescription = "Generate a new Nostr key", + enabled = !model.busy, + onClick = actions.generateAccount, + ) + + BasicTextField( + value = model.importDraft, + onValueChange = actions.editImportDraft, + enabled = !model.busy, + visualTransformation = PasswordVisualTransformation(), + modifier = Modifier + .fillMaxWidth() + .semantics { + contentDescription = "Nostr secret key" + password() + } + .testTag("import-nsec-input") + .background(InputBackgroundColor) + .padding(8.dp), + decorationBox = { innerTextField -> + if (model.importDraft.isEmpty()) BasicText("nsec or secret-key hex") + innerTextField() + }, + ) + TextAction( + text = "Add existing key", + testTag = "import-key", + contentDescription = "Import an existing Nostr secret key", + enabled = !model.busy && model.importDraft.isNotBlank(), + onClick = actions.importSecretKey, + ) + + model.problem?.let { + BasicText(it, Modifier.testTag("accounts-problem")) + } + + if (model.accounts.isEmpty()) { + BasicText("No saved accounts.", Modifier.testTag("accounts-empty")) + } + } +} + +@Composable +internal fun TextAction( + text: String, + testTag: String, + contentDescription: String, + enabled: Boolean = true, + onClick: () -> Unit, +) { + BasicText( + text = text, + modifier = Modifier + .semantics { + role = Role.Button + this.contentDescription = contentDescription + if (!enabled) disabled() + } + .testTag(testTag) + .then(if (enabled) Modifier.clickable(onClick = onClick) else Modifier) + .background(ButtonBackgroundColor) + .padding(8.dp), + ) +} 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 @@ -1,11 +1,13 @@ package org.radroots.studio.application -import androidx.compose.foundation.text.BasicText import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import kotlinx.coroutines.CoroutineScope +import org.radroots.studio.accounts.ui.StudioScreen +import org.radroots.studio.accounts.ui.StudioUiActions +import org.radroots.studio.accounts.ui.toUiModel import org.radroots.studio.ffi.StudioAppCore internal typealias StudioStoreFactory = (CoroutineScope) -> StudioAppStore @@ -21,7 +23,14 @@ fun RadrootsApplication( onDispose(store::close) } - BasicText("radroots") + StudioScreen( + model = store.state.value.toUiModel(), + actions = StudioUiActions( + editImportDraft = store::editImportDraft, + generateAccount = store::generateAccount, + importSecretKey = store::importSecretKey, + ), + ) } internal fun createStudioAppStore(scope: CoroutineScope): StudioAppStore { 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 @@ -0,0 +1,77 @@ +package org.radroots.studio.accounts.ui + +import androidx.compose.runtime.getValue +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.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTextInput +import androidx.compose.ui.test.v2.runComposeUiTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue +import org.radroots.studio.ffi.SessionStateDto + +@OptIn(ExperimentalTestApi::class) +class StudioScreenTest { + @Test + fun inactiveScreenGeneratesAndImportsMaskedSecretInput() = runComposeUiTest { + var importDraft by mutableStateOf("") + var generateCalls = 0 + var importCalls = 0 + setContent { + StudioScreen( + model = emptyUiModel(importDraft = importDraft), + actions = StudioUiActions( + editImportDraft = { importDraft = it }, + generateAccount = { generateCalls += 1 }, + importSecretKey = { importCalls += 1 }, + ), + ) + } + + onNodeWithTag("accounts-screen").assertIsDisplayed() + onNodeWithText("radroots").assertIsDisplayed() + onNodeWithTag("generate-key").performClick() + onNodeWithTag("import-nsec-input").performTextInput("nsec1secret") + onNodeWithTag("import-key").performClick() + + assertEquals(1, generateCalls) + assertEquals(1, importCalls) + assertEquals("nsec1secret", importDraft) + assertTrue(onNodeWithTag("import-nsec-input").fetchSemanticsNode().config.any { + it.key.name == "Password" && it.value == Unit + }) + } + + @Test + fun inactiveScreenShowsSafeFailureAndNoGenericFields() = runComposeUiTest { + setContent { + StudioScreen( + model = emptyUiModel(problem = "The secret key is invalid."), + actions = StudioUiActions(), + ) + } + + onNodeWithText("The secret key is invalid.").assertIsDisplayed() + onNodeWithTag("accounts-empty").assertIsDisplayed() + } +} + +private fun emptyUiModel( + importDraft: String = "", + problem: String? = null, +) = StudioUiModel( + accounts = emptyList(), + activeAccount = null, + configuredRelays = emptyList(), + importDraft = importDraft, + generatedKeyBackup = null, + pendingRemovalPublicKeyHex = null, + session = SessionStateDto.SIGNED_OUT, + busy = false, + problem = problem, +) diff --git a/docs/implementation/nostr-runtime-rcld.md b/docs/implementation/nostr-runtime-rcld.md @@ -534,7 +534,7 @@ handoff commit sequence. - [x] 48. Add Kotlin StudioAppStore thin adapter. - [x] 49. Bootstrap AppCore once at application root. - [x] 50. Create UI model mapping helpers without server fields. -- [ ] 51. Implement inactive account screen generate/import controls. +- [x] 51. Implement inactive account screen generate/import controls. - [ ] 52. Implement generated-key backup panel. - [ ] 53. Implement saved-account list, activation, and removal controls. - [ ] 54. Implement active account home screen.