commit 40b496c38043747244173568a247c61c41f6a802 parent fc02f1922a9ce99ee8efc34e81cca9a4e6fc6b01 Author: triesap <tyson@radroots.org> Date: Thu, 13 Aug 2026 18:53:59 +0000 ui: make identity activation observable - track the admitted activation target through the presenter lifecycle - render an accessible progress canvas while native activation runs - surface safe activation failures ahead of signed-out guidance - cover success, failure, routing, and Compose semantics Diffstat:
10 files changed, 157 insertions(+), 6 deletions(-)
diff --git a/app/shared/src/commonMain/kotlin/org/harvestcircle/application/HarvestCirclePresenter.kt b/app/shared/src/commonMain/kotlin/org/harvestcircle/application/HarvestCirclePresenter.kt @@ -210,18 +210,32 @@ class HarvestCirclePresenter( intent: HarvestCircleIntent, operationId: OperationId = operationIds.next(), ) { - launchOperation(operationId = operationId) { - val command = intent.toApplicationCommand() + val activationTarget = (intent as? HarvestCircleIntent.ActivateIdentity)?.identityId + launchOperation( + operationId = operationId, + onAccepted = { + activationTarget?.let { target -> + updateState { copy(activatingIdentityId = target) } + } + }, + ) { try { + val command = intent.toApplicationCommand() acceptResult(runtime.execute(command), operationId) pendingRetry = null - if (intent is HarvestCircleIntent.ActivateIdentity || intent == HarvestCircleIntent.SignOut) { + if (activationTarget != null || intent == HarvestCircleIntent.SignOut) { updateState { copy(identityChooserVisible = false) } } } catch (error: Exception) { val problem = error.toProblem(operationId) pendingRetry = PendingRetry(intent, operationId).takeIf { problem.retryable } throw ApplicationFailure(problem) + } finally { + activationTarget?.let { target -> + updateState { + if (activatingIdentityId == target) copy(activatingIdentityId = null) else this + } + } } } } diff --git a/app/shared/src/commonMain/kotlin/org/harvestcircle/application/PresentationModels.kt b/app/shared/src/commonMain/kotlin/org/harvestcircle/application/PresentationModels.kt @@ -57,6 +57,7 @@ data class HarvestCirclePresenterState( val lastRemovedIdentityId: IdentityId? = null, val identityChooserVisible: Boolean = false, val identityEntryMode: IdentityEntryMode = IdentityEntryMode.CHOICE, + val activatingIdentityId: IdentityId? = null, val busy: Boolean = false, val commandStatus: CommandStatus = CommandStatus.IDLE, val lastCommandOperationId: OperationId? = null, diff --git a/app/shared/src/commonMain/kotlin/org/harvestcircle/application/ShellAvailability.kt b/app/shared/src/commonMain/kotlin/org/harvestcircle/application/ShellAvailability.kt @@ -105,6 +105,9 @@ fun deriveShellRoot( if (presenterState.generatedKeyBackup != null) { return ShellRoot.BootstrapCanvas(BootstrapStep.GeneratedRecovery) } + if (presenterState.activatingIdentityId != null) { + return ShellRoot.BootstrapCanvas(BootstrapStep.ActivationProgress) + } if (presenterState.snapshot.activeIdentity != null || session.readOnly) { return ShellRoot.Dashboard(NavigationState(AppRoute.PersonalToday)) } diff --git a/app/shared/src/commonMain/kotlin/org/harvestcircle/application/ShellStatus.kt b/app/shared/src/commonMain/kotlin/org/harvestcircle/application/ShellStatus.kt @@ -63,6 +63,8 @@ private fun deriveBanner( when { state.identity.lastProblem?.category == ApplicationErrorCategory.Storage -> GlobalStatusBanner("Local data needs attention", "Review the local runtime status before continuing.", BannerSeverity.Critical) + state.identity.problem != null -> + GlobalStatusBanner("Identity action could not complete", state.identity.problem, BannerSeverity.Caution) sync == SyncStatusLabel.Unavailable -> GlobalStatusBanner("Offline", "Public views may be out of date.", BannerSeverity.Caution) sync == SyncStatusLabel.Degraded -> 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 @@ -1,6 +1,7 @@ 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 @@ -8,11 +9,14 @@ import androidx.compose.foundation.lazy.items import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.platform.testTag +import androidx.compose.ui.semantics.LiveRegionMode +import androidx.compose.ui.semantics.liveRegion import androidx.compose.ui.semantics.selected import androidx.compose.ui.semantics.semantics import androidx.compose.ui.unit.dp import org.harvestcircle.appearance.TextSizePreference import org.harvestcircle.application.ShellFocusTarget +import org.harvestcircle.designsystem.component.feedback.HarvestCircleProgressIndicator import org.harvestcircle.designsystem.shell.HarvestCircleShellButton import org.harvestcircle.designsystem.shell.HarvestCircleShellPalette import org.harvestcircle.designsystem.shell.HarvestCircleShellPanel @@ -97,6 +101,43 @@ fun IdentityChooserCanvas( } @Composable +fun IdentityActivationCanvas( + model: HarvestCircleUiModel, + activatingPublicKeyHex: String, +) { + val identity = model.identities.singleOrNull { it.publicKeyHex == activatingPublicKeyHex } + CanvasScaffold( + textSize = TextSizePreference.Default, + header = { HarvestCircleShellText("Activating identity", role = HarvestCircleShellTextRole.PaneTitle) }, + body = { + Column( + Modifier + .fillMaxWidth() + .semantics { liveRegion = LiveRegionMode.Polite } + .testTag("identity-activation-progress"), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + HarvestCircleShellPanel { + HarvestCircleProgressIndicator(Modifier.testTag("identity-activation-indicator")) + HarvestCircleShellText( + identity?.label ?: "Selected identity", + role = HarvestCircleShellTextRole.SectionTitle, + ) + identity?.let { + HarvestCircleShellText(it.shortNpub, role = HarvestCircleShellTextRole.Code) + } + HarvestCircleShellText( + "Checking the local credential and preparing signed actions.", + color = HarvestCircleShellPalette.contentSecondary, + ) + } + } + }, + actionBar = {}, + ) +} + +@Composable private fun IdentityRow( identity: IdentityUiModel, model: HarvestCircleUiModel, 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 @@ -105,14 +105,17 @@ private fun HarvestCircleShellContent( ) BootstrapStep.GeneratedRecovery -> GeneratedRecoveryCanvas(state.identity.toUiModel(), identityActions, platformActions) - BootstrapStep.IdentityChooser, - BootstrapStep.ActivationProgress, - -> + BootstrapStep.IdentityChooser -> IdentityChooserCanvas( model = state.identity.toUiModel(), actions = identityActions, onReadOnly = { dispatch(HarvestCircleShellIntent.EnterReadOnly) }, ) + BootstrapStep.ActivationProgress -> + IdentityActivationCanvas( + model = state.identity.toUiModel(), + activatingPublicKeyHex = checkNotNull(state.identity.activatingIdentityId).value, + ) } is ShellRoot.Dashboard -> DashboardRoot(state, root, identityActions, platformActions, dispatch) } diff --git a/app/shared/src/commonTest/kotlin/org/harvestcircle/application/HarvestCirclePresenterTest.kt b/app/shared/src/commonTest/kotlin/org/harvestcircle/application/HarvestCirclePresenterTest.kt @@ -113,6 +113,55 @@ class HarvestCirclePresenterTest { } @Test + fun activationTargetIsObservableOnlyWhileTheCommandIsRunning() = + runTest { + val gate = CompletableDeferred<Unit>() + val runtime = FakePresenterRuntime(executeGate = gate) + val presenter = presenter(runtime) + runCurrent() + val identityId = IdentityId.fromPublicKeyHex("01".repeat(32)) + + presenter.dispatch(HarvestCircleIntent.ActivateIdentity(identityId)) + runCurrent() + + assertEquals(identityId, presenter.state.value.activatingIdentityId) + assertTrue(presenter.state.value.busy) + + gate.complete(Unit) + advanceUntilIdle() + + assertNull(presenter.state.value.activatingIdentityId) + assertFalse(presenter.state.value.busy) + presenter.close() + } + + @Test + fun failedActivationClearsProgressAndPublishesTheSafeProblem() = + runTest { + val runtime = FakePresenterRuntime() + val presenter = presenter(runtime) + runCurrent() + val identityId = IdentityId.fromPublicKeyHex("01".repeat(32)) + runtime.nextFailure = + ApplicationProblem( + code = ApplicationErrorCode.CredentialMissing, + category = ApplicationErrorCategory.Credential, + retryable = false, + recoveryAction = RecoveryAction.RepairCredential, + operationId = null, + safeMessage = "The local credential is missing.", + ) + + presenter.dispatch(HarvestCircleIntent.ActivateIdentity(identityId)) + advanceUntilIdle() + + assertNull(presenter.state.value.activatingIdentityId) + assertEquals(CommandStatus.FAILED_TERMINAL, presenter.state.value.commandStatus) + assertEquals("The local credential is missing.", presenter.state.value.problem) + presenter.close() + } + + @Test fun retryReusesTheOriginalInjectedOperationIdentity() = runTest { val runtime = FakePresenterRuntime() diff --git a/app/shared/src/commonTest/kotlin/org/harvestcircle/application/ShellAvailabilityTest.kt b/app/shared/src/commonTest/kotlin/org/harvestcircle/application/ShellAvailabilityTest.kt @@ -48,6 +48,13 @@ class ShellAvailabilityTest { ShellSessionState(), ), ) + assertEquals( + ShellRoot.BootstrapCanvas(BootstrapStep.ActivationProgress), + deriveShellRoot( + signedOut.copy(activatingIdentityId = IdentityId.fromPublicKeyHex("01".repeat(32))), + ShellSessionState(), + ), + ) } @Test diff --git a/app/shared/src/commonTest/kotlin/org/harvestcircle/application/ShellStatusTest.kt b/app/shared/src/commonTest/kotlin/org/harvestcircle/application/ShellStatusTest.kt @@ -29,6 +29,19 @@ class ShellStatusTest { val signerMissing = deriveShellStatus(shellState(signer = SignerAvailability.CredentialMissing)) assertEquals("Signer unavailable", signerMissing.banner?.title) } + + @Test + fun currentIdentityFailureIsVisibleAheadOfSignedOutGuidance() { + val failure = problem(ApplicationErrorCategory.Credential, ApplicationErrorCode.CredentialMissing) + val state = shellState(active = false, lastProblem = failure) + val status = + deriveShellStatus( + state.copy(identity = state.identity.copy(problem = failure.safeMessage)), + ) + + assertEquals("Identity action could not complete", status.banner?.title) + assertEquals(failure.safeMessage, status.banner?.message) + } } private fun shellState( 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 @@ -5,8 +5,10 @@ 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.assertIsDisplayed import androidx.compose.ui.test.onAllNodesWithTag import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick import androidx.compose.ui.test.v2.runComposeUiTest import org.harvestcircle.application.HarvestCircleRoute @@ -83,6 +85,22 @@ class BootstrapRecoveryAndChooserTest { assertEquals("second", activated) assertEquals("second", removal) } + + @Test + fun activationProgressNamesTheTargetAndExposesIndeterminateStatus() = + runComposeUiTest { + setHarvestCircleContent { + IdentityActivationCanvas( + model = model(identities = listOf(identity("first", selected = true))), + activatingPublicKeyHex = "first", + ) + } + + onNodeWithTag("identity-activation-progress").assertIsDisplayed() + onNodeWithTag("identity-activation-indicator").assertIsDisplayed() + onNodeWithText("First").assertIsDisplayed() + onNodeWithText("Checking the local credential and preparing signed actions.").assertIsDisplayed() + } } private fun identity(