commit d8dc06940444547b8db846a006f6e65ebdabdcfb
parent 4a26c5bda68a7055f92e0484b64e38cc2cc3ecd9
Author: triesap <tyson@radroots.org>
Date: Tue, 11 Aug 2026 16:45:16 +0000
ui: integrate local identity entry
- place locked create and import flows in the canvas
- focus and mask the bounded native secret input
- preserve existing presenter validation and secret clearing
- keep back and action controls in the fixed regions
Diffstat:
3 files changed, 200 insertions(+), 0 deletions(-)
diff --git a/app/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/BootstrapIdentityEntry.kt b/app/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/BootstrapIdentityEntry.kt
@@ -0,0 +1,104 @@
+package org.harvestcircle.ui.shell
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.text.BasicText
+import androidx.compose.foundation.text.BasicTextField
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.focus.FocusRequester
+import androidx.compose.ui.focus.focusRequester
+import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.semantics.contentDescription
+import androidx.compose.ui.semantics.password
+import androidx.compose.ui.semantics.semantics
+import androidx.compose.ui.text.input.PasswordVisualTransformation
+import androidx.compose.ui.unit.dp
+import org.harvestcircle.design.TextSizePreference
+import org.harvestcircle.identities.ui.HarvestCircleUiActions
+import org.harvestcircle.identities.ui.HarvestCircleUiModel
+import org.harvestcircle.navigation.BootstrapStep
+
+@Composable
+fun BootstrapIdentityEntry(
+ step: BootstrapStep,
+ model: HarvestCircleUiModel,
+ actions: HarvestCircleUiActions,
+ onBack: () -> Unit,
+) {
+ require(step == BootstrapStep.CreateIdentity || step == BootstrapStep.ImportIdentity)
+ CanvasScaffold(
+ textSize = TextSizePreference.Default,
+ navigation = { ShellAction("Back", "Back", "identity-entry-back", onClick = onBack) },
+ header = {
+ BasicText(
+ if (step == BootstrapStep.CreateIdentity) {
+ "Create a local Nostr identity"
+ } else {
+ "Import an existing identity"
+ },
+ )
+ },
+ body = {
+ if (step == BootstrapStep.CreateIdentity) {
+ CreateIdentityBody(model)
+ } else {
+ ImportIdentityBody(model, actions)
+ }
+ },
+ actionBar = {
+ Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
+ if (step == BootstrapStep.CreateIdentity) {
+ ShellAction("Generate identity", "Generate identity", "generate-key", enabled = !model.busy) {
+ actions.generateIdentity()
+ }
+ } else {
+ ShellAction("Import identity", "Import identity", "import-key", enabled = !model.busy) {
+ actions.importSecretKey()
+ }
+ }
+ ShellAction("Back", "Back", "identity-entry-cancel", onClick = onBack)
+ }
+ },
+ )
+}
+
+@Composable
+private fun CreateIdentityBody(model: HarvestCircleUiModel) {
+ Column(Modifier.testTag("create-identity-entry"), verticalArrangement = Arrangement.spacedBy(16.dp)) {
+ BasicText("HarvestCircle will generate a new Nostr identity.")
+ BasicText("Save the recovery key before the identity is stored in the operating-system keyring.")
+ model.problem?.let { BasicText(it, Modifier.testTag("identity-entry-problem")) }
+ }
+}
+
+@Composable
+private fun ImportIdentityBody(
+ model: HarvestCircleUiModel,
+ actions: HarvestCircleUiActions,
+) {
+ val requester = remember { FocusRequester() }
+ Column(Modifier.testTag("import-identity-entry"), verticalArrangement = Arrangement.spacedBy(16.dp)) {
+ BasicText("Nostr secret key")
+ BasicTextField(
+ value = model.importDraft,
+ onValueChange = actions.editImportDraft,
+ modifier =
+ Modifier
+ .focusRequester(requester)
+ .semantics {
+ password()
+ contentDescription = "Nostr secret key"
+ }.testTag("import-nsec-input"),
+ visualTransformation = PasswordVisualTransformation(),
+ )
+ BasicText("nsec1…")
+ BasicText("The secret is sent directly to the local native runtime and is not retained in the interface.")
+ model.importGuidance?.let { BasicText(it, Modifier.testTag("identity-entry-guidance")) }
+ model.problem?.let { BasicText(it, Modifier.testTag("identity-entry-problem")) }
+ }
+ LaunchedEffect(Unit) { requester.requestFocus() }
+}
diff --git a/app/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/HarvestCircleShell.kt b/app/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/HarvestCircleShell.kt
@@ -37,6 +37,22 @@ fun HarvestCircleShell(
is ShellRoot.BootstrapCanvas ->
when (root.step) {
BootstrapStep.Welcome -> BootstrapWelcome(dispatch)
+ BootstrapStep.CreateIdentity,
+ BootstrapStep.ImportIdentity,
+ ->
+ BootstrapIdentityEntry(
+ step = root.step,
+ model = state.identity.toUiModel(),
+ actions = identityActions,
+ onBack = {
+ identityActions.cancelIdentityEntry()
+ dispatch(
+ HarvestCircleShellIntent.Navigation(
+ NavigationIntent.SelectBootstrapStep(BootstrapStep.Welcome),
+ ),
+ )
+ },
+ )
else -> HarvestCircleScreen(state.identity.toUiModel(), identityActions, platformActions)
}
is ShellRoot.Dashboard -> DashboardRoot(state, root, dispatch)
diff --git a/app/shared/src/desktopTest/kotlin/org/harvestcircle/ui/shell/BootstrapIdentityEntryTest.kt b/app/shared/src/desktopTest/kotlin/org/harvestcircle/ui/shell/BootstrapIdentityEntryTest.kt
@@ -0,0 +1,80 @@
+package org.harvestcircle.ui.shell
+
+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.assertIsFocused
+import androidx.compose.ui.test.onNodeWithTag
+import androidx.compose.ui.test.performClick
+import androidx.compose.ui.test.performTextInput
+import androidx.compose.ui.test.v2.runComposeUiTest
+import org.harvestcircle.application.HarvestCircleRoute
+import org.harvestcircle.application.IdentityEntryMode
+import org.harvestcircle.application.RecoveryAction
+import org.harvestcircle.application.RemovalStatus
+import org.harvestcircle.application.SessionLifecycle
+import org.harvestcircle.identities.ui.HarvestCircleUiActions
+import org.harvestcircle.identities.ui.HarvestCircleUiModel
+import org.harvestcircle.navigation.BootstrapStep
+import kotlin.test.Test
+import kotlin.test.assertEquals
+
+@OptIn(ExperimentalTestApi::class)
+class BootstrapIdentityEntryTest {
+ @Test
+ fun importIsMaskedFocusedBoundedAndCancelable() =
+ runComposeUiTest {
+ var draft by mutableStateOf("")
+ var back = 0
+ setContent {
+ BootstrapIdentityEntry(
+ BootstrapStep.ImportIdentity,
+ model = model(draft),
+ actions = HarvestCircleUiActions(editImportDraft = { draft = it }),
+ onBack = { back += 1 },
+ )
+ }
+ onNodeWithTag("import-nsec-input").assertIsFocused().performTextInput("nsec1secret")
+ assertEquals("nsec1secret", draft)
+ onNodeWithTag("identity-entry-cancel").performClick()
+ assertEquals(1, back)
+ }
+
+ @Test
+ fun createDispatchesOnlyTheExistingGenerateAction() =
+ runComposeUiTest {
+ var generate = 0
+ setContent {
+ BootstrapIdentityEntry(
+ BootstrapStep.CreateIdentity,
+ model = model(),
+ actions = HarvestCircleUiActions(generateIdentity = { generate += 1 }),
+ onBack = {},
+ )
+ }
+ onNodeWithTag("generate-key").performClick()
+ assertEquals(1, generate)
+ }
+}
+
+private fun model(importDraft: String = "") =
+ HarvestCircleUiModel(
+ route = HarvestCircleRoute.IDENTITIES,
+ identities = emptyList(),
+ activeIdentity = null,
+ configuredRelays = emptyList(),
+ importDraft = importDraft,
+ generatedKeyBackup = null,
+ pendingRemovalPublicKeyHex = null,
+ removalImpact = null,
+ removalStatus = RemovalStatus.NONE,
+ lastRemovedPublicKeyHex = null,
+ identityChooserVisible = false,
+ identityEntryMode = IdentityEntryMode.IMPORT,
+ session = SessionLifecycle.SignedOut,
+ busy = false,
+ problem = null,
+ importGuidance = null,
+ recoveryAction = RecoveryAction.None,
+ )