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:
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,