app

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

commit b3d5ebfaf2ebb5813edfd4b2dcc3580e5d5910ae
parent 9dd4f257d8231790ca6230cb3d190c14dbcc96d6
Author: triesap <tyson@radroots.org>
Date:   Thu, 13 Aug 2026 16:04:46 +0000

ui: migrate HarvestCircle overlays and status banners

- add an owned dialog frame for product-specific interaction flows
- render global status through semantic design-system banner tones
- preserve modal focus cycles, busy-state gating, and typed actions
- verify overlay behavior and the complete standalone capsule check

Diffstat:
Mapp/design_system/src/commonMain/kotlin/org/harvestcircle/designsystem/component/container/HarvestCircleDialog.kt | 178+++++++++++++++++++++++++++++++++++++++++++------------------------------------
Mapp/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/FoundationOverlayHost.kt | 75++++++++++++++++++++++++++++++++++++++++-----------------------------------
2 files changed, 138 insertions(+), 115 deletions(-)

diff --git a/app/design_system/src/commonMain/kotlin/org/harvestcircle/designsystem/component/container/HarvestCircleDialog.kt b/app/design_system/src/commonMain/kotlin/org/harvestcircle/designsystem/component/container/HarvestCircleDialog.kt @@ -3,6 +3,7 @@ package org.harvestcircle.designsystem.component.container import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding @@ -31,6 +32,34 @@ import org.harvestcircle.designsystem.primitive.HarvestCircleSurfaceRole import org.harvestcircle.designsystem.primitive.HarvestCircleText import org.harvestcircle.designsystem.theme.HarvestCircleTheme +/** Generic dialog frame for product-owned forms and interaction flows. */ +@Composable +public fun HarvestCircleDialogFrame( + onDismissRequest: () -> Unit, + title: String, + modifier: Modifier = Modifier, + content: @Composable ColumnScope.() -> Unit, +) { + Dialog(onDismissRequest = onDismissRequest) { + HarvestCircleSurface( + modifier = + modifier + .widthIn(min = 360.dp, max = 480.dp) + .semantics { paneTitle = title }, + role = HarvestCircleSurfaceRole.Overlay, + shape = HarvestCircleTheme.foundation.shapes.dialog, + border = BorderStroke(1.dp, HarvestCircleTheme.foundation.colors.border.subtle), + shadowElevation = HarvestCircleTheme.component.elevations.dialog, + ) { + Column( + modifier = Modifier.padding(HarvestCircleTheme.foundation.spacing.xl), + verticalArrangement = Arrangement.spacedBy(HarvestCircleTheme.foundation.spacing.lg), + content = content, + ) + } + } +} + /** Canonical macOS alert sheet/panel treatment implemented with Compose Dialog primitives. */ @Composable public fun HarvestCircleDialog( @@ -55,94 +84,83 @@ public fun HarvestCircleDialog( val defaultFocusRequester = remember { FocusRequester() } val focusDismiss = destructive && dismissLabel != null - Dialog(onDismissRequest = onDismissRequest) { - val focusManager = LocalFocusManager.current - LaunchedEffect(defaultFocusRequester, focusDismiss) { - defaultFocusRequester.requestFocus() + val focusManager = LocalFocusManager.current + LaunchedEffect(defaultFocusRequester, focusDismiss) { + defaultFocusRequester.requestFocus() + } + + HarvestCircleDialogFrame( + onDismissRequest = onDismissRequest, + title = title, + modifier = + modifier.harvestCircleClearFocusOnBackgroundPress( + focusManager = focusManager, + enabled = + focusDismissBehavior == + HarvestCircleFocusDismissBehavior.ClearOnBackgroundPress, + force = forceFocusDismissal, + ), + ) { + Column( + verticalArrangement = Arrangement.spacedBy(HarvestCircleTheme.foundation.spacing.sm), + ) { + HarvestCircleText( + text = title, + role = HarvestCircleTextRole.SectionTitle, + ) + HarvestCircleText( + text = message, + role = HarvestCircleTextRole.Body, + tone = HarvestCircleContentTone.Secondary, + ) } - HarvestCircleSurface( - modifier = - modifier - .widthIn(min = 360.dp, max = 480.dp) - .harvestCircleClearFocusOnBackgroundPress( - focusManager = focusManager, - enabled = - focusDismissBehavior == - HarvestCircleFocusDismissBehavior.ClearOnBackgroundPress, - force = forceFocusDismissal, - ).semantics { paneTitle = title }, - role = HarvestCircleSurfaceRole.Overlay, - shape = HarvestCircleTheme.foundation.shapes.dialog, - border = BorderStroke(1.dp, HarvestCircleTheme.foundation.colors.border.subtle), - shadowElevation = HarvestCircleTheme.component.elevations.dialog, + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(HarvestCircleTheme.foundation.spacing.md, Alignment.End), + verticalAlignment = Alignment.CenterVertically, ) { - Column( - modifier = Modifier.padding(HarvestCircleTheme.foundation.spacing.xl), - verticalArrangement = Arrangement.spacedBy(HarvestCircleTheme.foundation.spacing.lg), - ) { - Column( - verticalArrangement = Arrangement.spacedBy(HarvestCircleTheme.foundation.spacing.sm), + if (dismissLabel != null && onDismiss != null) { + HarvestCircleButton( + onClick = onDismiss, + modifier = + if (focusDismiss) { + Modifier.focusRequester(defaultFocusRequester) + } else { + Modifier + }, + variant = HarvestCircleButtonVariant.Secondary, + focusRing = focusRing, ) { HarvestCircleText( - text = title, - role = HarvestCircleTextRole.SectionTitle, - ) - HarvestCircleText( - text = message, - role = HarvestCircleTextRole.Body, - tone = HarvestCircleContentTone.Secondary, + text = dismissLabel, + role = HarvestCircleTextRole.Label, + tone = HarvestCircleContentTone.Inherit, ) } + } - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(HarvestCircleTheme.foundation.spacing.md, Alignment.End), - verticalAlignment = Alignment.CenterVertically, - ) { - if (dismissLabel != null && onDismiss != null) { - HarvestCircleButton( - onClick = onDismiss, - modifier = - if (focusDismiss) { - Modifier.focusRequester(defaultFocusRequester) - } else { - Modifier - }, - variant = HarvestCircleButtonVariant.Secondary, - focusRing = focusRing, - ) { - HarvestCircleText( - text = dismissLabel, - role = HarvestCircleTextRole.Label, - tone = HarvestCircleContentTone.Inherit, - ) - } - } - - HarvestCircleButton( - onClick = onConfirm, - modifier = - if (!focusDismiss) { - Modifier.focusRequester(defaultFocusRequester) - } else { - Modifier - }, - variant = - if (destructive) { - HarvestCircleButtonVariant.Destructive - } else { - HarvestCircleButtonVariant.Primary - }, - focusRing = focusRing, - ) { - HarvestCircleText( - text = confirmLabel, - role = HarvestCircleTextRole.Label, - tone = HarvestCircleContentTone.Inherit, - ) - } - } + HarvestCircleButton( + onClick = onConfirm, + modifier = + if (!focusDismiss) { + Modifier.focusRequester(defaultFocusRequester) + } else { + Modifier + }, + variant = + if (destructive) { + HarvestCircleButtonVariant.Destructive + } else { + HarvestCircleButtonVariant.Primary + }, + focusRing = focusRing, + ) { + HarvestCircleText( + text = confirmLabel, + role = HarvestCircleTextRole.Label, + tone = HarvestCircleContentTone.Inherit, + ) } } } 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 @@ -5,7 +5,6 @@ import androidx.compose.foundation.focusable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.remember @@ -22,15 +21,17 @@ import androidx.compose.ui.input.key.type import androidx.compose.ui.platform.testTag import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.heading -import androidx.compose.ui.semantics.paneTitle import androidx.compose.ui.semantics.semantics -import androidx.compose.ui.unit.dp -import androidx.compose.ui.window.Dialog +import org.harvestcircle.application.BannerSeverity import org.harvestcircle.application.FoundationOverlay import org.harvestcircle.application.OverlayIntent import org.harvestcircle.application.OverlayState import org.harvestcircle.application.ShellStatusModel import org.harvestcircle.application.StatusOverlayKey +import org.harvestcircle.designsystem.component.container.HarvestCircleDialogFrame +import org.harvestcircle.designsystem.component.feedback.HarvestCircleBanner +import org.harvestcircle.designsystem.component.feedback.HarvestCircleBannerTone +import org.harvestcircle.designsystem.theme.HarvestCircleTheme @Composable fun FoundationOverlayHost( @@ -39,19 +40,20 @@ fun FoundationOverlayHost( onIntent: (OverlayIntent) -> Unit, ) { status.banner?.let { banner -> - ShellCard( - Modifier.semantics { contentDescription = "Status: ${banner.title}. ${banner.message}" }.testTag("global-status-banner"), - ) { - Column { - ShellText(banner.title, textRole = ShellTextRole.CardTitle) - ShellText(banner.message) - } - } + HarvestCircleBanner( + message = banner.message, + modifier = + Modifier + .semantics { contentDescription = "Status: ${banner.title}. ${banner.message}" } + .testTag("global-status-banner"), + tone = banner.severity.toBannerTone(), + title = banner.title, + ) } state.current?.let { overlay -> val overlayBusy = (overlay as? FoundationOverlay.ConfirmAction)?.busy == true val rootRequester = remember { FocusRequester() } - Dialog( + HarvestCircleDialogFrame( onDismissRequest = { if (!overlayBusy) { if (overlay is FoundationOverlay.ConfirmAction) { @@ -61,27 +63,23 @@ fun FoundationOverlayHost( } } }, - ) { - ShellSurface( + title = overlay.title(), + modifier = Modifier .focusGroup() .focusRequester(rootRequester) .focusable(overlayBusy) - .semantics { - contentDescription = "Dialog: ${overlay.title()}" - paneTitle = overlay.title() - }.testTag("foundation-overlay") - .padding(24.dp), - ) { - when (overlay) { - 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) - } - is FoundationOverlay.OpenNostrReference -> ReferenceOverlay(overlay, onIntent) - } + .semantics { contentDescription = "Dialog: ${overlay.title()}" } + .testTag("foundation-overlay"), + ) { + when (overlay) { + 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) + } + is FoundationOverlay.OpenNostrReference -> ReferenceOverlay(overlay, onIntent) } } } @@ -96,10 +94,10 @@ private fun ConfirmOverlay( ) { val confirmRequester = remember { FocusRequester() } val cancelRequester = remember { FocusRequester() } - Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { + Column(verticalArrangement = Arrangement.spacedBy(HarvestCircleTheme.shell.layout.contentGap)) { ShellText(overlay.title, Modifier.semantics { heading() }, ShellTextRole.SectionTitle) ShellText(overlay.explanation) - Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { + Row(horizontalArrangement = Arrangement.spacedBy(HarvestCircleTheme.shell.layout.inlineGap)) { ShellButton( overlay.actionLabel, overlay.actionLabel, @@ -141,7 +139,7 @@ private fun StatusOverlay( onIntent: (OverlayIntent) -> Unit, ) { val requester = remember { FocusRequester() } - Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { + Column(verticalArrangement = Arrangement.spacedBy(HarvestCircleTheme.shell.layout.contentGap)) { ShellText(title, Modifier.semantics { heading() }, ShellTextRole.SectionTitle) ShellText(status, Modifier.testTag("overlay-status")) ShellButton( @@ -168,7 +166,7 @@ private fun ReferenceOverlay( val inputRequester = remember { FocusRequester() } val submitRequester = remember { FocusRequester() } val cancelRequester = remember { FocusRequester() } - Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { + Column(verticalArrangement = Arrangement.spacedBy(HarvestCircleTheme.shell.layout.contentGap)) { ShellText("Open a Nostr reference", Modifier.semantics { heading() }, ShellTextRole.SectionTitle) ShellTextField( value = overlay.input, @@ -185,7 +183,7 @@ private fun ReferenceOverlay( .testTag("nostr-reference-input"), ) overlay.result?.let { ShellText(it.message, Modifier.testTag("nostr-reference-result")) } - Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { + Row(horizontalArrangement = Arrangement.spacedBy(HarvestCircleTheme.shell.layout.inlineGap)) { ShellButton( "Open a Nostr reference", "Open a Nostr reference", @@ -235,3 +233,10 @@ private fun FoundationOverlay.title(): String = is FoundationOverlay.Status -> if (key == StatusOverlayKey.Signer) "Signer status" else "Sync status" is FoundationOverlay.OpenNostrReference -> "Open a Nostr reference" } + +private fun BannerSeverity.toBannerTone(): HarvestCircleBannerTone = + when (this) { + BannerSeverity.Information -> HarvestCircleBannerTone.Info + BannerSeverity.Caution -> HarvestCircleBannerTone.Warning + BannerSeverity.Critical -> HarvestCircleBannerTone.Error + }