app

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

commit 7e67a4b1726f17764678a9cf3455668deb9893b4
parent 1e42dfa35d40caa78446638be03fefba504ff062
Author: triesap <tyson@radroots.org>
Date:   Mon,  3 Aug 2026 23:56:02 +0000

accounts: add primary account entry choices

- present create and import as the only initial account paths
- keep account entry mode as transient Kotlin presentation state
- clear imported key drafts when leaving the import path
- cover create import and return transitions in Compose tests

Diffstat:
Mapp/desktop/src/main/kotlin/org/radroots/studio/accounts/ui/AccountsUiModel.kt | 3+++
Mapp/desktop/src/main/kotlin/org/radroots/studio/accounts/ui/StudioScreen.kt | 112+++++++++++++++++++++++++++++++++++++++++++++++++++++++------------------------
Mapp/desktop/src/main/kotlin/org/radroots/studio/application/RadrootsApplication.kt | 3+++
Mapp/desktop/src/main/kotlin/org/radroots/studio/application/StudioAppStore.kt | 23+++++++++++++++++++++++
Mapp/desktop/src/test/kotlin/org/radroots/studio/accounts/ui/StudioScreenTest.kt | 11++++++++++-
5 files changed, 117 insertions(+), 35 deletions(-)

diff --git a/app/desktop/src/main/kotlin/org/radroots/studio/accounts/ui/AccountsUiModel.kt b/app/desktop/src/main/kotlin/org/radroots/studio/accounts/ui/AccountsUiModel.kt @@ -2,6 +2,7 @@ package org.radroots.studio.accounts.ui import org.radroots.studio.application.StudioStoreState import org.radroots.studio.application.StudioRoute +import org.radroots.studio.application.AccountEntryMode import org.radroots.studio.ffi.AccountDto import org.radroots.studio.ffi.ActiveAccountDto import org.radroots.studio.ffi.ProfileLoadStateDto @@ -49,6 +50,7 @@ data class StudioUiModel( val generatedKeyBackup: GeneratedKeyBackupUiModel?, val pendingRemovalPublicKeyHex: String?, val accountChooserVisible: Boolean, + val accountEntryMode: AccountEntryMode, val session: SessionStateDto, val busy: Boolean, val problem: String?, @@ -68,6 +70,7 @@ fun StudioStoreState.toUiModel(): StudioUiModel { }, pendingRemovalPublicKeyHex = pendingRemovalPublicKeyHex, accountChooserVisible = accountChooserVisible, + accountEntryMode = accountEntryMode, session = snapshot.session, busy = busy, problem = problem 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 @@ -26,12 +26,16 @@ import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.unit.dp import org.radroots.studio.application.StudioRoute +import org.radroots.studio.application.AccountEntryMode private val WindowBackgroundColor = Color(0xFFF5F5F2) private val ButtonBackgroundColor = Color(0xFFE7E7E2) private val InputBackgroundColor = Color(0xFFFEFDF8) data class StudioUiActions( + val chooseCreateAccount: () -> Unit = {}, + val chooseImportAccount: () -> Unit = {}, + val cancelAccountEntry: () -> Unit = {}, val editImportDraft: (String) -> Unit = {}, val generateAccount: () -> Unit = {}, val importSecretKey: () -> Unit = {}, @@ -209,40 +213,7 @@ private fun InactiveAccountsScreen( ) } - TextAction( - text = "Generate new key", - testTag = "generate-key", - contentDescription = "Generate a new Nostr key", - enabled = !model.busy && model.generatedKeyBackup == null, - 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, - ) + AccountEntry(model, actions) model.generatedKeyBackup?.let { backup -> GeneratedKeyBackupPanel(backup, actions) @@ -261,6 +232,79 @@ private fun InactiveAccountsScreen( } @Composable +private fun AccountEntry(model: StudioUiModel, actions: StudioUiActions) { + when (model.accountEntryMode) { + AccountEntryMode.CHOICE -> { + TextAction( + text = "Create account", + testTag = "choose-create-account", + contentDescription = "Create a new Nostr account", + enabled = !model.busy, + onClick = actions.chooseCreateAccount, + ) + TextAction( + text = "Import key", + testTag = "choose-import-account", + contentDescription = "Import an existing Nostr secret key", + enabled = !model.busy, + onClick = actions.chooseImportAccount, + ) + } + AccountEntryMode.CREATE -> { + TextAction( + text = "Back", + testTag = "cancel-account-entry", + contentDescription = "Return to account choices", + enabled = !model.busy, + onClick = actions.cancelAccountEntry, + ) + TextAction( + text = "Generate new key", + testTag = "generate-key", + contentDescription = "Generate a new Nostr key", + enabled = !model.busy && model.generatedKeyBackup == null, + onClick = actions.generateAccount, + ) + } + AccountEntryMode.IMPORT -> { + TextAction( + text = "Back", + testTag = "cancel-account-entry", + contentDescription = "Return to account choices", + enabled = !model.busy, + onClick = actions.cancelAccountEntry, + ) + 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, + ) + } + } +} + +@Composable private fun ColumnScope.SavedAccountList( model: StudioUiModel, actions: StudioUiActions, 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 @@ -41,6 +41,9 @@ fun RadrootsApplication( StudioScreen( model = store.state.value.toUiModel(), actions = StudioUiActions( + chooseCreateAccount = store::chooseCreateAccount, + chooseImportAccount = store::chooseImportAccount, + cancelAccountEntry = store::cancelAccountEntry, editImportDraft = store::editImportDraft, generateAccount = store::generateAccount, importSecretKey = store::importSecretKey, diff --git a/app/desktop/src/main/kotlin/org/radroots/studio/application/StudioAppStore.kt b/app/desktop/src/main/kotlin/org/radroots/studio/application/StudioAppStore.kt @@ -34,6 +34,12 @@ enum class CommandStatus { FAILED_TERMINAL, } +enum class AccountEntryMode { + CHOICE, + CREATE, + IMPORT, +} + data class StudioStoreState( val snapshot: AppSnapshotDto, val route: StudioRoute = snapshot.toStudioRoute(), @@ -41,6 +47,7 @@ data class StudioStoreState( val generatedKeyBackup: GeneratedKeyBackup? = null, val pendingRemovalPublicKeyHex: String? = null, val accountChooserVisible: Boolean = false, + val accountEntryMode: AccountEntryMode = AccountEntryMode.CHOICE, val busy: Boolean = false, val commandStatus: CommandStatus = CommandStatus.IDLE, val lastCommandRequestId: String? = null, @@ -87,6 +94,22 @@ class StudioAppStore( ) } + fun chooseCreateAccount() { + mutableState.value = mutableState.value.copy(accountEntryMode = AccountEntryMode.CREATE, problem = null) + } + + fun chooseImportAccount() { + mutableState.value = mutableState.value.copy(accountEntryMode = AccountEntryMode.IMPORT, problem = null) + } + + fun cancelAccountEntry() { + mutableState.value = mutableState.value.copy( + accountEntryMode = AccountEntryMode.CHOICE, + importDraft = "", + problem = null, + ) + } + fun generateAccount() { launchCommand { val recovery = gateway.beginGeneratedAccount() 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 @@ -18,6 +18,7 @@ import kotlin.test.assertEquals import kotlin.test.assertTrue import org.radroots.studio.ffi.SessionStateDto import org.radroots.studio.application.StudioRoute +import org.radroots.studio.application.AccountEntryMode @OptIn(ExperimentalTestApi::class) class StudioScreenTest { @@ -53,12 +54,16 @@ class StudioScreenTest { @Test fun inactiveScreenGeneratesAndImportsMaskedSecretInput() = runComposeUiTest { var importDraft by mutableStateOf("") + var accountEntryMode by mutableStateOf(AccountEntryMode.CHOICE) var generateCalls = 0 var importCalls = 0 setContent { StudioScreen( - model = emptyUiModel(importDraft = importDraft), + model = emptyUiModel(importDraft = importDraft).copy(accountEntryMode = accountEntryMode), actions = StudioUiActions( + chooseCreateAccount = { accountEntryMode = AccountEntryMode.CREATE }, + chooseImportAccount = { accountEntryMode = AccountEntryMode.IMPORT }, + cancelAccountEntry = { accountEntryMode = AccountEntryMode.CHOICE }, editImportDraft = { importDraft = it }, generateAccount = { generateCalls += 1 }, importSecretKey = { importCalls += 1 }, @@ -68,7 +73,10 @@ class StudioScreenTest { onNodeWithTag("accounts-screen").assertIsDisplayed() onNodeWithText("radroots").assertIsDisplayed() + onNodeWithTag("choose-create-account").performClick() onNodeWithTag("generate-key").performClick() + onNodeWithTag("cancel-account-entry").performClick() + onNodeWithTag("choose-import-account").performClick() onNodeWithTag("import-nsec-input").performTextInput("nsec1secret") onNodeWithTag("import-key").performClick() @@ -261,6 +269,7 @@ private fun emptyUiModel( generatedKeyBackup = null, pendingRemovalPublicKeyHex = null, accountChooserVisible = false, + accountEntryMode = AccountEntryMode.CHOICE, session = SessionStateDto.SIGNED_OUT, busy = false, problem = problem,