app

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

commit a4fab8f9067c2e46a77386ac0cd82014404f5543
parent d8dc06940444547b8db846a006f6e65ebdabdcfb
Author: triesap <tyson@radroots.org>
Date:   Tue, 11 Aug 2026 16:51:03 +0000

ui: integrate recovery and identity selection

- Route presenter-owned bootstrap modes and one-use recovery into the shell.
- Render recovery copy, acknowledgement, cancellation, and secret removal.
- Expose explicit identity selection, activation, and confirmed local removal.
- Verify shared UI, desktop, Rust workspace, and formatting suites.

Diffstat:
Mapp/shared/src/commonMain/kotlin/org/harvestcircle/application/HarvestCircleShellPresenter.kt | 2--
Mapp/shared/src/commonMain/kotlin/org/harvestcircle/application/ShellAvailability.kt | 21+++++++++++++++------
Aapp/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/BootstrapRecoveryAndChooser.kt | 152+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mapp/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/HarvestCircleShell.kt | 11++++++++++-
Mapp/shared/src/commonTest/kotlin/org/harvestcircle/application/ShellAvailabilityTest.kt | 20++++++++++++++++++++
Aapp/shared/src/desktopTest/kotlin/org/harvestcircle/ui/shell/BootstrapRecoveryAndChooserTest.kt | 122+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
6 files changed, 319 insertions(+), 9 deletions(-)

diff --git a/app/shared/src/commonMain/kotlin/org/harvestcircle/application/HarvestCircleShellPresenter.kt b/app/shared/src/commonMain/kotlin/org/harvestcircle/application/HarvestCircleShellPresenter.kt @@ -99,8 +99,6 @@ class HarvestCircleShellPresenter( when { derived is ShellRoot.Dashboard && root is ShellRoot.Dashboard -> derived.copy(navigation = root.navigation) - derived is ShellRoot.BootstrapCanvas && root is ShellRoot.BootstrapCanvas -> - derived.copy(step = root.step) else -> derived } copy(identity = identity, root = retained) diff --git a/app/shared/src/commonMain/kotlin/org/harvestcircle/application/ShellAvailability.kt b/app/shared/src/commonMain/kotlin/org/harvestcircle/application/ShellAvailability.kt @@ -60,23 +60,32 @@ sealed interface ShellRoot { fun deriveShellRoot( presenterState: HarvestCirclePresenterState, session: ShellSessionState, -): ShellRoot = - when (presenterState.route) { +): ShellRoot { + if (presenterState.generatedKeyBackup != null) { + return ShellRoot.BootstrapCanvas(BootstrapStep.GeneratedRecovery) + } + return when (presenterState.route) { HarvestCircleRoute.ACTIVE_IDENTITY -> ShellRoot.Dashboard(NavigationState(AppRoute.PersonalToday)) HarvestCircleRoute.IDENTITIES -> if (session.readOnly) { ShellRoot.Dashboard(NavigationState(AppRoute.PersonalToday)) } else { val step = - if (presenterState.snapshot.identities.isEmpty()) { - BootstrapStep.Welcome - } else { - BootstrapStep.IdentityChooser + when (presenterState.identityEntryMode) { + IdentityEntryMode.CREATE -> BootstrapStep.CreateIdentity + IdentityEntryMode.IMPORT -> BootstrapStep.ImportIdentity + IdentityEntryMode.CHOICE -> + if (presenterState.snapshot.identities.isEmpty()) { + BootstrapStep.Welcome + } else { + BootstrapStep.IdentityChooser + } } ShellRoot.BootstrapCanvas(step) } else -> ShellRoot.LifecycleCanvas(presenterState.route) } +} fun activateShellDestination( state: NavigationState, diff --git a/app/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/BootstrapRecoveryAndChooser.kt b/app/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/BootstrapRecoveryAndChooser.kt @@ -0,0 +1,152 @@ +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.layout.fillMaxWidth +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.text.BasicText +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.semantics.selected +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.dp +import org.harvestcircle.design.TextSizePreference +import org.harvestcircle.identities.ui.HarvestCirclePlatformActions +import org.harvestcircle.identities.ui.HarvestCircleUiActions +import org.harvestcircle.identities.ui.HarvestCircleUiModel +import org.harvestcircle.identities.ui.IdentityUiModel + +@Composable +fun GeneratedRecoveryCanvas( + model: HarvestCircleUiModel, + actions: HarvestCircleUiActions, + platformActions: HarvestCirclePlatformActions, +) { + val backup = requireNotNull(model.generatedKeyBackup) + CanvasScaffold( + textSize = TextSizePreference.Default, + header = { BasicText("Save your recovery key") }, + body = { + Column(Modifier.testTag("generated-key-backup"), verticalArrangement = Arrangement.spacedBy(16.dp)) { + BasicText("This key is shown once.") + BasicText("Store it somewhere private before continuing.") + BasicText("Recovery key") + BasicText(backup.nsec, Modifier.testTag("generated-nsec")) + } + }, + actionBar = { + Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { + ShellAction("Copy recovery key", "Copy recovery key", "copy-generated-key") { + platformActions.copySecret(backup.nsec) + } + ShellAction("I have saved the recovery key", "I have saved the recovery key", "acknowledge-key-backup") { + actions.acknowledgeGeneratedKeyBackup() + } + ShellAction("Cancel identity creation", "Cancel identity creation", "cancel-generated-key") { + actions.cancelGeneratedKeyBackup() + } + } + }, + ) +} + +@Composable +fun IdentityChooserCanvas( + model: HarvestCircleUiModel, + actions: HarvestCircleUiActions, + onReadOnly: () -> Unit, +) { + CanvasScaffold( + textSize = TextSizePreference.Default, + header = { BasicText("Choose a Nostr identity") }, + body = { + LazyColumn(Modifier.fillMaxWidth().testTag("saved-identity-list")) { + items(model.identities, key = IdentityUiModel::publicKeyHex) { identity -> + IdentityRow(identity, model, actions) + } + } + }, + actionBar = { + Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { + ShellAction("Create another identity", "Create another identity", "choose-create-identity") { + actions.chooseCreateIdentity() + } + ShellAction("Import identity", "Import identity", "choose-import-identity") { + actions.chooseImportIdentity() + } + ShellAction("Explore read-only", "Explore read-only", "chooser-read-only", onClick = onReadOnly) + } + }, + ) +} + +@Composable +private fun IdentityRow( + identity: IdentityUiModel, + model: HarvestCircleUiModel, + actions: HarvestCircleUiActions, +) { + Column( + Modifier + .fillMaxWidth() + .semantics { selected = identity.selected } + .testTag("identity-row:${identity.publicKeyHex}"), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + BasicText(identity.label) + BasicText(identity.shortNpub) + BasicText( + if (identity.signerAvailability == "available") { + "Local credential available" + } else { + "Local credential unavailable" + }, + ) + if (identity.selected) BasicText("Selected") + ShellAction( + label = if (identity.selected) "Selected identity" else "Select identity", + description = "Select ${identity.label}", + tag = "select-identity:${identity.publicKeyHex}", + enabled = !model.busy && !identity.selected, + ) { + actions.selectIdentity(identity.publicKeyHex) + } + ShellAction( + label = if (identity.active) "Active identity" else "Activate identity", + description = "Activate ${identity.label}", + tag = "activate-identity:${identity.publicKeyHex}", + enabled = !model.busy && !identity.active, + ) { + actions.activateIdentity(identity.publicKeyHex) + } + ShellAction( + "Remove local identity", + "Remove ${identity.label}", + "remove-identity:${identity.publicKeyHex}", + enabled = !model.busy, + ) { + actions.requestIdentityRemoval(identity.publicKeyHex) + } + if (model.pendingRemovalPublicKeyHex == identity.publicKeyHex) { + BasicText("Remove this saved identity?") + model.removalImpact?.takeIf { it.deletesLocalCredential }?.let { + BasicText("Its local credential will be deleted from the operating-system keyring.") + } + model.removalImpact?.takeIf { it.signsOut }?.let { + BasicText("The active session will be signed out before removal.") + } + ShellAction("Keep identity", "Keep identity", "remove-cancel") { actions.cancelIdentityRemoval() } + ShellAction( + "Remove local identity", + "Remove local identity", + "remove-confirm", + enabled = !model.busy, + ) { + actions.confirmIdentityRemoval() + } + } + } +} 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 @@ -53,7 +53,16 @@ fun HarvestCircleShell( ) }, ) - else -> HarvestCircleScreen(state.identity.toUiModel(), identityActions, platformActions) + BootstrapStep.GeneratedRecovery -> + GeneratedRecoveryCanvas(state.identity.toUiModel(), identityActions, platformActions) + BootstrapStep.IdentityChooser, + BootstrapStep.ActivationProgress, + -> + IdentityChooserCanvas( + model = state.identity.toUiModel(), + actions = identityActions, + onReadOnly = { dispatch(HarvestCircleShellIntent.EnterReadOnly) }, + ) } is ShellRoot.Dashboard -> DashboardRoot(state, root, dispatch) } diff --git a/app/shared/src/commonTest/kotlin/org/harvestcircle/application/ShellAvailabilityTest.kt b/app/shared/src/commonTest/kotlin/org/harvestcircle/application/ShellAvailabilityTest.kt @@ -30,6 +30,26 @@ class ShellAvailabilityTest { } @Test + fun bootstrapEntryAndRecoveryFollowPresenterTruth() { + val signedOut = presenterState(HarvestCircleRoute.IDENTITIES) + assertEquals( + ShellRoot.BootstrapCanvas(BootstrapStep.CreateIdentity), + deriveShellRoot(signedOut.copy(identityEntryMode = IdentityEntryMode.CREATE), ShellSessionState()), + ) + assertEquals( + ShellRoot.BootstrapCanvas(BootstrapStep.ImportIdentity), + deriveShellRoot(signedOut.copy(identityEntryMode = IdentityEntryMode.IMPORT), ShellSessionState()), + ) + assertEquals( + ShellRoot.BootstrapCanvas(BootstrapStep.GeneratedRecovery), + deriveShellRoot( + signedOut.copy(generatedKeyBackup = GeneratedKeyBackup("npub1generated", "nsec1generated")), + ShellSessionState(), + ), + ) + } + + @Test fun disabledFeaturesCannotDispatch() { val state = NavigationState(AppRoute.PersonalToday) assertSame(state, activateShellDestination(state, ShellDestination.Explore)) diff --git a/app/shared/src/desktopTest/kotlin/org/harvestcircle/ui/shell/BootstrapRecoveryAndChooserTest.kt b/app/shared/src/desktopTest/kotlin/org/harvestcircle/ui/shell/BootstrapRecoveryAndChooserTest.kt @@ -0,0 +1,122 @@ +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.assertCountEquals +import androidx.compose.ui.test.onAllNodesWithTag +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performClick +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.GeneratedKeyBackupUiModel +import org.harvestcircle.identities.ui.HarvestCirclePlatformActions +import org.harvestcircle.identities.ui.HarvestCircleUiActions +import org.harvestcircle.identities.ui.HarvestCircleUiModel +import org.harvestcircle.identities.ui.IdentityUiModel +import kotlin.test.Test +import kotlin.test.assertEquals + +@OptIn(ExperimentalTestApi::class) +class BootstrapRecoveryAndChooserTest { + @Test + fun oneUseRecoveryOwnsCopyAcknowledgementAndCancellation() = + runComposeUiTest { + var backup: GeneratedKeyBackupUiModel? by + mutableStateOf(GeneratedKeyBackupUiModel("npub1generated", "nsec1generated")) + var copied = "" + var cancelled = 0 + setContent { + backup?.let { + GeneratedRecoveryCanvas( + model = model(generatedKeyBackup = it), + actions = + HarvestCircleUiActions( + acknowledgeGeneratedKeyBackup = { backup = null }, + cancelGeneratedKeyBackup = { cancelled += 1 }, + ), + platformActions = HarvestCirclePlatformActions(copySecret = { copied = it }), + ) + } + } + + onAllNodesWithTag("generated-nsec").assertCountEquals(1) + onNodeWithTag("copy-generated-key").performClick() + assertEquals("nsec1generated", copied) + onNodeWithTag("cancel-generated-key").performClick() + assertEquals(1, cancelled) + onNodeWithTag("acknowledge-key-backup").performClick() + onAllNodesWithTag("generated-key-backup").assertCountEquals(0) + onAllNodesWithTag("generated-nsec").assertCountEquals(0) + } + + @Test + fun chooserExposesExplicitSelectionActivationAndRemoval() = + runComposeUiTest { + val first = identity("first", selected = true) + val second = identity("second") + var selected = "" + var activated = "" + var removal = "" + setContent { + IdentityChooserCanvas( + model = model(identities = listOf(first, second)), + actions = + HarvestCircleUiActions( + selectIdentity = { selected = it }, + activateIdentity = { activated = it }, + requestIdentityRemoval = { removal = it }, + ), + onReadOnly = {}, + ) + } + + onNodeWithTag("select-identity:second").performClick() + onNodeWithTag("activate-identity:second").performClick() + onNodeWithTag("remove-identity:second").performClick() + assertEquals("second", selected) + assertEquals("second", activated) + assertEquals("second", removal) + } +} + +private fun identity( + id: String, + selected: Boolean = false, +) = IdentityUiModel( + publicKeyHex = id, + npub = "npub1$id", + shortNpub = "npub1$id", + label = id.replaceFirstChar(Char::uppercaseChar), + signerAvailability = "available", + selected = selected, + active = false, +) + +private fun model( + identities: List<IdentityUiModel> = emptyList(), + generatedKeyBackup: GeneratedKeyBackupUiModel? = null, +) = HarvestCircleUiModel( + route = HarvestCircleRoute.IDENTITIES, + identities = identities, + activeIdentity = null, + configuredRelays = emptyList(), + importDraft = "", + generatedKeyBackup = generatedKeyBackup, + pendingRemovalPublicKeyHex = null, + removalImpact = null, + removalStatus = RemovalStatus.NONE, + lastRemovedPublicKeyHex = null, + identityChooserVisible = false, + identityEntryMode = IdentityEntryMode.CHOICE, + session = SessionLifecycle.SignedOut, + busy = false, + problem = null, + importGuidance = null, + recoveryAction = RecoveryAction.None, +)