app

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

commit 3d6a327bd6141372cd00acd791d30dbacb0b17fe
parent ddad30f5016260f3c8d6b4966f2c0e22a6abb78f
Author: triesap <tyson@radroots.org>
Date:   Tue, 11 Aug 2026 21:53:13 +0000

ui: remove the legacy identity surface

- split reusable identity actions and platform ports from legacy composition
- render lifecycle and standalone failures with the active themed shell
- delete the old home, chooser, entry, recovery, colors, and inline controls
- migrate lifecycle behavior coverage and desktop host imports to the shell

Diffstat:
Mapp/desktop/src/main/kotlin/org/harvestcircle/application/HarvestCircleApplication.kt | 5+++--
Mapp/desktop/src/main/kotlin/org/harvestcircle/desktop/Main.kt | 2+-
Dapp/shared/src/commonMain/kotlin/org/harvestcircle/identities/ui/HarvestCircleScreen.kt | 528-------------------------------------------------------------------------------
Aapp/shared/src/commonMain/kotlin/org/harvestcircle/identities/ui/IdentityUiActions.kt | 29+++++++++++++++++++++++++++++
Mapp/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/HarvestCircleShell.kt | 3+--
Mapp/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/HarvestCircleTheme.kt | 2+-
Aapp/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/LifecycleCanvases.kt | 126+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Dapp/shared/src/desktopTest/kotlin/org/harvestcircle/identities/ui/HarvestCircleScreenTest.kt | 394-------------------------------------------------------------------------------
Aapp/shared/src/desktopTest/kotlin/org/harvestcircle/ui/shell/LifecycleCanvasesTest.kt | 86+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
9 files changed, 247 insertions(+), 928 deletions(-)

diff --git a/app/desktop/src/main/kotlin/org/harvestcircle/application/HarvestCircleApplication.kt b/app/desktop/src/main/kotlin/org/harvestcircle/application/HarvestCircleApplication.kt @@ -18,9 +18,9 @@ import kotlinx.coroutines.withTimeoutOrNull import org.harvestcircle.ffi.generateOperationIdV7 import org.harvestcircle.identities.ui.HarvestCirclePlatformActions import org.harvestcircle.identities.ui.HarvestCircleUiActions -import org.harvestcircle.identities.ui.ShutdownFailureScreen -import org.harvestcircle.identities.ui.StartupFailureScreen import org.harvestcircle.ui.shell.HarvestCircleShell +import org.harvestcircle.ui.shell.ShutdownFailureScreen +import org.harvestcircle.ui.shell.StartupFailureScreen import java.util.concurrent.atomic.AtomicBoolean internal typealias HarvestCirclePresenterFactory = (CoroutineScope) -> HarvestCirclePresenter @@ -119,6 +119,7 @@ internal fun HarvestCircleApplicationWithDependencies( confirmIdentityRemoval = { presenter.dispatch(HarvestCircleIntent.ConfirmIdentityRemoval) }, refreshActiveProfile = { presenter.dispatch(HarvestCircleIntent.RefreshActiveProfile) }, retryLastCommand = { presenter.dispatch(HarvestCircleIntent.RetryLastCommand) }, + dismissProblem = { presenter.dispatch(HarvestCircleIntent.DismissProblem) }, signOut = { presenter.dispatch(HarvestCircleIntent.SignOut) }, showIdentityChooser = { presenter.dispatch(HarvestCircleIntent.ShowIdentityChooser) }, hideIdentityChooser = { presenter.dispatch(HarvestCircleIntent.HideIdentityChooser) }, diff --git a/app/desktop/src/main/kotlin/org/harvestcircle/desktop/Main.kt b/app/desktop/src/main/kotlin/org/harvestcircle/desktop/Main.kt @@ -18,7 +18,7 @@ import org.harvestcircle.application.desktopRuntimeOpenConfiguration import org.harvestcircle.application.verifyNativeCompatibility import org.harvestcircle.ffi.HarvestCircleException import org.harvestcircle.ffi.compatibilityDescriptor -import org.harvestcircle.identities.ui.StartupFailureScreen +import org.harvestcircle.ui.shell.StartupFailureScreen import java.awt.Dimension import java.awt.Taskbar import java.nio.file.FileVisitResult diff --git a/app/shared/src/commonMain/kotlin/org/harvestcircle/identities/ui/HarvestCircleScreen.kt b/app/shared/src/commonMain/kotlin/org/harvestcircle/identities/ui/HarvestCircleScreen.kt @@ -1,528 +0,0 @@ -package org.harvestcircle.identities.ui - -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.ColumnScope -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.text.BasicText -import androidx.compose.foundation.text.BasicTextField -import androidx.compose.foundation.verticalScroll -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.graphics.Color -import androidx.compose.ui.platform.testTag -import androidx.compose.ui.semantics.Role -import androidx.compose.ui.semantics.contentDescription -import androidx.compose.ui.semantics.disabled -import androidx.compose.ui.semantics.password -import androidx.compose.ui.semantics.role -import androidx.compose.ui.semantics.selected -import androidx.compose.ui.semantics.semantics -import androidx.compose.ui.text.input.PasswordVisualTransformation -import androidx.compose.ui.unit.dp -import org.harvestcircle.application.HarvestCircleRoute -import org.harvestcircle.application.IdentityEntryMode - -private val WindowBackgroundColor = Color(0xFFF5F5F2) -private val ButtonBackgroundColor = Color(0xFFE7E7E2) -private val InputBackgroundColor = Color(0xFFFEFDF8) - -data class HarvestCircleUiActions( - val chooseCreateIdentity: () -> Unit = {}, - val chooseImportIdentity: () -> Unit = {}, - val cancelIdentityEntry: () -> Unit = {}, - val editImportDraft: (String) -> Unit = {}, - val generateIdentity: () -> Unit = {}, - val importSecretKey: () -> Unit = {}, - val acknowledgeGeneratedKeyBackup: () -> Unit = {}, - val cancelGeneratedKeyBackup: () -> Unit = {}, - val selectIdentity: (String) -> Unit = {}, - val activateIdentity: (String) -> Unit = {}, - val requestIdentityRemoval: (String) -> Unit = {}, - val cancelIdentityRemoval: () -> Unit = {}, - val confirmIdentityRemoval: () -> Unit = {}, - val refreshActiveProfile: () -> Unit = {}, - val retryLastCommand: () -> Unit = {}, - val signOut: () -> Unit = {}, - val showIdentityChooser: () -> Unit = {}, - val hideIdentityChooser: () -> Unit = {}, -) - -data class HarvestCirclePlatformActions( - val copySecret: (String) -> Unit = {}, - val openSource: () -> Unit = {}, - val openLicence: () -> Unit = {}, -) - -@Composable -fun StartupFailureScreen(problem: String) { - Column( - modifier = - Modifier - .fillMaxSize() - .background(WindowBackgroundColor) - .padding(24.dp) - .verticalScroll(rememberScrollState()) - .testTag("startup-failure"), - verticalArrangement = Arrangement.spacedBy(16.dp), - ) { - BasicText("HarvestCircle") - BasicText(problem, Modifier.testTag("startup-problem")) - } -} - -@Composable -fun ShutdownFailureScreen( - problem: String, - forceExit: () -> Unit, -) { - Column( - modifier = - Modifier - .fillMaxSize() - .background(WindowBackgroundColor) - .padding(24.dp) - .verticalScroll(rememberScrollState()) - .testTag("shutdown-failure"), - verticalArrangement = Arrangement.spacedBy(16.dp), - ) { - BasicText("HarvestCircle could not close safely") - BasicText(problem, Modifier.testTag("shutdown-problem")) - TextAction( - text = "Force exit", - testTag = "force-exit", - contentDescription = "Force HarvestCircle to exit", - onClick = forceExit, - ) - } -} - -@Composable -fun HarvestCircleScreen( - model: HarvestCircleUiModel, - actions: HarvestCircleUiActions, - platformActions: HarvestCirclePlatformActions = HarvestCirclePlatformActions(), -) { - model.generatedKeyBackup?.let { backup -> - GeneratedKeyRecoveryScreen(backup, actions, platformActions) - return - } - when (model.route) { - HarvestCircleRoute.OPENING -> LifecycleScreen("Opening local identity store", "lifecycle-opening") - HarvestCircleRoute.CHECKING_COMPATIBILITY -> - LifecycleScreen( - "Checking native compatibility", - "lifecycle-compatibility", - ) - HarvestCircleRoute.ACQUIRING_OWNERSHIP -> - LifecycleScreen( - "Acquiring local identity store", - "lifecycle-ownership", - ) - HarvestCircleRoute.MIGRATING -> - LifecycleScreen( - "Updating local identity store", - "lifecycle-migrating", - ) - HarvestCircleRoute.RECOVERING -> - LifecycleScreen( - "Recovering local identity state", - "lifecycle-recovering", - ) - HarvestCircleRoute.SHUTTING_DOWN -> LifecycleScreen("Shutting down", "lifecycle-shutting-down") - HarvestCircleRoute.CLOSED -> LifecycleScreen("Closed", "lifecycle-closed") - HarvestCircleRoute.BLOCKED -> - LifecycleScreen( - model.problem ?: "Local identity access is blocked.", - "lifecycle-blocked", - ) - HarvestCircleRoute.FATAL -> - LifecycleScreen( - model.problem ?: "The application could not continue.", - "lifecycle-fatal", - ) - HarvestCircleRoute.DEGRADED -> InactiveIdentitiesScreen(model, actions, degraded = true) - HarvestCircleRoute.ACTIVE_IDENTITY -> { - if (model.activeIdentity != null && !model.identityChooserVisible) { - ActiveIdentityHome(model, model.activeIdentity, actions) - } else { - InactiveIdentitiesScreen(model, actions) - } - } - HarvestCircleRoute.IDENTITIES -> InactiveIdentitiesScreen(model, actions) - } -} - -@Composable -private fun LifecycleScreen( - message: String, - testTag: String, -) { - Column( - modifier = - Modifier - .fillMaxSize() - .background(WindowBackgroundColor) - .padding(24.dp) - .testTag(testTag), - verticalArrangement = Arrangement.spacedBy(16.dp), - ) { - BasicText("HarvestCircle") - BasicText(message) - } -} - -@Composable -private fun ActiveIdentityHome( - model: HarvestCircleUiModel, - active: ActiveIdentityUiModel, - actions: HarvestCircleUiActions, -) { - Column( - modifier = - Modifier - .fillMaxSize() - .background(WindowBackgroundColor) - .padding(24.dp) - .verticalScroll(rememberScrollState()) - .testTag("home-screen"), - verticalArrangement = Arrangement.spacedBy(10.dp), - ) { - BasicText("HarvestCircle") - BasicText(active.heading) - BasicText(active.identity.npub, Modifier.testTag("active-npub")) - BasicText(active.identity.publicKeyHex, Modifier.testTag("active-pubkey-hex")) - BasicText("Name: ${active.profile.name}", Modifier.testTag("active-profile-name")) - BasicText("Display name: ${active.profile.displayName}") - BasicText("NIP-05 (unverified): ${active.profile.nip05}") - BasicText("About: ${active.profile.about}", Modifier.testTag("active-profile-about")) - BasicText("Picture: ${active.profile.picture}") - BasicText("Relay: ${active.relayState.displayText()}", Modifier.testTag("relay-state")) - BasicText("Profile: ${active.profileState.displayText()}", Modifier.testTag("profile-state")) - BasicText("Configured relays") - if (model.configuredRelays.isEmpty()) { - BasicText("None") - } else { - model.configuredRelays.forEach { relay -> BasicText(relay) } - } - TextAction( - text = "Switch identity", - testTag = "switch-identity", - contentDescription = "Choose another saved identity", - enabled = !model.busy, - onClick = actions.showIdentityChooser, - ) - TextAction( - text = "Refresh metadata", - testTag = "refresh-profile", - contentDescription = "Refresh active Nostr profile metadata", - enabled = !model.busy, - onClick = actions.refreshActiveProfile, - ) - TextAction( - text = "Sign out", - testTag = "sign-out", - contentDescription = "Sign out of the active identity", - enabled = !model.busy, - onClick = actions.signOut, - ) - model.problem?.let { BasicText(it, Modifier.testTag("home-problem")) } - RecoveryAction(model, actions) - } -} - -@Composable -private fun InactiveIdentitiesScreen( - model: HarvestCircleUiModel, - actions: HarvestCircleUiActions, - degraded: Boolean = false, -) { - Column( - modifier = - Modifier - .fillMaxSize() - .background(WindowBackgroundColor) - .padding(24.dp) - .testTag("identities-screen"), - verticalArrangement = Arrangement.spacedBy(16.dp), - ) { - BasicText("HarvestCircle") - BasicText("Identities") - if (degraded) { - BasicText(model.problem ?: "Nostr relay access is unavailable. Local identities remain available.") - } - - if (model.activeIdentity != null) { - BasicText("Choose an identity to activate. The current identity remains active until replacement succeeds.") - TextAction( - text = "Back to active identity", - testTag = "return-home", - contentDescription = "Return to the active identity", - onClick = actions.hideIdentityChooser, - ) - } - - IdentityEntry(model, actions) - - model.problem?.let { - BasicText(it, Modifier.testTag("identities-problem")) - } - RecoveryAction(model, actions) - - if (model.identities.isEmpty()) { - BasicText("No saved identities.", Modifier.testTag("identities-empty")) - } else { - SavedIdentityList(model, actions) - } - } -} - -@Composable -private fun RecoveryAction( - model: HarvestCircleUiModel, - actions: HarvestCircleUiActions, -) { - if (model.recoveryAction == org.harvestcircle.application.RecoveryAction.Retry) { - TextAction( - text = "Retry", - testTag = "retry-last-command", - contentDescription = "Retry the last failed action", - enabled = !model.busy, - onClick = actions.retryLastCommand, - ) - } -} - -@Composable -private fun IdentityEntry( - model: HarvestCircleUiModel, - actions: HarvestCircleUiActions, -) { - when (model.identityEntryMode) { - IdentityEntryMode.CHOICE -> { - TextAction( - text = "Create identity", - testTag = "choose-create-identity", - contentDescription = "Create a new Nostr identity", - enabled = !model.busy, - onClick = actions.chooseCreateIdentity, - ) - TextAction( - text = "Import key", - testTag = "choose-import-identity", - contentDescription = "Import an existing Nostr secret key", - enabled = !model.busy, - onClick = actions.chooseImportIdentity, - ) - } - IdentityEntryMode.CREATE -> { - TextAction( - text = "Back", - testTag = "cancel-identity-entry", - contentDescription = "Return to identity choices", - enabled = !model.busy, - onClick = actions.cancelIdentityEntry, - ) - TextAction( - text = "Generate new key", - testTag = "generate-key", - contentDescription = "Generate a new Nostr key", - enabled = !model.busy && model.generatedKeyBackup == null, - onClick = actions.generateIdentity, - ) - } - IdentityEntryMode.IMPORT -> { - val importFocusRequester = remember { FocusRequester() } - LaunchedEffect(Unit) { importFocusRequester.requestFocus() } - TextAction( - text = "Back", - testTag = "cancel-identity-entry", - contentDescription = "Return to identity choices", - enabled = !model.busy, - onClick = actions.cancelIdentityEntry, - ) - BasicTextField( - value = model.importDraft, - onValueChange = actions.editImportDraft, - enabled = !model.busy, - visualTransformation = PasswordVisualTransformation(), - modifier = - Modifier - .fillMaxWidth() - .semantics { - contentDescription = "Nostr secret key" - password() - }.focusRequester(importFocusRequester) - .testTag("import-nsec-input") - .background(InputBackgroundColor) - .padding(8.dp), - decorationBox = { innerTextField -> - if (model.importDraft.isEmpty()) BasicText("nsec or secret-key hex") - innerTextField() - }, - ) - model.importGuidance?.let { guidance -> - BasicText(guidance, Modifier.testTag("import-guidance")) - } - 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.SavedIdentityList( - model: HarvestCircleUiModel, - actions: HarvestCircleUiActions, -) { - LazyColumn( - modifier = - Modifier - .fillMaxWidth() - .weight(1f) - .testTag("saved-identity-list"), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - items(model.identities, key = IdentityUiModel::publicKeyHex) { identity -> - Column( - modifier = - Modifier - .fillMaxWidth() - .semantics { selected = identity.selected } - .testTag("identity-row:${identity.publicKeyHex}") - .background(InputBackgroundColor) - .padding(12.dp), - verticalArrangement = Arrangement.spacedBy(6.dp), - ) { - BasicText(identity.label) - BasicText(identity.npub) - BasicText("Key: ${identity.signerAvailability.displayText()}") - if (identity.selected) BasicText("Selected") - if (identity.active) BasicText("Active") - TextAction( - text = if (identity.selected) "Selected identity" else "Select", - testTag = "select-identity:${identity.publicKeyHex}", - contentDescription = "Select ${identity.label}", - enabled = !model.busy && !identity.selected, - onClick = { actions.selectIdentity(identity.publicKeyHex) }, - ) - TextAction( - text = if (identity.active) "Active identity" else "Activate", - testTag = "activate-identity:${identity.publicKeyHex}", - contentDescription = "Activate ${identity.label}", - enabled = !model.busy && !identity.active, - onClick = { actions.activateIdentity(identity.publicKeyHex) }, - ) - TextAction( - text = "Remove", - testTag = "remove-identity:${identity.publicKeyHex}", - contentDescription = "Remove ${identity.label}", - enabled = !model.busy, - onClick = { actions.requestIdentityRemoval(identity.publicKeyHex) }, - ) - if (model.pendingRemovalPublicKeyHex == identity.publicKeyHex) { - BasicText("Remove this saved identity?") - if (model.removalImpact?.deletesLocalCredential == true) { - BasicText("Its local credential will be deleted from the operating-system keyring.") - } - if (model.removalImpact?.signsOut == true) { - BasicText("The active session will be signed out before removal.") - } - TextAction( - text = "Cancel", - testTag = "remove-cancel", - contentDescription = "Cancel identity removal", - onClick = actions.cancelIdentityRemoval, - ) - TextAction( - text = "Confirm removal", - testTag = "remove-confirm", - contentDescription = "Confirm identity removal", - enabled = !model.busy, - onClick = actions.confirmIdentityRemoval, - ) - } - } - } - } -} - -@Composable -private fun GeneratedKeyRecoveryScreen( - backup: GeneratedKeyBackupUiModel, - actions: HarvestCircleUiActions, - platformActions: HarvestCirclePlatformActions, -) { - Column( - modifier = - Modifier - .fillMaxSize() - .background(WindowBackgroundColor) - .padding(24.dp) - .verticalScroll(rememberScrollState()) - .testTag("generated-key-backup"), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - BasicText("Save this key") - BasicText("Losing this secret key means losing access to the identity.") - BasicText(backup.npub) - BasicText(backup.nsec, Modifier.testTag("generated-nsec")) - TextAction( - text = "Copy", - testTag = "copy-generated-key", - contentDescription = "Copy generated Nostr secret key", - onClick = { platformActions.copySecret(backup.nsec) }, - ) - TextAction( - text = "Cancel", - testTag = "cancel-generated-key", - contentDescription = "Cancel generated identity", - onClick = actions.cancelGeneratedKeyBackup, - ) - TextAction( - text = "I have saved this key", - testTag = "acknowledge-key-backup", - contentDescription = "Confirm generated key backup", - onClick = actions.acknowledgeGeneratedKeyBackup, - ) - } -} - -@Composable -internal fun TextAction( - text: String, - testTag: String, - contentDescription: String, - enabled: Boolean = true, - onClick: () -> Unit, -) { - BasicText( - text = text, - modifier = - Modifier - .semantics { - role = Role.Button - this.contentDescription = contentDescription - if (!enabled) disabled() - }.testTag(testTag) - .then(if (enabled) Modifier.clickable(onClick = onClick) else Modifier) - .background(ButtonBackgroundColor) - .padding(8.dp), - ) -} diff --git a/app/shared/src/commonMain/kotlin/org/harvestcircle/identities/ui/IdentityUiActions.kt b/app/shared/src/commonMain/kotlin/org/harvestcircle/identities/ui/IdentityUiActions.kt @@ -0,0 +1,29 @@ +package org.harvestcircle.identities.ui + +data class HarvestCircleUiActions( + val chooseCreateIdentity: () -> Unit = {}, + val chooseImportIdentity: () -> Unit = {}, + val cancelIdentityEntry: () -> Unit = {}, + val editImportDraft: (String) -> Unit = {}, + val generateIdentity: () -> Unit = {}, + val importSecretKey: () -> Unit = {}, + val acknowledgeGeneratedKeyBackup: () -> Unit = {}, + val cancelGeneratedKeyBackup: () -> Unit = {}, + val selectIdentity: (String) -> Unit = {}, + val activateIdentity: (String) -> Unit = {}, + val requestIdentityRemoval: (String) -> Unit = {}, + val cancelIdentityRemoval: () -> Unit = {}, + val confirmIdentityRemoval: () -> Unit = {}, + val refreshActiveProfile: () -> Unit = {}, + val retryLastCommand: () -> Unit = {}, + val dismissProblem: () -> Unit = {}, + val signOut: () -> Unit = {}, + val showIdentityChooser: () -> Unit = {}, + val hideIdentityChooser: () -> Unit = {}, +) + +data class HarvestCirclePlatformActions( + val copySecret: (String) -> Unit = {}, + val openSource: () -> Unit = {}, + val openLicence: () -> Unit = {}, +) 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 @@ -16,7 +16,6 @@ import org.harvestcircle.application.ShellRoot import org.harvestcircle.application.StatusOverlayKey import org.harvestcircle.application.deriveShellStatus import org.harvestcircle.identities.ui.HarvestCirclePlatformActions -import org.harvestcircle.identities.ui.HarvestCircleScreen import org.harvestcircle.identities.ui.HarvestCircleUiActions import org.harvestcircle.identities.ui.toUiModel import org.harvestcircle.navigation.AppRoute @@ -47,7 +46,7 @@ private fun HarvestCircleShellContent( ) { when (val root = state.root) { is ShellRoot.LifecycleCanvas -> - HarvestCircleScreen(state.identity.toUiModel(), identityActions, platformActions) + ShellLifecycleCanvas(state.identity, identityActions) is ShellRoot.BootstrapCanvas -> when (root.step) { BootstrapStep.Welcome -> BootstrapWelcome(dispatch) diff --git a/app/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/HarvestCircleTheme.kt b/app/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/HarvestCircleTheme.kt @@ -109,5 +109,5 @@ fun HarvestCircleTheme( fun ColorToken.toComposeColor(): Color { val rgb = hex.removePrefix("#").toLong(16) - return Color(0xFF000000 or rgb) + return Color(rgb.toInt()).copy(alpha = 1f) } diff --git a/app/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/LifecycleCanvases.kt b/app/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/LifecycleCanvases.kt @@ -0,0 +1,126 @@ +package org.harvestcircle.ui.shell + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.unit.dp +import org.harvestcircle.application.HarvestCirclePresenterState +import org.harvestcircle.application.HarvestCircleRoute +import org.harvestcircle.design.AppearanceState +import org.harvestcircle.design.TextSizePreference +import org.harvestcircle.identities.ui.HarvestCircleUiActions + +@Composable +fun ShellLifecycleCanvas( + state: HarvestCirclePresenterState, + actions: HarvestCircleUiActions, +) { + val presentation = state.route.lifecyclePresentation() + CanvasScaffold( + textSize = TextSizePreference.Default, + header = { ShellText(presentation.title, textRole = ShellTextRole.ScreenTitle) }, + body = { + Column( + Modifier.testTag("lifecycle-${state.route.name.lowercase()}"), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + ShellText(presentation.detail) + state.problem?.let { ShellText(it, Modifier.testTag("lifecycle-problem")) } + } + }, + actionBar = { + Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { + if (state.lastProblem?.retryable == true) { + ShellButton("Retry", "Retry the last local operation", actions.retryLastCommand) + } + if (state.problem != null) { + ShellButton("Dismiss", "Dismiss this problem", actions.dismissProblem, kind = ShellButtonKind.Quiet) + } + } + }, + ) +} + +@Composable +fun StartupFailureScreen(problem: String) { + HarvestCircleTheme(AppearanceState()) { + FailureCanvas( + title = "HarvestCircle could not start", + problem = problem, + tag = "startup-failure", + ) + } +} + +@Composable +fun ShutdownFailureScreen( + problem: String, + forceExit: () -> Unit, +) { + HarvestCircleTheme(AppearanceState()) { + Box(Modifier.fillMaxSize().testTag("shutdown-failure")) { + CanvasScaffold( + textSize = TextSizePreference.Default, + header = { ShellText("HarvestCircle could not close safely", textRole = ShellTextRole.ScreenTitle) }, + body = { ShellText(problem, Modifier.testTag("shutdown-problem")) }, + actionBar = { + ShellButton( + "Force exit", + "Force HarvestCircle to exit", + forceExit, + Modifier.testTag("force-exit"), + kind = ShellButtonKind.Destructive, + ) + }, + ) + } + } +} + +@Composable +private fun FailureCanvas( + title: String, + problem: String, + tag: String, +) { + CanvasScaffold( + textSize = TextSizePreference.Default, + header = { ShellText(title, textRole = ShellTextRole.ScreenTitle) }, + body = { + Column(Modifier.testTag(tag)) { + ShellText(problem, Modifier.testTag("startup-problem")) + } + }, + actionBar = {}, + ) +} + +private data class LifecyclePresentation( + val title: String, + val detail: String, +) + +private fun HarvestCircleRoute.lifecyclePresentation(): LifecyclePresentation = + when (this) { + HarvestCircleRoute.OPENING -> LifecyclePresentation("Opening HarvestCircle", "Opening the local identity store.") + HarvestCircleRoute.CHECKING_COMPATIBILITY -> + LifecyclePresentation( + "Checking compatibility", + "Checking the native runtime contract.", + ) + HarvestCircleRoute.ACQUIRING_OWNERSHIP -> LifecyclePresentation("Opening local data", "Acquiring exclusive local-store ownership.") + HarvestCircleRoute.MIGRATING -> LifecyclePresentation("Updating local data", "Applying the supported local schema migration.") + HarvestCircleRoute.RECOVERING -> LifecyclePresentation("Recovering local data", "Completing an interrupted local operation.") + HarvestCircleRoute.DEGRADED, HarvestCircleRoute.BLOCKED -> + LifecyclePresentation("Local data needs attention", "HarvestCircle cannot safely open the product shell.") + HarvestCircleRoute.SHUTTING_DOWN -> LifecyclePresentation("Closing HarvestCircle", "Closing the native runtime safely.") + HarvestCircleRoute.FATAL -> LifecyclePresentation("HarvestCircle could not start", "The local runtime reported a terminal problem.") + HarvestCircleRoute.CLOSED -> LifecyclePresentation("HarvestCircle is closed", "The local runtime has closed.") + HarvestCircleRoute.IDENTITIES, HarvestCircleRoute.ACTIVE_IDENTITY -> + LifecyclePresentation("HarvestCircle", "Preparing the product shell.") + } diff --git a/app/shared/src/desktopTest/kotlin/org/harvestcircle/identities/ui/HarvestCircleScreenTest.kt b/app/shared/src/desktopTest/kotlin/org/harvestcircle/identities/ui/HarvestCircleScreenTest.kt @@ -1,394 +0,0 @@ -package org.harvestcircle.identities.ui - -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.assertIsDisplayed -import androidx.compose.ui.test.assertIsFocused -import androidx.compose.ui.test.assertIsNotEnabled -import androidx.compose.ui.test.assertIsSelected -import androidx.compose.ui.test.hasTestTag -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.performScrollToNode -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.IdentityId -import org.harvestcircle.application.ProfileLoadState -import org.harvestcircle.application.RecoveryAction -import org.harvestcircle.application.RelayConnectionState -import org.harvestcircle.application.RemovalImpactState -import org.harvestcircle.application.RemovalStatus -import org.harvestcircle.application.SessionLifecycle -import org.harvestcircle.application.SignerAvailability -import org.harvestcircle.application.UnixSeconds -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertTrue - -@OptIn(ExperimentalTestApi::class) -class HarvestCircleScreenTest { - @Test - fun rendersEveryNonReadyLifecycleRouteWithoutIdentityControls() = - runComposeUiTest { - var model by mutableStateOf(emptyUiModel().copy(route = HarvestCircleRoute.OPENING)) - setContent { HarvestCircleScreen(model, HarvestCircleUiActions()) } - - val routes = - listOf( - HarvestCircleRoute.OPENING to "lifecycle-opening", - HarvestCircleRoute.CHECKING_COMPATIBILITY to "lifecycle-compatibility", - HarvestCircleRoute.ACQUIRING_OWNERSHIP to "lifecycle-ownership", - HarvestCircleRoute.MIGRATING to "lifecycle-migrating", - HarvestCircleRoute.RECOVERING to "lifecycle-recovering", - HarvestCircleRoute.BLOCKED to "lifecycle-blocked", - HarvestCircleRoute.SHUTTING_DOWN to "lifecycle-shutting-down", - HarvestCircleRoute.FATAL to "lifecycle-fatal", - HarvestCircleRoute.CLOSED to "lifecycle-closed", - ) - routes.forEach { (route, tag) -> - model = emptyUiModel(problem = "Safe lifecycle problem").copy(route = route) - waitForIdle() - onNodeWithTag(tag).assertIsDisplayed() - onAllNodesWithTag("generate-key").assertCountEquals(0) - } - - model = emptyUiModel(problem = "Relay access is unavailable.").copy(route = HarvestCircleRoute.DEGRADED) - waitForIdle() - onNodeWithTag("identities-screen").assertIsDisplayed() - onNodeWithTag("identities-problem").assertIsDisplayed() - } - - @Test - fun inactiveScreenGeneratesAndImportsMaskedSecretInput() = - runComposeUiTest { - var importDraft by mutableStateOf("") - var identityEntryMode by mutableStateOf(IdentityEntryMode.CHOICE) - var generateCalls = 0 - var importCalls = 0 - setContent { - HarvestCircleScreen( - model = emptyUiModel(importDraft = importDraft).copy(identityEntryMode = identityEntryMode), - actions = - HarvestCircleUiActions( - chooseCreateIdentity = { identityEntryMode = IdentityEntryMode.CREATE }, - chooseImportIdentity = { identityEntryMode = IdentityEntryMode.IMPORT }, - cancelIdentityEntry = { identityEntryMode = IdentityEntryMode.CHOICE }, - editImportDraft = { importDraft = it }, - generateIdentity = { generateCalls += 1 }, - importSecretKey = { importCalls += 1 }, - ), - ) - } - - onNodeWithTag("identities-screen").assertIsDisplayed() - onNodeWithText("HarvestCircle").assertIsDisplayed() - onNodeWithTag("choose-create-identity").performClick() - onNodeWithTag("generate-key").performClick() - onNodeWithTag("cancel-identity-entry").performClick() - onNodeWithTag("choose-import-identity").performClick() - onNodeWithTag("import-nsec-input").assertIsFocused() - onNodeWithTag("import-nsec-input").performTextInput("nsec1secret") - onNodeWithTag("import-key").performClick() - - assertEquals(1, generateCalls) - assertEquals(1, importCalls) - assertEquals("nsec1secret", importDraft) - assertTrue( - onNodeWithTag("import-nsec-input").fetchSemanticsNode().config.any { - it.key.name == "Password" && it.value == Unit - }, - ) - } - - @Test - fun inactiveScreenShowsSafeFailureAndNoGenericFields() = - runComposeUiTest { - setContent { - HarvestCircleScreen( - model = emptyUiModel(problem = "The secret key is invalid."), - actions = HarvestCircleUiActions(), - ) - } - - onNodeWithText("The secret key is invalid.").assertIsDisplayed() - onNodeWithTag("identities-empty").assertIsDisplayed() - } - - @Test - fun generatedKeyBackupCopiesAndClearsOnlyAfterAcknowledgement() = - runComposeUiTest { - var backup: GeneratedKeyBackupUiModel? by mutableStateOf( - GeneratedKeyBackupUiModel("npub1generated", "nsec1generated"), - ) - var copied: String? = null - setContent { - HarvestCircleScreen( - model = emptyUiModel().copy(generatedKeyBackup = backup), - actions = - HarvestCircleUiActions( - acknowledgeGeneratedKeyBackup = { backup = null }, - ), - platformActions = HarvestCirclePlatformActions(copySecret = { copied = it }), - ) - } - - onNodeWithTag("generated-key-backup").assertIsDisplayed() - onAllNodesWithTag("identities-screen").assertCountEquals(0) - onAllNodesWithTag("generate-key").assertCountEquals(0) - onNodeWithTag("generated-nsec").assertIsDisplayed() - onNodeWithTag("copy-generated-key").performClick() - assertEquals("nsec1generated", copied) - - onNodeWithTag("acknowledge-key-backup").performClick() - onAllNodesWithTag("generated-key-backup").assertCountEquals(0) - onAllNodesWithTag("generated-nsec").assertCountEquals(0) - } - - @Test - fun generatedKeyRecoveryCanBeCancelledWithoutExposingIdentityControls() = - runComposeUiTest { - var backup: GeneratedKeyBackupUiModel? by mutableStateOf( - GeneratedKeyBackupUiModel("npub1generated", "nsec1generated"), - ) - var cancelled = 0 - setContent { - HarvestCircleScreen( - model = emptyUiModel().copy(generatedKeyBackup = backup), - actions = - HarvestCircleUiActions( - cancelGeneratedKeyBackup = { - cancelled += 1 - backup = null - }, - ), - ) - } - - onNodeWithTag("cancel-generated-key").performClick() - assertEquals(1, cancelled) - onAllNodesWithTag("generated-key-backup").assertCountEquals(0) - } - - @Test - fun savedIdentitiesSelectActivateAndRequireRemovalConfirmation() = - runComposeUiTest { - val first = identityUi("11".repeat(32), selected = true) - val second = identityUi("22".repeat(32), selected = false) - var pendingRemoval: String? by mutableStateOf(null) - val selected = mutableListOf<String>() - val activated = mutableListOf<String>() - var confirmations = 0 - setContent { - HarvestCircleScreen( - model = - emptyUiModel().copy( - identities = listOf(first, second), - pendingRemovalPublicKeyHex = pendingRemoval, - removalImpact = - pendingRemoval?.let { - RemovalImpactState( - IdentityId.fromPublicKeyHex(it), - deletesLocalCredential = true, - signsOut = true, - expiresAt = UnixSeconds(60), - ) - }, - ), - actions = - HarvestCircleUiActions( - selectIdentity = selected::add, - activateIdentity = activated::add, - requestIdentityRemoval = { pendingRemoval = it }, - cancelIdentityRemoval = { pendingRemoval = null }, - confirmIdentityRemoval = { confirmations += 1 }, - ), - ) - } - - onNodeWithTag("saved-identity-list").assertIsDisplayed() - onNodeWithTag("identity-row:${first.publicKeyHex}").assertIsSelected() - onNodeWithTag("select-identity:${second.publicKeyHex}", useUnmergedTree = true).performClick() - onNodeWithTag("activate-identity:${second.publicKeyHex}", useUnmergedTree = true).performClick() - assertEquals(listOf(second.publicKeyHex), selected) - assertEquals(listOf(second.publicKeyHex), activated) - - onNodeWithTag("remove-identity:${second.publicKeyHex}", useUnmergedTree = true).performClick() - onNodeWithText("Its local credential will be deleted from the operating-system keyring.").assertIsDisplayed() - onNodeWithText("The active session will be signed out before removal.").assertIsDisplayed() - onNodeWithTag("remove-cancel", useUnmergedTree = true).performClick() - assertEquals(null, pendingRemoval) - onNodeWithTag("remove-identity:${second.publicKeyHex}", useUnmergedTree = true).performClick() - onNodeWithTag("remove-confirm", useUnmergedTree = true).performClick() - assertEquals(1, confirmations) - } - - @Test - fun savedIdentityListRemainsReachableForLargeRegistries() = - runComposeUiTest { - val identities = - (0 until 100).map { index -> - identityUi(index.toString(16).padStart(64, '0'), selected = index == 0) - } - setContent { - HarvestCircleScreen( - model = emptyUiModel().copy(identities = identities), - actions = HarvestCircleUiActions(), - ) - } - - val lastTag = "identity-row:${identities.last().publicKeyHex}" - onNodeWithTag("saved-identity-list").performScrollToNode(hasTestTag(lastTag)) - onNodeWithTag(lastTag).assertIsDisplayed() - } - - @Test - fun activeHomeShowsIdentityProfileRelayAndCommands() = - runComposeUiTest { - var refreshCalls = 0 - var signOutCalls = 0 - val identity = identityUi("33".repeat(32), selected = true) - val active = - ActiveIdentityUiModel( - identity = identity, - heading = "Alice", - relayState = RelayConnectionState.Connected, - profileState = ProfileLoadState.Fresh, - profile = - ProfileUiModel( - name = "alice", - displayName = "Alice", - nip05 = "alice@example.com", - about = "Local grower", - picture = "https://example.com/alice.png", - ), - ) - setContent { - HarvestCircleScreen( - model = - emptyUiModel().copy( - route = HarvestCircleRoute.ACTIVE_IDENTITY, - identities = listOf(identity), - activeIdentity = active, - configuredRelays = listOf("ws://localhost:8080"), - session = SessionLifecycle.Active, - ), - actions = - HarvestCircleUiActions( - refreshActiveProfile = { refreshCalls += 1 }, - signOut = { signOutCalls += 1 }, - ), - ) - } - - onNodeWithTag("home-screen").assertIsDisplayed() - onNodeWithTag("active-npub").assertIsDisplayed() - onNodeWithTag("active-pubkey-hex").assertIsDisplayed() - onNodeWithTag("active-profile-name").assertIsDisplayed() - onNodeWithTag("active-profile-about").assertIsDisplayed() - onNodeWithTag("relay-state").assertIsDisplayed() - onNodeWithTag("profile-state").assertIsDisplayed() - onNodeWithText("ws://localhost:8080").assertIsDisplayed() - onNodeWithTag("refresh-profile").performClick() - onNodeWithTag("sign-out").performClick() - assertEquals(1, refreshCalls) - assertEquals(1, signOutCalls) - } - - @Test - fun activeIdentityCanOpenChooserWithoutDroppingCurrentSession() = - runComposeUiTest { - val first = identityUi("44".repeat(32), selected = true, active = true) - val second = identityUi("55".repeat(32), selected = false) - val active = - ActiveIdentityUiModel( - identity = first, - heading = first.label, - relayState = RelayConnectionState.Connected, - profileState = ProfileLoadState.Cached, - profile = ProfileUiModel("", "", "", "", ""), - ) - var chooserVisible by mutableStateOf(false) - var activated: String? = null - setContent { - HarvestCircleScreen( - model = - emptyUiModel().copy( - route = HarvestCircleRoute.ACTIVE_IDENTITY, - identities = listOf(first, second), - activeIdentity = active, - session = SessionLifecycle.Active, - identityChooserVisible = chooserVisible, - ), - actions = - HarvestCircleUiActions( - showIdentityChooser = { chooserVisible = true }, - hideIdentityChooser = { chooserVisible = false }, - activateIdentity = { activated = it }, - ), - ) - } - - onNodeWithTag("switch-identity").performClick() - onNodeWithTag("identities-screen").assertIsDisplayed() - onNodeWithTag("activate-identity:${first.publicKeyHex}", useUnmergedTree = true).assertIsNotEnabled() - onNodeWithText("Active").assertIsDisplayed() - onNodeWithTag("activate-identity:${second.publicKeyHex}", useUnmergedTree = true).performClick() - assertEquals(second.publicKeyHex, activated) - assertEquals( - SessionLifecycle.Active, - emptyUiModel() - .copy( - activeIdentity = active, - session = SessionLifecycle.Active, - ).session, - ) - onNodeWithTag("return-home").performClick() - onNodeWithTag("home-screen").assertIsDisplayed() - } -} - -private fun emptyUiModel( - importDraft: String = "", - problem: String? = null, - importGuidance: String? = null, - recoveryAction: RecoveryAction = RecoveryAction.None, -) = 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.CHOICE, - session = SessionLifecycle.SignedOut, - busy = false, - problem = problem, - importGuidance = importGuidance, - recoveryAction = recoveryAction, -) - -private fun identityUi( - publicKeyHex: String, - selected: Boolean, - active: Boolean = false, -) = IdentityUiModel( - publicKeyHex = publicKeyHex, - npub = "npub1${publicKeyHex.take(12)}", - shortNpub = "npub1${publicKeyHex.take(12)}", - label = "Identity ${publicKeyHex.take(2)}", - signerAvailability = SignerAvailability.Available, - selected = selected, - active = active, -) diff --git a/app/shared/src/desktopTest/kotlin/org/harvestcircle/ui/shell/LifecycleCanvasesTest.kt b/app/shared/src/desktopTest/kotlin/org/harvestcircle/ui/shell/LifecycleCanvasesTest.kt @@ -0,0 +1,86 @@ +package org.harvestcircle.ui.shell + +import androidx.compose.ui.test.ExperimentalTestApi +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.ApplicationErrorCategory +import org.harvestcircle.application.ApplicationErrorCode +import org.harvestcircle.application.ApplicationLifecycle +import org.harvestcircle.application.ApplicationProblem +import org.harvestcircle.application.ApplicationSnapshot +import org.harvestcircle.application.HarvestCirclePresenterState +import org.harvestcircle.application.RecoveryAction +import org.harvestcircle.application.SessionLifecycle +import org.harvestcircle.application.SnapshotRevision +import org.harvestcircle.identities.ui.HarvestCircleUiActions +import kotlin.test.Test +import kotlin.test.assertEquals + +@OptIn(ExperimentalTestApi::class) +class LifecycleCanvasesTest { + @Test + fun blockedLifecycleUsesTheThemeAndTypedRecoveryActions() = + runComposeUiTest { + var retries = 0 + var dismissals = 0 + setContent { + HarvestCircleTheme(org.harvestcircle.design.AppearanceState()) { + ShellLifecycleCanvas( + presenterState(), + HarvestCircleUiActions( + retryLastCommand = { retries += 1 }, + dismissProblem = { dismissals += 1 }, + ), + ) + } + } + + onNodeWithTag("lifecycle-blocked").assertExists() + onNodeWithText("Retry").performClick() + onNodeWithText("Dismiss").performClick() + assertEquals(1, retries) + assertEquals(1, dismissals) + } + + @Test + fun standaloneFailuresUseTheActiveShellControls() = + runComposeUiTest { + var forced = 0 + setContent { ShutdownFailureScreen("Shutdown timed out.") { forced += 1 } } + onNodeWithTag("shutdown-problem").assertExists() + onNodeWithTag("force-exit").performClick() + assertEquals(1, forced) + } +} + +private fun presenterState(): HarvestCirclePresenterState { + val problem = + ApplicationProblem( + ApplicationErrorCode.StorageUnavailable, + ApplicationErrorCategory.Storage, + retryable = true, + RecoveryAction.RepairStorage, + operationId = null, + safeMessage = "The local store needs attention.", + ) + return HarvestCirclePresenterState( + snapshot = + ApplicationSnapshot( + SnapshotRevision(1UL), + ApplicationLifecycle.Blocked, + lifecycleProblem = problem, + configuredRelays = emptyList(), + identities = emptyList(), + selectedIdentityId = null, + session = SessionLifecycle.SignedOut, + sessionSubjectIdentityId = null, + sessionProblem = null, + activeIdentity = null, + recoverableProblem = null, + ), + lastProblem = problem, + problem = problem.safeMessage, + ) +}