app

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

commit e47adf2001fce5efd8e90e9ead33b80cf04bd700
parent 6f44b3d3419fb7218dfa8107c68e7c0ee49e1b94
Author: triesap <tyson@radroots.org>
Date:   Thu, 13 Aug 2026 17:46:04 +0000

ui: align shell overlays and menus

- add owned Studio-derived dialog banner and menu surfaces
- restyle reference status and confirmation flows
- retain modal focus cycles busy-state denial and typed intents
- keep degraded and critical status visibly distinct

Diffstat:
Aapp/design_system/src/commonMain/kotlin/org/harvestcircle/designsystem/shell/HarvestCircleShellOverlays.kt | 235+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mapp/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/FoundationOverlayHost.kt | 82++++++++++++++++++++++++++++++++++++++++----------------------------------------
Mapp/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/GlobalTopBar.kt | 9+++------
3 files changed, 279 insertions(+), 47 deletions(-)

diff --git a/app/design_system/src/commonMain/kotlin/org/harvestcircle/designsystem/shell/HarvestCircleShellOverlays.kt b/app/design_system/src/commonMain/kotlin/org/harvestcircle/designsystem/shell/HarvestCircleShellOverlays.kt @@ -0,0 +1,235 @@ +package org.harvestcircle.designsystem.shell + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.focusable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +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 +import androidx.compose.foundation.layout.widthIn +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.KeyEventType +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.onPreviewKeyEvent +import androidx.compose.ui.input.key.type +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.paneTitle +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.IntRect +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.Popup +import androidx.compose.ui.window.PopupPositionProvider +import androidx.compose.ui.window.PopupProperties +import org.harvestcircle.designsystem.component.menu.HarvestCircleMenuOption + +public enum class HarvestCircleShellBannerTone { Information, Caution, Critical } + +@Composable +public fun HarvestCircleShellBanner( + title: String, + message: String, + modifier: Modifier = Modifier, + tone: HarvestCircleShellBannerTone = HarvestCircleShellBannerTone.Information, +) { + val colors = HarvestCircleShellPalette + val border = if (tone == HarvestCircleShellBannerTone.Critical) colors.destructive else colors.border + val shape = + androidx.compose.foundation.shape + .RoundedCornerShape(HarvestCircleShellMetrics.contentPanelRadius) + Row( + modifier = + modifier + .fillMaxWidth() + .clip(shape) + .background(if (tone == HarvestCircleShellBannerTone.Information) colors.accentSubtle else colors.raised) + .border(BorderStroke(1.dp, border), shape) + .padding(horizontal = 16.dp, vertical = 12.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { + HarvestCircleShellText(title, role = HarvestCircleShellTextRole.BodyStrong) + HarvestCircleShellText(message, color = colors.contentSecondary) + } + } +} + +@Composable +public fun HarvestCircleShellDialogFrame( + onDismissRequest: () -> Unit, + title: String, + modifier: Modifier = Modifier, + content: @Composable ColumnScope.() -> Unit, +) { + val colors = HarvestCircleShellPalette + val shape = + androidx.compose.foundation.shape + .RoundedCornerShape(14.dp) + Dialog(onDismissRequest = onDismissRequest) { + Column( + modifier = + modifier + .widthIn(min = 360.dp, max = 480.dp) + .clip(shape) + .background(colors.raised) + .border(BorderStroke(1.dp, colors.border), shape) + .semantics { paneTitle = title } + .padding(20.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + content = content, + ) + } +} + +@Composable +public fun <T> HarvestCircleShellMenuButton( + selectedValue: T, + options: List<HarvestCircleMenuOption<T>>, + onValueChange: (T) -> Unit, + label: String, + modifier: Modifier = Modifier, +) { + require(options.isNotEmpty()) + var expanded by remember { mutableStateOf(false) } + var anchorWidthPx by remember { mutableIntStateOf(0) } + var activeIndex by remember(options, selectedValue) { + mutableIntStateOf(options.indexOfFirst { it.value == selectedValue }.coerceAtLeast(0)) + } + val density = LocalDensity.current + val gapPx = with(density) { 4.dp.roundToPx() } + val anchorWidth = with(density) { anchorWidthPx.toDp() } + val requester = remember { FocusRequester() } + val positionProvider = + remember(gapPx) { + object : PopupPositionProvider { + override fun calculatePosition( + anchorBounds: IntRect, + windowSize: IntSize, + layoutDirection: LayoutDirection, + popupContentSize: IntSize, + ): IntOffset { + val preferredX = + if (layoutDirection == LayoutDirection.Ltr) anchorBounds.left else anchorBounds.right - popupContentSize.width + val x = preferredX.coerceIn(0, (windowSize.width - popupContentSize.width).coerceAtLeast(0)) + val below = anchorBounds.bottom + gapPx + val y = + if (below + popupContentSize.height <= windowSize.height) { + below + } else { + (anchorBounds.top - popupContentSize.height - gapPx).coerceAtLeast(0) + } + return IntOffset(x, y) + } + } + } + Box(modifier.onGloballyPositioned { anchorWidthPx = it.size.width }) { + HarvestCircleShellButton(label, { expanded = !expanded }) + if (expanded) { + Popup( + popupPositionProvider = positionProvider, + onDismissRequest = { expanded = false }, + properties = PopupProperties(focusable = true), + ) { + val colors = HarvestCircleShellPalette + val shape = + androidx.compose.foundation.shape + .RoundedCornerShape(HarvestCircleShellMetrics.controlRadius) + LaunchedEffect(Unit) { requester.requestFocus() } + Column( + Modifier + .widthIn(min = anchorWidth.coerceAtLeast(176.dp), max = 320.dp) + .clip(shape) + .background(colors.raised) + .border(BorderStroke(1.dp, colors.border), shape) + .padding(6.dp) + .focusRequester(requester) + .focusable() + .onPreviewKeyEvent { event -> + if (event.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false + when (event.key) { + Key.DirectionDown -> { + activeIndex = nextEnabled(options, activeIndex, 1) + true + } + Key.DirectionUp -> { + activeIndex = nextEnabled(options, activeIndex, -1) + true + } + Key.Enter, Key.Spacebar -> { + options.getOrNull(activeIndex)?.takeIf { it.enabled }?.let { + onValueChange(it.value) + expanded = false + } + true + } + Key.Escape -> { + expanded = false + true + } + else -> false + } + }, + ) { + options.forEachIndexed { index, option -> + val active = index == activeIndex + Row( + Modifier + .fillMaxWidth() + .clip( + androidx.compose.foundation.shape + .RoundedCornerShape(8.dp), + ).background(if (active) colors.navigationSelected else colors.raised) + .clickable(enabled = option.enabled, role = Role.Button) { + onValueChange(option.value) + expanded = false + }.padding(horizontal = 10.dp, vertical = 8.dp), + ) { + HarvestCircleShellText( + option.label, + color = if (option.enabled) colors.contentPrimary else colors.contentDisabled, + maxLines = 1, + ) + } + } + } + } + } + } +} + +private fun <T> nextEnabled( + options: List<HarvestCircleMenuOption<T>>, + current: Int, + direction: Int, +): Int { + var candidate = current + repeat(options.size) { + candidate = (candidate + direction + options.size).mod(options.size) + if (options[candidate].enabled) return candidate + } + return current +} 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,6 +5,7 @@ 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,21 +23,20 @@ import androidx.compose.ui.platform.testTag import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.heading import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.dp 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.HarvestCircleButtonVariant -import org.harvestcircle.designsystem.component.HarvestCircleTextRole -import org.harvestcircle.designsystem.component.action.HarvestCircleLabeledButton -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.component.input.HarvestCircleTextField -import org.harvestcircle.designsystem.primitive.HarvestCircleText -import org.harvestcircle.designsystem.theme.HarvestCircleTheme +import org.harvestcircle.designsystem.shell.HarvestCircleShellBanner +import org.harvestcircle.designsystem.shell.HarvestCircleShellBannerTone +import org.harvestcircle.designsystem.shell.HarvestCircleShellButton +import org.harvestcircle.designsystem.shell.HarvestCircleShellDialogFrame +import org.harvestcircle.designsystem.shell.HarvestCircleShellText +import org.harvestcircle.designsystem.shell.HarvestCircleShellTextField +import org.harvestcircle.designsystem.shell.HarvestCircleShellTextRole @Composable fun FoundationOverlayHost( @@ -45,20 +45,21 @@ fun FoundationOverlayHost( onIntent: (OverlayIntent) -> Unit, ) { status.banner?.let { banner -> - HarvestCircleBanner( + HarvestCircleShellBanner( message = banner.message, modifier = Modifier + .padding(16.dp) .semantics { contentDescription = "Status: ${banner.title}. ${banner.message}" } .testTag("global-status-banner"), - tone = banner.severity.toBannerTone(), + tone = banner.severity.toShellBannerTone(), title = banner.title, ) } state.current?.let { overlay -> val overlayBusy = (overlay as? FoundationOverlay.ConfirmAction)?.busy == true val rootRequester = remember { FocusRequester() } - HarvestCircleDialogFrame( + HarvestCircleShellDialogFrame( onDismissRequest = { if (!overlayBusy) { if (overlay is FoundationOverlay.ConfirmAction) { @@ -99,12 +100,11 @@ private fun ConfirmOverlay( ) { val confirmRequester = remember { FocusRequester() } val cancelRequester = remember { FocusRequester() } - Column(verticalArrangement = Arrangement.spacedBy(HarvestCircleTheme.shell.layout.contentGap)) { - HarvestCircleText(overlay.title, Modifier.semantics { heading() }, HarvestCircleTextRole.SectionTitle) - HarvestCircleText(overlay.explanation) - Row(horizontalArrangement = Arrangement.spacedBy(HarvestCircleTheme.shell.layout.inlineGap)) { - HarvestCircleLabeledButton( - overlay.actionLabel, + Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { + HarvestCircleShellText(overlay.title, Modifier.semantics { heading() }, HarvestCircleShellTextRole.SectionTitle) + HarvestCircleShellText(overlay.explanation) + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + HarvestCircleShellButton( overlay.actionLabel, { onIntent(OverlayIntent.Confirm(overlay.action)) }, Modifier @@ -115,10 +115,9 @@ private fun ConfirmOverlay( }.modalFocusCycle(cancelRequester, cancelRequester) .testTag("overlay-confirm"), enabled = !busy, - variant = HarvestCircleButtonVariant.Destructive, + destructive = true, ) - HarvestCircleLabeledButton( - "Cancel", + HarvestCircleShellButton( "Cancel", { onIntent(OverlayIntent.DismissConfirmation(overlay.action)) }, Modifier @@ -128,7 +127,7 @@ private fun ConfirmOverlay( previous = confirmRequester }.modalFocusCycle(confirmRequester, confirmRequester) .testTag("overlay-cancel"), - !busy, + enabled = !busy, ) } } @@ -144,11 +143,10 @@ private fun StatusOverlay( onIntent: (OverlayIntent) -> Unit, ) { val requester = remember { FocusRequester() } - Column(verticalArrangement = Arrangement.spacedBy(HarvestCircleTheme.shell.layout.contentGap)) { - HarvestCircleText(title, Modifier.semantics { heading() }, HarvestCircleTextRole.SectionTitle) - HarvestCircleText(status, Modifier.testTag("overlay-status")) - HarvestCircleLabeledButton( - "Close", + 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 @@ -171,9 +169,13 @@ private fun ReferenceOverlay( val inputRequester = remember { FocusRequester() } val submitRequester = remember { FocusRequester() } val cancelRequester = remember { FocusRequester() } - Column(verticalArrangement = Arrangement.spacedBy(HarvestCircleTheme.shell.layout.contentGap)) { - HarvestCircleText("Open a Nostr reference", Modifier.semantics { heading() }, HarvestCircleTextRole.SectionTitle) - HarvestCircleTextField( + Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { + HarvestCircleShellText( + "Open a Nostr reference", + Modifier.semantics { heading() }, + HarvestCircleShellTextRole.SectionTitle, + ) + HarvestCircleShellTextField( value = overlay.input, onValueChange = { onIntent(OverlayIntent.EditReference(it)) }, label = "Nostr link, note1, nevent1, or address", @@ -187,10 +189,9 @@ private fun ReferenceOverlay( }.modalFocusCycle(submitRequester, cancelRequester) .testTag("nostr-reference-input"), ) - overlay.result?.let { HarvestCircleText(it.message, Modifier.testTag("nostr-reference-result")) } - Row(horizontalArrangement = Arrangement.spacedBy(HarvestCircleTheme.shell.layout.inlineGap)) { - HarvestCircleLabeledButton( - "Open a Nostr reference", + overlay.result?.let { HarvestCircleShellText(it.message, Modifier.testTag("nostr-reference-result")) } + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + HarvestCircleShellButton( "Open a Nostr reference", { onIntent(OverlayIntent.SubmitReference) }, Modifier @@ -200,10 +201,9 @@ private fun ReferenceOverlay( previous = inputRequester }.modalFocusCycle(cancelRequester, inputRequester) .testTag("nostr-reference-submit"), - variant = HarvestCircleButtonVariant.Primary, + primary = true, ) - HarvestCircleLabeledButton( - "Cancel", + HarvestCircleShellButton( "Cancel", { onIntent(OverlayIntent.Close) }, Modifier @@ -239,9 +239,9 @@ private fun FoundationOverlay.title(): String = is FoundationOverlay.OpenNostrReference -> "Open a Nostr reference" } -private fun BannerSeverity.toBannerTone(): HarvestCircleBannerTone = +private fun BannerSeverity.toShellBannerTone(): HarvestCircleShellBannerTone = when (this) { - BannerSeverity.Information -> HarvestCircleBannerTone.Info - BannerSeverity.Caution -> HarvestCircleBannerTone.Warning - BannerSeverity.Critical -> HarvestCircleBannerTone.Error + BannerSeverity.Information -> HarvestCircleShellBannerTone.Information + BannerSeverity.Caution -> HarvestCircleShellBannerTone.Caution + BannerSeverity.Critical -> HarvestCircleShellBannerTone.Critical } diff --git a/app/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/GlobalTopBar.kt b/app/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/GlobalTopBar.kt @@ -14,13 +14,12 @@ import androidx.compose.ui.platform.testTag import org.harvestcircle.application.ShellFocusTarget import org.harvestcircle.application.SignerStatusLabel import org.harvestcircle.application.SyncStatusLabel -import org.harvestcircle.designsystem.component.HarvestCircleControlSize import org.harvestcircle.designsystem.component.menu.HarvestCircleMenuOption -import org.harvestcircle.designsystem.component.menu.HarvestCirclePopupButton import org.harvestcircle.designsystem.icon.HarvestCircleIcons import org.harvestcircle.designsystem.primitive.HarvestCircleIcon import org.harvestcircle.designsystem.shell.HarvestCircleShellButton import org.harvestcircle.designsystem.shell.HarvestCircleShellIconButton +import org.harvestcircle.designsystem.shell.HarvestCircleShellMenuButton import org.harvestcircle.designsystem.shell.HarvestCircleShellMetrics import org.harvestcircle.designsystem.shell.HarvestCircleShellPalette import org.harvestcircle.designsystem.shell.HarvestCircleShellTab @@ -144,14 +143,12 @@ fun GlobalTopBar( { onIntent(GlobalTopBarIntent.ShowSignerStatus) }, Modifier.shellFocusTarget(ShellFocusTarget.TopBarSigner).testTag("top-bar-signer"), ) - HarvestCirclePopupButton( + HarvestCircleShellMenuButton( selectedValue = ApplicationMenuAction.Settings, options = ApplicationMenuAction.entries.map { HarvestCircleMenuOption(it, it.label) }, onValueChange = { onIntent(GlobalTopBarIntent.SelectApplicationMenu(it)) }, modifier = Modifier.testTag("top-bar-menu"), - buttonLabel = "Menu", - showSelection = false, - size = HarvestCircleControlSize.Small, + label = "Menu", ) } }