app

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

commit bf43951acb2c9490dc29f3933f3c58ed46bb393c
parent d34469d6d67efe0d69f33f8ac5337c2f228087aa
Author: triesap <tyson@radroots.org>
Date:   Tue, 11 Aug 2026 16:34:21 +0000

ui: implement the foundation overlay host

- keep one typed top overlay independent of route history
- implement confirm signer sync and global-status surfaces
- validate bounded Nostr reference syntax without resolution
- register deferred overlays without exposing constructors

Diffstat:
Aapp/shared/src/commonMain/kotlin/org/harvestcircle/application/ShellOverlays.kt | 97+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aapp/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/FoundationOverlayHost.kt | 91+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aapp/shared/src/commonTest/kotlin/org/harvestcircle/application/ShellOverlaysTest.kt | 41+++++++++++++++++++++++++++++++++++++++++
Aapp/shared/src/desktopTest/kotlin/org/harvestcircle/ui/shell/FoundationOverlayHostTest.kt | 42++++++++++++++++++++++++++++++++++++++++++
4 files changed, 271 insertions(+), 0 deletions(-)

diff --git a/app/shared/src/commonMain/kotlin/org/harvestcircle/application/ShellOverlays.kt b/app/shared/src/commonMain/kotlin/org/harvestcircle/application/ShellOverlays.kt @@ -0,0 +1,97 @@ +package org.harvestcircle.application + +import org.harvestcircle.ui.shell.SignerStatusLabel +import org.harvestcircle.ui.shell.SyncStatusLabel + +sealed interface FoundationOverlay { + data class ConfirmAction( + val title: String, + val explanation: String, + val actionLabel: String, + ) : FoundationOverlay + + data class SignerStatus( + val status: SignerStatusLabel, + ) : FoundationOverlay + + data class SyncStatus( + val status: SyncStatusLabel, + ) : FoundationOverlay + + data class OpenNostrReference( + val input: String = "", + val result: ReferenceResult? = null, + ) : FoundationOverlay +} + +enum class BannerSeverity { Information, Caution, Critical } + +data class GlobalStatusBanner( + val message: String, + val severity: BannerSeverity, +) + +data class OverlayState( + val current: FoundationOverlay? = null, + val banner: GlobalStatusBanner? = null, +) + +sealed interface OverlayIntent { + data class Open( + val overlay: FoundationOverlay, + ) : OverlayIntent + + data class EditReference( + val value: String, + ) : OverlayIntent + + data object SubmitReference : OverlayIntent + + data object Confirm : OverlayIntent + + data object Close : OverlayIntent + + data object Escape : OverlayIntent +} + +object OverlayReducer { + fun reduce( + state: OverlayState, + intent: OverlayIntent, + ): OverlayState = + when (intent) { + is OverlayIntent.Open -> state.copy(current = intent.overlay) + is OverlayIntent.EditReference -> + state.copy(current = (state.current as? FoundationOverlay.OpenNostrReference)?.copy(input = intent.value)) + OverlayIntent.SubmitReference -> submitReference(state) + OverlayIntent.Confirm, OverlayIntent.Close, OverlayIntent.Escape -> state.copy(current = null) + } + + private fun submitReference(state: OverlayState): OverlayState { + val overlay = state.current as? FoundationOverlay.OpenNostrReference ?: return state + return state.copy(current = overlay.copy(result = validateNostrReference(overlay.input))) + } +} + +enum class ReferenceResult( + val message: String, +) { + Invalid("This reference is not valid."), + Unsupported("This Nostr reference is not supported by this build."), +} + +fun validateNostrReference(raw: String): ReferenceResult { + if (raw.isBlank() || raw.length > MAX_REFERENCE_CHARS || raw.any(Char::isISOControl)) return ReferenceResult.Invalid + val value = raw.trim() + val payload = value.removePrefix("nostr:") + val accepted = + payload.matches(Regex("[0-9a-fA-F]{64}")) || + payload.matches(Regex("(?:npub|nprofile|note|nevent|naddr)1[023456789acdefghjklmnpqrstuvwxyz]{6,}")) + return if (accepted && (value == payload || value.startsWith("nostr:"))) { + ReferenceResult.Unsupported + } else { + ReferenceResult.Invalid + } +} + +private const val MAX_REFERENCE_CHARS = 2048 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 @@ -0,0 +1,91 @@ +package org.harvestcircle.ui.shell + +import androidx.compose.foundation.focusGroup +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.text.BasicText +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +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 org.harvestcircle.application.FoundationOverlay +import org.harvestcircle.application.OverlayIntent +import org.harvestcircle.application.OverlayState + +@Composable +fun FoundationOverlayHost( + state: OverlayState, + onIntent: (OverlayIntent) -> Unit, +) { + state.banner?.let { banner -> + BasicText( + banner.message, + Modifier.semantics { contentDescription = "Status: ${banner.message}" }.testTag("global-status-banner"), + ) + } + state.current?.let { overlay -> + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Column( + Modifier + .focusGroup() + .semantics { contentDescription = "Dialog" } + .testTag("foundation-overlay"), + ) { + when (overlay) { + is FoundationOverlay.ConfirmAction -> ConfirmOverlay(overlay, 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) + } + } + } + } +} + +@Composable +private fun ConfirmOverlay( + overlay: FoundationOverlay.ConfirmAction, + onIntent: (OverlayIntent) -> Unit, +) { + BasicText(overlay.title) + BasicText(overlay.explanation) + ShellAction(overlay.actionLabel, overlay.actionLabel, "overlay-confirm") { onIntent(OverlayIntent.Confirm) } + ShellAction("Cancel", "Cancel", "overlay-cancel") { onIntent(OverlayIntent.Close) } +} + +@Composable +private fun StatusOverlay( + title: String, + status: String, + onIntent: (OverlayIntent) -> Unit, +) { + BasicText(title) + BasicText(status, Modifier.testTag("overlay-status")) + ShellAction("Close", "Close", "overlay-close") { onIntent(OverlayIntent.Close) } +} + +@Composable +private fun ReferenceOverlay( + overlay: FoundationOverlay.OpenNostrReference, + onIntent: (OverlayIntent) -> Unit, +) { + val requester = androidx.compose.runtime.remember { FocusRequester() } + BasicText("Open a Nostr reference") + BasicTextField( + value = overlay.input, + onValueChange = { onIntent(OverlayIntent.EditReference(it)) }, + modifier = Modifier.focusRequester(requester).testTag("nostr-reference-input"), + ) + androidx.compose.runtime.LaunchedEffect(Unit) { requester.requestFocus() } + overlay.result?.let { BasicText(it.message, Modifier.testTag("nostr-reference-result")) } + ShellAction("Open reference", "Open Nostr reference", "nostr-reference-submit") { + onIntent(OverlayIntent.SubmitReference) + } + ShellAction("Cancel", "Cancel", "overlay-cancel") { onIntent(OverlayIntent.Close) } +} diff --git a/app/shared/src/commonTest/kotlin/org/harvestcircle/application/ShellOverlaysTest.kt b/app/shared/src/commonTest/kotlin/org/harvestcircle/application/ShellOverlaysTest.kt @@ -0,0 +1,41 @@ +package org.harvestcircle.application + +import org.harvestcircle.product.FeatureAvailability +import org.harvestcircle.product.HarvestCircleSurfaceRegistry +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class ShellOverlaysTest { + @Test + fun acceptedReferencesRemainSyntaxOnlyAndMalformedInputFails() { + assertEquals(ReferenceResult.Unsupported, validateNostrReference("ab".repeat(32))) + assertEquals(ReferenceResult.Unsupported, validateNostrReference("nostr:note1qqqqqq")) + assertEquals(ReferenceResult.Invalid, validateNostrReference("nostr:bad reference")) + assertEquals(ReferenceResult.Invalid, validateNostrReference("note1abc\u0000")) + } + + @Test + fun oneTopOverlayReplacesPriorAndEscapeDoesNotTouchNavigation() { + val first = + OverlayReducer.reduce( + OverlayState(), + OverlayIntent.Open(FoundationOverlay.SignerStatus(org.harvestcircle.ui.shell.SignerStatusLabel.Available)), + ) + val second = + OverlayReducer.reduce( + first, + OverlayIntent.Open(FoundationOverlay.SyncStatus(org.harvestcircle.ui.shell.SyncStatusLabel.Degraded)), + ) + assertTrue(second.current is FoundationOverlay.SyncStatus) + assertNull(OverlayReducer.reduce(second, OverlayIntent.Escape).current) + } + + @Test + fun deferredOverlaysHaveNoFoundationStateConstructor() { + val deferred = HarvestCircleSurfaceRegistry.overlays.filter { it.availability.name.startsWith("Deferred") } + assertEquals(5, deferred.size) + assertTrue(FeatureAvailability.Foundation in HarvestCircleSurfaceRegistry.overlays.map { it.availability }) + } +} 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 @@ -0,0 +1,42 @@ +package org.harvestcircle.ui.shell + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +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.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.FoundationOverlay +import org.harvestcircle.application.GlobalStatusBanner +import org.harvestcircle.application.OverlayIntent +import org.harvestcircle.application.OverlayReducer +import org.harvestcircle.application.OverlayState +import kotlin.test.Test + +@OptIn(ExperimentalTestApi::class) +class FoundationOverlayHostTest { + @Test + fun referenceDialogOwnsFocusAndReportsUnsupportedSyntax() = + runComposeUiTest { + var state by mutableStateOf( + OverlayState( + current = FoundationOverlay.OpenNostrReference(), + banner = GlobalStatusBanner("Limited connection", BannerSeverity.Caution), + ), + ) + setContent { FoundationOverlayHost(state) { state = OverlayReducer.reduce(state, it) } } + onAllNodesWithTag("foundation-overlay").assertCountEquals(1) + onAllNodesWithTag("global-status-banner").assertCountEquals(1) + onNodeWithTag("nostr-reference-input").assertIsFocused().performTextInput("note1qqqqqq") + onNodeWithTag("nostr-reference-submit").performClick() + onAllNodesWithTag("nostr-reference-result").assertCountEquals(1) + state = OverlayReducer.reduce(state, OverlayIntent.Escape) + onAllNodesWithTag("foundation-overlay").assertCountEquals(0) + } +}