app

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

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:
Aapp/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/BootstrapIdentityEntry.kt | 104+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mapp/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/HarvestCircleShell.kt | 16++++++++++++++++
Aapp/shared/src/desktopTest/kotlin/org/harvestcircle/ui/shell/BootstrapIdentityEntryTest.kt | 80+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
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, + )