app

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

commit f2af587e7281a6f70f496f4bb3fdeee6bd1ebe65
parent 40b496c38043747244173568a247c61c41f6a802
Author: triesap <tyson@radroots.org>
Date:   Thu, 13 Aug 2026 19:05:28 +0000

ui: let read-only sessions manage identities

- expose identity entry from the global signer-status overlay
- leave read-only mode and close its overlay in one reducer transition
- preserve accessible focus containment across both status actions
- prove read-only through native generation activation and removal

Diffstat:
Mapp/desktop/src/hostUiTest/kotlin/org/harvestcircle/hostui/IdentityLifecycleHostUiTest.kt | 6++++++
Mapp/shared/src/commonMain/kotlin/org/harvestcircle/application/HarvestCircleShellPresenter.kt | 3+++
Mapp/shared/src/commonMain/kotlin/org/harvestcircle/application/ShellAvailability.kt | 2++
Mapp/shared/src/commonMain/kotlin/org/harvestcircle/application/ShellReducer.kt | 8++++++++
Mapp/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/FoundationOverlayHost.kt | 70+++++++++++++++++++++++++++++++++++++++++++++++++++++++---------------
Mapp/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/HarvestCircleShell.kt | 1+
Mapp/shared/src/commonTest/kotlin/org/harvestcircle/application/ShellReducerTest.kt | 16++++++++++++++++
Mapp/shared/src/desktopTest/kotlin/org/harvestcircle/ui/shell/FoundationOverlayHostTest.kt | 20++++++++++++++++++++
8 files changed, 111 insertions(+), 15 deletions(-)

diff --git a/app/desktop/src/hostUiTest/kotlin/org/harvestcircle/hostui/IdentityLifecycleHostUiTest.kt b/app/desktop/src/hostUiTest/kotlin/org/harvestcircle/hostui/IdentityLifecycleHostUiTest.kt @@ -70,6 +70,12 @@ class IdentityLifecycleHostUiTest { } waitForTag("bootstrap-welcome") + onNodeWithTag("bootstrap-read-only").performClick() + waitForTag("foundation-today") + onNodeWithTag("top-bar-signer").performClick() + waitForTag("signer-add-or-activate-identity") + onNodeWithTag("signer-add-or-activate-identity").performClick() + waitForTag("bootstrap-welcome") onNodeWithTag("bootstrap-create").performClick() onNodeWithTag("generate-key").performClick() waitForTag("generated-key-backup") diff --git a/app/shared/src/commonMain/kotlin/org/harvestcircle/application/HarvestCircleShellPresenter.kt b/app/shared/src/commonMain/kotlin/org/harvestcircle/application/HarvestCircleShellPresenter.kt @@ -39,6 +39,8 @@ sealed interface HarvestCircleShellIntent { data object EnterReadOnly : HarvestCircleShellIntent + data object ManageIdentity : HarvestCircleShellIntent + data class Navigate( val screenKey: ScreenKey, ) : HarvestCircleShellIntent @@ -81,6 +83,7 @@ class HarvestCircleShellPresenter( when (intent) { is HarvestCircleShellIntent.Identity -> identityPresenter.dispatch(intent.intent) HarvestCircleShellIntent.EnterReadOnly -> reduce(ShellEvent.EnterReadOnly) + HarvestCircleShellIntent.ManageIdentity -> reduce(ShellEvent.ManageIdentity) is HarvestCircleShellIntent.Navigate -> reduce(ShellEvent.Navigate(intent.screenKey)) is HarvestCircleShellIntent.Navigation -> reduce(ShellEvent.Navigation(intent.intent)) is HarvestCircleShellIntent.Overlay -> dispatchOverlay(intent.intent) diff --git a/app/shared/src/commonMain/kotlin/org/harvestcircle/application/ShellAvailability.kt b/app/shared/src/commonMain/kotlin/org/harvestcircle/application/ShellAvailability.kt @@ -14,6 +14,8 @@ data class ShellSessionState( val readOnly: Boolean = false, ) { fun enterReadOnly(): ShellSessionState = copy(readOnly = true) + + fun leaveReadOnly(): ShellSessionState = copy(readOnly = false) } enum class DegradationReason { diff --git a/app/shared/src/commonMain/kotlin/org/harvestcircle/application/ShellReducer.kt b/app/shared/src/commonMain/kotlin/org/harvestcircle/application/ShellReducer.kt @@ -14,6 +14,8 @@ sealed interface ShellEvent { data object EnterReadOnly : ShellEvent + data object ManageIdentity : ShellEvent + data class Navigate( val screenKey: ScreenKey, ) : ShellEvent @@ -47,6 +49,7 @@ object ShellReducer { when (event) { is ShellEvent.IdentityObserved -> observeIdentity(state, event.identity) ShellEvent.EnterReadOnly -> updateSession(state, state.session.enterReadOnly()) + ShellEvent.ManageIdentity -> manageIdentity(state) is ShellEvent.Navigate -> updateNavigation(state) { activateShellScreen(it, event.screenKey) } is ShellEvent.Navigation -> updateNavigation(state, event.intent) is ShellEvent.Overlay -> OverlayReducer.transition(state, event.intent).state @@ -114,6 +117,11 @@ object ShellReducer { ) } + private fun manageIdentity(state: HarvestCircleShellState): HarvestCircleShellState { + if (!state.session.readOnly) return state + return updateSession(state, state.session.leaveReadOnly()).copy(overlays = OverlayState()) + } + private fun updateSession( state: HarvestCircleShellState, session: ShellSessionState, diff --git a/app/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/FoundationOverlayHost.kt b/app/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/FoundationOverlayHost.kt @@ -29,6 +29,7 @@ import org.harvestcircle.application.FoundationOverlay import org.harvestcircle.application.OverlayIntent import org.harvestcircle.application.OverlayState import org.harvestcircle.application.ShellStatusModel +import org.harvestcircle.application.SignerStatusLabel import org.harvestcircle.application.StatusOverlayKey import org.harvestcircle.designsystem.shell.HarvestCircleShellBanner import org.harvestcircle.designsystem.shell.HarvestCircleShellBannerTone @@ -43,6 +44,7 @@ fun FoundationOverlayHost( state: OverlayState, status: ShellStatusModel, showBanner: Boolean = true, + onAddOrActivateIdentity: () -> Unit = {}, onIntent: (OverlayIntent) -> Unit, ) { if (showBanner) { @@ -74,8 +76,19 @@ fun FoundationOverlayHost( is FoundationOverlay.ConfirmAction -> ConfirmOverlay(overlay, overlayBusy, rootRequester, onIntent) is FoundationOverlay.Status -> when (overlay.key) { - StatusOverlayKey.Signer -> StatusOverlay("Signer status", status.signer.text, onIntent) - StatusOverlayKey.Sync -> StatusOverlay("Sync status", status.sync.text, onIntent) + StatusOverlayKey.Signer -> + StatusOverlay( + title = "Signer status", + status = status.signer.text, + primaryAction = + if (status.signer == SignerStatusLabel.ReadOnly) { + StatusOverlayAction("Add or activate identity", onAddOrActivateIdentity) + } else { + null + }, + onIntent = onIntent, + ) + StatusOverlayKey.Sync -> StatusOverlay("Sync status", status.sync.text, onIntent = onIntent) } is FoundationOverlay.OpenNostrReference -> ReferenceOverlay(overlay, onIntent) } @@ -151,27 +164,54 @@ private fun ConfirmOverlay( private fun StatusOverlay( title: String, status: String, + primaryAction: StatusOverlayAction? = null, onIntent: (OverlayIntent) -> Unit, ) { - val requester = remember { FocusRequester() } + val primaryRequester = remember { FocusRequester() } + val closeRequester = remember { FocusRequester() } Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { HarvestCircleShellText(title, Modifier.semantics { heading() }, HarvestCircleShellTextRole.SectionTitle) HarvestCircleShellText(status, Modifier.testTag("overlay-status")) - HarvestCircleShellButton( - "Close", - { onIntent(OverlayIntent.Close) }, - Modifier - .focusRequester(requester) - .focusProperties { - next = requester - previous = requester - }.modalFocusCycle(requester, requester) - .testTag("overlay-close"), - ) + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + primaryAction?.let { action -> + HarvestCircleShellButton( + action.label, + action.onClick, + Modifier + .focusRequester(primaryRequester) + .focusProperties { + next = closeRequester + previous = closeRequester + }.modalFocusCycle(closeRequester, closeRequester) + .testTag("signer-add-or-activate-identity"), + primary = true, + ) + } + HarvestCircleShellButton( + "Close", + { onIntent(OverlayIntent.Close) }, + Modifier + .focusRequester(closeRequester) + .focusProperties { + next = if (primaryAction == null) closeRequester else primaryRequester + previous = if (primaryAction == null) closeRequester else primaryRequester + }.modalFocusCycle( + if (primaryAction == null) closeRequester else primaryRequester, + if (primaryAction == null) closeRequester else primaryRequester, + ).testTag("overlay-close"), + ) + } + } + LaunchedEffect(primaryAction != null) { + if (primaryAction == null) closeRequester.requestFocus() else primaryRequester.requestFocus() } - LaunchedEffect(Unit) { requester.requestFocus() } } +private data class StatusOverlayAction( + val label: String, + val onClick: () -> Unit, +) + @Composable private fun ReferenceOverlay( overlay: FoundationOverlay.OpenNostrReference, 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 @@ -125,6 +125,7 @@ private fun HarvestCircleShellContent( state = state.overlays, status = deriveShellStatus(state), showBanner = state.root !is ShellRoot.Dashboard, + onAddOrActivateIdentity = { dispatch(HarvestCircleShellIntent.ManageIdentity) }, ) { dispatch(HarvestCircleShellIntent.Overlay(it)) } diff --git a/app/shared/src/commonTest/kotlin/org/harvestcircle/application/ShellReducerTest.kt b/app/shared/src/commonTest/kotlin/org/harvestcircle/application/ShellReducerTest.kt @@ -51,6 +51,22 @@ class ShellReducerTest { } @Test + fun manageIdentityLeavesReadOnlyAndReturnsToSavedIdentityChooserAtomically() { + val entered = + ShellReducer.reduce( + HarvestCircleShellState(signedOutPresenterState(1UL), BuildInfo.unknown()), + ShellEvent.EnterReadOnly, + ) + val readOnly = entered.copy(overlays = OverlayState(FoundationOverlay.Status(StatusOverlayKey.Signer))) + + val managed = ShellReducer.reduce(readOnly, ShellEvent.ManageIdentity) + + assertEquals(false, managed.session.readOnly) + assertEquals(ShellRoot.BootstrapCanvas(org.harvestcircle.navigation.BootstrapStep.IdentityChooser), managed.root) + assertEquals(OverlayState(), managed.overlays) + } + + @Test fun presenterAtomicallyRetainsConcurrentIndependentEvents() = runTest { val identity = ReducerIdentityPresentation(activePresenterState(1UL)) diff --git a/app/shared/src/desktopTest/kotlin/org/harvestcircle/ui/shell/FoundationOverlayHostTest.kt b/app/shared/src/desktopTest/kotlin/org/harvestcircle/ui/shell/FoundationOverlayHostTest.kt @@ -186,6 +186,26 @@ class FoundationOverlayHostTest { } @Test + fun readOnlySignerStatusOffersTheIdentityEntryActionAndContainsFocus() = + runComposeUiTest { + var actionCount = 0 + val state = OverlayState(FoundationOverlay.Status(org.harvestcircle.application.StatusOverlayKey.Signer)) + setHarvestCircleContent { + FoundationOverlayHost( + state = state, + status = ShellStatusModel(SyncStatusLabel.NotYetObserved, SignerStatusLabel.ReadOnly, null), + onAddOrActivateIdentity = { actionCount += 1 }, + onIntent = {}, + ) + } + + onNodeWithTag("signer-add-or-activate-identity").assertIsFocused().pressTab() + onNodeWithTag("overlay-close").assertIsFocused().pressTab() + onNodeWithTag("signer-add-or-activate-identity").assertIsFocused().performClick() + kotlin.test.assertEquals(1, actionCount) + } + + @Test fun openStatusDialogRendersTheLatestStatusModel() = runComposeUiTest { var status by mutableStateOf(status())