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:
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())
+ }
}