app

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

commit 075ba18de239ee7b3e1af88b6d96fbf52d3905a7
parent d1965a01ce8d055e636879127c472860da47961c
Author: triesap <tyson@radroots.org>
Date:   Tue, 11 Aug 2026 21:37:43 +0000

ui: implement typed modal overlays

- render foundation overlays as blocking desktop dialogs with owned focus
- route destructive confirmation through a closed typed action model
- migrate saved-identity removal out of the inline chooser surface
- prevent duplicate confirmation, cancellation, and reference input while busy

Diffstat:
Mapp/shared/src/commonMain/kotlin/org/harvestcircle/application/HarvestCircleShellPresenter.kt | 22+++++++++++++++++++++-
Mapp/shared/src/commonMain/kotlin/org/harvestcircle/application/ShellOverlays.kt | 3+++
Mapp/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/BootstrapRecoveryAndChooser.kt | 43++++++++++++++++++++++++-------------------
Mapp/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/FoundationOverlayHost.kt | 100++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------------------
Mapp/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/HarvestCircleShell.kt | 3++-
Mapp/shared/src/commonTest/kotlin/org/harvestcircle/application/HarvestCircleShellPresenterTest.kt | 26++++++++++++++++++++++++++
Mapp/shared/src/desktopTest/kotlin/org/harvestcircle/ui/shell/BootstrapRecoveryAndChooserTest.kt | 4++++
Mapp/shared/src/desktopTest/kotlin/org/harvestcircle/ui/shell/FoundationOverlayHostTest.kt | 43+++++++++++++++++++++++++++++++++++++++++++
8 files changed, 192 insertions(+), 52 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 @@ -82,7 +82,7 @@ class HarvestCircleShellPresenter( HarvestCircleShellIntent.EnterReadOnly -> reduce(ShellEvent.EnterReadOnly) is HarvestCircleShellIntent.Navigate -> reduce(ShellEvent.Navigate(intent.screenKey)) is HarvestCircleShellIntent.Navigation -> reduce(ShellEvent.Navigation(intent.intent)) - is HarvestCircleShellIntent.Overlay -> reduce(ShellEvent.Overlay(intent.intent)) + is HarvestCircleShellIntent.Overlay -> dispatchOverlay(intent.intent) is HarvestCircleShellIntent.SetTheme -> reduce(ShellEvent.SetTheme(intent.theme)) is HarvestCircleShellIntent.SetTextSize -> reduce(ShellEvent.SetTextSize(intent.textSize)) is HarvestCircleShellIntent.SetMotion -> reduce(ShellEvent.SetMotion(intent.motion)) @@ -97,6 +97,26 @@ class HarvestCircleShellPresenter( reduce(ShellEvent.IdentityObserved(identity)) } + private fun dispatchOverlay(intent: OverlayIntent) { + val confirmation = mutableState.value.overlays.current as? FoundationOverlay.ConfirmAction + when (intent) { + OverlayIntent.Confirm -> { + if (mutableState.value.identity.busy) return + when (confirmation?.action) { + ConfirmationAction.RemoveLocalIdentity -> + identityPresenter.dispatch(HarvestCircleIntent.ConfirmIdentityRemoval) + null -> Unit + } + } + OverlayIntent.Close, OverlayIntent.Escape -> + if (confirmation?.action == ConfirmationAction.RemoveLocalIdentity) { + identityPresenter.dispatch(HarvestCircleIntent.CancelIdentityRemoval) + } + else -> Unit + } + reduce(ShellEvent.Overlay(intent)) + } + private fun reduce(event: ShellEvent) { mutableState.update { current -> ShellReducer.reduce(current, event) } } diff --git a/app/shared/src/commonMain/kotlin/org/harvestcircle/application/ShellOverlays.kt b/app/shared/src/commonMain/kotlin/org/harvestcircle/application/ShellOverlays.kt @@ -8,6 +8,7 @@ sealed interface FoundationOverlay { val title: String, val explanation: String, val actionLabel: String, + val action: ConfirmationAction, ) : FoundationOverlay data class SignerStatus( @@ -24,6 +25,8 @@ sealed interface FoundationOverlay { ) : FoundationOverlay } +enum class ConfirmationAction { RemoveLocalIdentity } + enum class BannerSeverity { Information, Caution, Critical } data class GlobalStatusBanner( 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 @@ -12,6 +12,10 @@ 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.application.ConfirmationAction +import org.harvestcircle.application.FoundationOverlay +import org.harvestcircle.application.HarvestCircleShellIntent +import org.harvestcircle.application.OverlayIntent import org.harvestcircle.design.TextSizePreference import org.harvestcircle.identities.ui.HarvestCirclePlatformActions import org.harvestcircle.identities.ui.HarvestCircleUiActions @@ -57,6 +61,7 @@ fun IdentityChooserCanvas( model: HarvestCircleUiModel, actions: HarvestCircleUiActions, onReadOnly: () -> Unit, + dispatch: (HarvestCircleShellIntent) -> Unit = {}, ) { CanvasScaffold( textSize = TextSizePreference.Default, @@ -64,7 +69,7 @@ fun IdentityChooserCanvas( body = { LazyColumn(Modifier.fillMaxWidth().testTag("saved-identity-list")) { items(model.identities, key = IdentityUiModel::publicKeyHex) { identity -> - IdentityRow(identity, model, actions) + IdentityRow(identity, model, actions, dispatch) } } }, @@ -87,6 +92,7 @@ private fun IdentityRow( identity: IdentityUiModel, model: HarvestCircleUiModel, actions: HarvestCircleUiActions, + dispatch: (HarvestCircleShellIntent) -> Unit, ) { Column( Modifier @@ -128,24 +134,23 @@ private fun IdentityRow( enabled = !model.busy, ) { actions.requestIdentityRemoval(identity.publicKeyHex) - } - if (model.pendingRemovalPublicKeyHex == identity.publicKeyHex) { - ShellText("Remove this saved identity?", textRole = ShellTextRole.CardTitle) - model.removalImpact?.takeIf { it.deletesLocalCredential }?.let { - ShellText("Its local credential will be deleted from the operating-system keyring.") - } - model.removalImpact?.takeIf { it.signsOut }?.let { - ShellText("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() - } + dispatch( + HarvestCircleShellIntent.Overlay( + OverlayIntent.Open( + FoundationOverlay.ConfirmAction( + title = "Remove this saved identity?", + explanation = + if (identity.active) { + "Its local credential will be deleted and the active session will be signed out." + } else { + "Its local credential will be deleted from the operating-system keyring." + }, + actionLabel = "Remove local identity", + action = ConfirmationAction.RemoveLocalIdentity, + ), + ), + ), + ) } } } 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 @@ -1,18 +1,21 @@ package org.harvestcircle.ui.shell -import androidx.compose.foundation.background import androidx.compose.foundation.focusGroup -import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment +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.semantics +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog import org.harvestcircle.application.FoundationOverlay import org.harvestcircle.application.OverlayIntent import org.harvestcircle.application.OverlayState @@ -20,6 +23,7 @@ import org.harvestcircle.application.OverlayState @Composable fun FoundationOverlayHost( state: OverlayState, + busy: Boolean = false, onIntent: (OverlayIntent) -> Unit, ) { state.banner?.let { banner -> @@ -29,19 +33,19 @@ fun FoundationOverlayHost( ) } state.current?.let { overlay -> - Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - Column( + Dialog(onDismissRequest = { if (!busy) onIntent(OverlayIntent.Close) }) { + ShellSurface( Modifier - .background(LocalHarvestCirclePalette.current.surface.toComposeColor()) .focusGroup() .semantics { contentDescription = "Dialog" } - .testTag("foundation-overlay"), + .testTag("foundation-overlay") + .padding(24.dp), ) { when (overlay) { - is FoundationOverlay.ConfirmAction -> ConfirmOverlay(overlay, onIntent) + is FoundationOverlay.ConfirmAction -> ConfirmOverlay(overlay, busy, onIntent) is FoundationOverlay.SignerStatus -> StatusOverlay("Signer status", overlay.status.text, onIntent) is FoundationOverlay.SyncStatus -> StatusOverlay("Sync status", overlay.status.text, onIntent) - is FoundationOverlay.OpenNostrReference -> ReferenceOverlay(overlay, onIntent) + is FoundationOverlay.OpenNostrReference -> ReferenceOverlay(overlay, busy, onIntent) } } } @@ -51,12 +55,26 @@ fun FoundationOverlayHost( @Composable private fun ConfirmOverlay( overlay: FoundationOverlay.ConfirmAction, + busy: Boolean, onIntent: (OverlayIntent) -> Unit, ) { - ShellText(overlay.title, textRole = ShellTextRole.SectionTitle) - ShellText(overlay.explanation) - ShellAction(overlay.actionLabel, overlay.actionLabel, "overlay-confirm") { onIntent(OverlayIntent.Confirm) } - ShellAction("Cancel", "Cancel", "overlay-cancel") { onIntent(OverlayIntent.Close) } + val requester = remember { FocusRequester() } + Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { + ShellText(overlay.title, textRole = ShellTextRole.SectionTitle) + ShellText(overlay.explanation) + Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { + ShellButton( + overlay.actionLabel, + overlay.actionLabel, + { onIntent(OverlayIntent.Confirm) }, + Modifier.focusRequester(requester).testTag("overlay-confirm"), + enabled = !busy, + kind = ShellButtonKind.Destructive, + ) + ShellButton("Cancel", "Cancel", { onIntent(OverlayIntent.Close) }, Modifier.testTag("overlay-cancel"), !busy) + } + } + LaunchedEffect(Unit) { requester.requestFocus() } } @Composable @@ -65,29 +83,49 @@ private fun StatusOverlay( status: String, onIntent: (OverlayIntent) -> Unit, ) { - ShellText(title, textRole = ShellTextRole.SectionTitle) - ShellText(status, Modifier.testTag("overlay-status")) - ShellAction("Close", "Close", "overlay-close") { onIntent(OverlayIntent.Close) } + val requester = remember { FocusRequester() } + Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { + ShellText(title, textRole = ShellTextRole.SectionTitle) + ShellText(status, Modifier.testTag("overlay-status")) + ShellButton( + "Close", + "Close", + { onIntent(OverlayIntent.Close) }, + Modifier.focusRequester(requester).testTag("overlay-close"), + ) + } + LaunchedEffect(Unit) { requester.requestFocus() } } @Composable private fun ReferenceOverlay( overlay: FoundationOverlay.OpenNostrReference, + busy: Boolean, onIntent: (OverlayIntent) -> Unit, ) { - val requester = androidx.compose.runtime.remember { FocusRequester() } - ShellText("Open a Nostr reference", textRole = ShellTextRole.SectionTitle) - ShellTextField( - value = overlay.input, - onValueChange = { onIntent(OverlayIntent.EditReference(it)) }, - label = "Nostr reference", - placeholder = "npub1…, note1…, or nevent1…", - modifier = Modifier.focusRequester(requester).testTag("nostr-reference-input"), - ) - androidx.compose.runtime.LaunchedEffect(Unit) { requester.requestFocus() } - overlay.result?.let { ShellText(it.message, Modifier.testTag("nostr-reference-result")) } - ShellAction("Open reference", "Open Nostr reference", "nostr-reference-submit") { - onIntent(OverlayIntent.SubmitReference) + val requester = remember { FocusRequester() } + Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { + ShellText("Open a Nostr reference", textRole = ShellTextRole.SectionTitle) + ShellTextField( + value = overlay.input, + onValueChange = { onIntent(OverlayIntent.EditReference(it)) }, + label = "Nostr reference", + placeholder = "npub1…, note1…, or nevent1…", + modifier = Modifier.focusRequester(requester).testTag("nostr-reference-input"), + enabled = !busy, + ) + overlay.result?.let { ShellText(it.message, Modifier.testTag("nostr-reference-result")) } + Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { + ShellButton( + "Open reference", + "Open Nostr reference", + { onIntent(OverlayIntent.SubmitReference) }, + Modifier.testTag("nostr-reference-submit"), + enabled = !busy, + kind = ShellButtonKind.Primary, + ) + ShellButton("Cancel", "Cancel", { onIntent(OverlayIntent.Close) }, Modifier.testTag("overlay-cancel"), !busy) + } } - ShellAction("Cancel", "Cancel", "overlay-cancel") { onIntent(OverlayIntent.Close) } + 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 @@ -75,11 +75,12 @@ private fun HarvestCircleShellContent( model = state.identity.toUiModel(), actions = identityActions, onReadOnly = { dispatch(HarvestCircleShellIntent.EnterReadOnly) }, + dispatch = dispatch, ) } is ShellRoot.Dashboard -> DashboardRoot(state, root, identityActions, platformActions, dispatch) } - FoundationOverlayHost(state.overlays) { dispatch(HarvestCircleShellIntent.Overlay(it)) } + FoundationOverlayHost(state.overlays, state.identity.busy) { dispatch(HarvestCircleShellIntent.Overlay(it)) } } @Composable diff --git a/app/shared/src/commonTest/kotlin/org/harvestcircle/application/HarvestCircleShellPresenterTest.kt b/app/shared/src/commonTest/kotlin/org/harvestcircle/application/HarvestCircleShellPresenterTest.kt @@ -10,6 +10,7 @@ import org.harvestcircle.navigation.AppRoute import org.harvestcircle.product.ScreenKey import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertNull import kotlin.test.assertTrue @OptIn(ExperimentalCoroutinesApi::class) @@ -50,6 +51,31 @@ class HarvestCircleShellPresenterTest { } @Test + fun confirmationDispatchesTypedIdentityEffectsAndClosesExactlyOnce() = + runTest { + val identity = FakeIdentityPresentation(presenterState(HarvestCircleRoute.IDENTITIES)) + val presenter = HarvestCircleShellPresenter(identity, BuildInfo.unknown(), this) + val confirmation = + FoundationOverlay.ConfirmAction( + "Remove identity?", + "The local credential will be deleted.", + "Remove local identity", + ConfirmationAction.RemoveLocalIdentity, + ) + presenter.dispatch(HarvestCircleShellIntent.Overlay(OverlayIntent.Open(confirmation))) + presenter.dispatch(HarvestCircleShellIntent.Overlay(OverlayIntent.Confirm)) + + assertTrue(identity.intents.single() === HarvestCircleIntent.ConfirmIdentityRemoval) + assertNull(presenter.state.value.overlays.current) + + presenter.dispatch(HarvestCircleShellIntent.Overlay(OverlayIntent.Open(confirmation))) + presenter.dispatch(HarvestCircleShellIntent.Overlay(OverlayIntent.Close)) + assertTrue(identity.intents.last() === HarvestCircleIntent.CancelIdentityRemoval) + assertNull(presenter.state.value.overlays.current) + presenter.close() + } + + @Test fun usableDegradationPreservesDashboardNavigation() = runTest { val identity = FakeIdentityPresentation(activePresenterState(ApplicationLifecycle.Ready, null, 1UL)) 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 @@ -10,6 +10,7 @@ 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.HarvestCircleShellIntent import org.harvestcircle.application.IdentityEntryMode import org.harvestcircle.application.RecoveryAction import org.harvestcircle.application.RemovalStatus @@ -63,6 +64,7 @@ class BootstrapRecoveryAndChooserTest { var selected = "" var activated = "" var removal = "" + val shellIntents = mutableListOf<HarvestCircleShellIntent>() setContent { IdentityChooserCanvas( model = model(identities = listOf(first, second)), @@ -73,6 +75,7 @@ class BootstrapRecoveryAndChooserTest { requestIdentityRemoval = { removal = it }, ), onReadOnly = {}, + dispatch = shellIntents::add, ) } @@ -82,6 +85,7 @@ class BootstrapRecoveryAndChooserTest { assertEquals("second", selected) assertEquals("second", activated) assertEquals("second", removal) + kotlin.test.assertTrue(shellIntents.single() is HarvestCircleShellIntent.Overlay) } } 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 @@ -6,12 +6,14 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.test.ExperimentalTestApi import androidx.compose.ui.test.assertCountEquals import androidx.compose.ui.test.assertIsFocused +import androidx.compose.ui.test.assertIsNotEnabled import androidx.compose.ui.test.onAllNodesWithTag 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.BannerSeverity +import org.harvestcircle.application.ConfirmationAction import org.harvestcircle.application.FoundationOverlay import org.harvestcircle.application.GlobalStatusBanner import org.harvestcircle.application.OverlayIntent @@ -39,4 +41,45 @@ class FoundationOverlayHostTest { state = OverlayReducer.reduce(state, OverlayIntent.Escape) onAllNodesWithTag("foundation-overlay").assertCountEquals(0) } + + @Test + fun destructiveConfirmationOwnsFocusAndDispatchesTypedSubmission() = + runComposeUiTest { + val intents = mutableListOf<OverlayIntent>() + val state = + OverlayState( + current = + FoundationOverlay.ConfirmAction( + "Remove identity?", + "The local credential will be deleted.", + "Remove local identity", + ConfirmationAction.RemoveLocalIdentity, + ), + ) + setContent { FoundationOverlayHost(state, onIntent = intents::add) } + + onNodeWithTag("overlay-confirm").assertIsFocused().performClick() + kotlin.test.assertEquals(listOf<OverlayIntent>(OverlayIntent.Confirm), intents) + } + + @Test + fun busyConfirmationBlocksDuplicateSubmissionAndDismissal() = + runComposeUiTest { + val intents = mutableListOf<OverlayIntent>() + val state = + OverlayState( + current = + FoundationOverlay.ConfirmAction( + "Remove identity?", + "The local credential will be deleted.", + "Remove local identity", + ConfirmationAction.RemoveLocalIdentity, + ), + ) + setContent { FoundationOverlayHost(state, busy = true, onIntent = intents::add) } + + onNodeWithTag("overlay-confirm").assertIsNotEnabled().performClick() + onNodeWithTag("overlay-cancel").assertIsNotEnabled() + kotlin.test.assertTrue(intents.isEmpty()) + } }