app

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

commit 9f11bd08d04d0fac4a87277753d800615f4eaf92
parent 3359c65307c7536f43ef673bd0ec5958bc78f01a
Author: triesap <tyson@radroots.org>
Date:   Wed, 12 Aug 2026 19:52:07 +0000

ui: localize overlay busy state

- derive confirmation blocking only from the active confirmation phase
- keep reference and status overlays interactive during unrelated identity work
- restore rejected confirmations to a ready state immediately
- cover reducer, presenter, and Compose interaction behavior

Diffstat:
Mapp/shared/src/commonMain/kotlin/org/harvestcircle/application/ShellOverlays.kt | 1-
Mapp/shared/src/commonMain/kotlin/org/harvestcircle/application/ShellReducer.kt | 1-
Mapp/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/FoundationOverlayHost.kt | 11+++--------
Mapp/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/HarvestCircleShell.kt | 2+-
Mapp/shared/src/commonTest/kotlin/org/harvestcircle/application/HarvestCircleShellPresenterTest.kt | 35+++++++++++++++++++++++++++++++++++
Mapp/shared/src/commonTest/kotlin/org/harvestcircle/application/ShellOverlaysTest.kt | 44++++++++++++++++++++++++++++++++++++++++++++
Mapp/shared/src/desktopTest/kotlin/org/harvestcircle/ui/shell/FoundationOverlayHostTest.kt | 25++++++++++++++++++++++++-
7 files changed, 107 insertions(+), 12 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 @@ -167,7 +167,6 @@ object OverlayReducer { val admitted = state.identity.removalConfirmation ?: return OverlayTransition(state) if (current.action != expected || current.phase != ConfirmationPhase.Ready || - state.identity.busy || state.identity.removalStatus != RemovalStatus.AWAITING_CONFIRMATION || admitted.identityId != removal.identityId || admitted.requestId != removal.requestId diff --git a/app/shared/src/commonMain/kotlin/org/harvestcircle/application/ShellReducer.kt b/app/shared/src/commonMain/kotlin/org/harvestcircle/application/ShellReducer.kt @@ -86,7 +86,6 @@ object ShellReducer { if (current?.action == action) { val admissionRejected = current.busy && - !identity.busy && identity.removalStatus == RemovalStatus.AWAITING_CONFIRMATION && identity.commandStatus in REMOVAL_ADMISSION_FAILURES return if (admissionRejected) { 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 @@ -36,7 +36,6 @@ import org.harvestcircle.application.StatusOverlayKey fun FoundationOverlayHost( state: OverlayState, status: ShellStatusModel, - busy: Boolean = false, onIntent: (OverlayIntent) -> Unit, ) { status.banner?.let { banner -> @@ -50,7 +49,7 @@ fun FoundationOverlayHost( } } state.current?.let { overlay -> - val overlayBusy = busy || (overlay as? FoundationOverlay.ConfirmAction)?.busy == true + val overlayBusy = (overlay as? FoundationOverlay.ConfirmAction)?.busy == true val rootRequester = remember { FocusRequester() } Dialog( onDismissRequest = { @@ -67,7 +66,7 @@ fun FoundationOverlayHost( Modifier .focusGroup() .focusRequester(rootRequester) - .focusable(overlayBusy && overlay is FoundationOverlay.ConfirmAction) + .focusable(overlayBusy) .semantics { contentDescription = "Dialog: ${overlay.title()}" paneTitle = overlay.title() @@ -81,7 +80,7 @@ fun FoundationOverlayHost( StatusOverlayKey.Signer -> StatusOverlay("Signer status", status.signer.text, onIntent) StatusOverlayKey.Sync -> StatusOverlay("Sync status", status.sync.text, onIntent) } - is FoundationOverlay.OpenNostrReference -> ReferenceOverlay(overlay, busy, onIntent) + is FoundationOverlay.OpenNostrReference -> ReferenceOverlay(overlay, onIntent) } } } @@ -164,7 +163,6 @@ private fun StatusOverlay( @Composable private fun ReferenceOverlay( overlay: FoundationOverlay.OpenNostrReference, - busy: Boolean, onIntent: (OverlayIntent) -> Unit, ) { val inputRequester = remember { FocusRequester() } @@ -185,7 +183,6 @@ private fun ReferenceOverlay( previous = cancelRequester }.modalFocusCycle(submitRequester, cancelRequester) .testTag("nostr-reference-input"), - enabled = !busy, ) overlay.result?.let { ShellText(it.message, Modifier.testTag("nostr-reference-result")) } Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { @@ -200,7 +197,6 @@ private fun ReferenceOverlay( previous = inputRequester }.modalFocusCycle(cancelRequester, inputRequester) .testTag("nostr-reference-submit"), - enabled = !busy, kind = ShellButtonKind.Primary, ) ShellButton( @@ -214,7 +210,6 @@ private fun ReferenceOverlay( previous = submitRequester }.modalFocusCycle(inputRequester, submitRequester) .testTag("overlay-cancel"), - !busy, ) } } 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 @@ -105,7 +105,7 @@ private fun HarvestCircleShellContent( } } ShellFocusRestorer(state.overlays.restoreFocus, fallback) - FoundationOverlayHost(state.overlays, deriveShellStatus(state), state.identity.busy) { + FoundationOverlayHost(state.overlays, deriveShellStatus(state)) { dispatch(HarvestCircleShellIntent.Overlay(it)) } } diff --git a/app/shared/src/commonTest/kotlin/org/harvestcircle/application/HarvestCircleShellPresenterTest.kt b/app/shared/src/commonTest/kotlin/org/harvestcircle/application/HarvestCircleShellPresenterTest.kt @@ -120,6 +120,41 @@ class HarvestCircleShellPresenterTest { } @Test + fun hcSl006BusyRejectedConfirmationReturnsToReady() = + runTest { + val identityId = IdentityId.fromPublicKeyHex("04".repeat(32)) + val requestId = RemovalRequestId.from("removal-shell-busy") + val identity = + FakeIdentityPresentation( + presenterState(HarvestCircleRoute.IDENTITIES) + .withRemovalConfirmation(identityId, requestId) + .copy(busy = true, commandStatus = CommandStatus.RUNNING), + ) + val presenter = HarvestCircleShellPresenter(identity, BuildInfo.unknown(), this) + runCurrent() + val action = ConfirmationAction.RemoveLocalIdentity(identityId, requestId) + + presenter.dispatch(HarvestCircleShellIntent.Overlay(OverlayIntent.Confirm(action))) + assertEquals( + ConfirmationPhase.Submitting, + (presenter.state.value.overlays.current as FoundationOverlay.ConfirmAction).phase, + ) + + identity.state.value = + identity.state.value.copy( + busy = true, + commandStatus = CommandStatus.REJECTED_BUSY, + ) + runCurrent() + + assertEquals( + ConfirmationPhase.Ready, + (presenter.state.value.overlays.current as FoundationOverlay.ConfirmAction).phase, + ) + presenter.close() + } + + @Test fun replacedConfirmationRejectsThePriorTokenAndAdmitsOnlyTheCurrentToken() = runTest { val identityId = IdentityId.fromPublicKeyHex("04".repeat(32)) diff --git a/app/shared/src/commonTest/kotlin/org/harvestcircle/application/ShellOverlaysTest.kt b/app/shared/src/commonTest/kotlin/org/harvestcircle/application/ShellOverlaysTest.kt @@ -84,6 +84,50 @@ class ShellOverlaysTest { } @Test + fun hcSl006UnrelatedIdentityBusyDoesNotBlockReadyConfirmationAdmission() { + val identityId = IdentityId.fromPublicKeyHex("02".repeat(32)) + val requestId = RemovalRequestId.from("overlay-local-busy") + val action = ConfirmationAction.RemoveLocalIdentity(identityId, requestId) + val confirmation = + IdentityRemovalConfirmation( + identityId = identityId, + requestId = requestId, + deletesLocalCredential = true, + signsOut = false, + expiresAt = UnixSeconds(60), + ) + val initial = + shellState( + FoundationOverlay.ConfirmAction( + title = "Remove this saved identity?", + explanation = "Its local credential will be deleted.", + actionLabel = "Remove local identity", + action = action, + ), + ) + val busy = + initial.copy( + identity = + initial.identity.copy( + busy = true, + removalConfirmation = confirmation, + removalStatus = RemovalStatus.AWAITING_CONFIRMATION, + ), + ) + + val transition = OverlayReducer.transition(busy, OverlayIntent.Confirm(action)) + + assertEquals( + ConfirmationPhase.Submitting, + (transition.state.overlays.current as FoundationOverlay.ConfirmAction).phase, + ) + assertEquals( + listOf(ShellEffect.DispatchIdentity(HarvestCircleIntent.ConfirmIdentityRemoval(identityId, requestId))), + transition.effects, + ) + } + + @Test fun deferredOverlaysHaveNoFoundationStateConstructor() { val deferred = HarvestCircleSurfaceRegistry.overlays.filter { it.availability.name.startsWith("Deferred") } assertEquals(5, deferred.size) 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,6 +6,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.input.key.Key import androidx.compose.ui.test.ExperimentalTestApi import androidx.compose.ui.test.assertCountEquals +import androidx.compose.ui.test.assertIsEnabled import androidx.compose.ui.test.assertIsFocused import androidx.compose.ui.test.assertIsNotEnabled import androidx.compose.ui.test.isDialog @@ -19,6 +20,7 @@ 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.ConfirmationPhase import org.harvestcircle.application.FoundationOverlay import org.harvestcircle.application.GlobalStatusBanner import org.harvestcircle.application.OverlayIntent @@ -123,9 +125,10 @@ class FoundationOverlayHostTest { "The local credential will be deleted.", "Remove local identity", removalAction(), + phase = ConfirmationPhase.Submitting, ), ) - setContent { FoundationOverlayHost(state, status(), busy = true, onIntent = intents::add) } + setContent { FoundationOverlayHost(state, status(), onIntent = intents::add) } onNodeWithTag("foundation-overlay").assertIsFocused() onNodeWithTag("foundation-overlay").performKeyInput { @@ -139,6 +142,26 @@ class FoundationOverlayHostTest { } @Test + fun hcSl006ReferenceAndStatusControlsRemainLocallyInteractive() = + runComposeUiTest { + var state by mutableStateOf(OverlayState(current = FoundationOverlay.OpenNostrReference())) + setContent { + FoundationOverlayHost(state, status()) { intent -> + state = reduceOverlay(state, intent) + } + } + + onNodeWithTag("nostr-reference-input").assertIsEnabled().performTextInput("note1candidate") + onNodeWithTag("nostr-reference-submit").assertIsEnabled() + onNodeWithTag("overlay-cancel").assertIsEnabled().performClick() + onAllNodesWithTag("foundation-overlay").assertCountEquals(0) + + state = OverlayState(FoundationOverlay.Status(org.harvestcircle.application.StatusOverlayKey.Sync)) + onNodeWithTag("overlay-close").assertIsEnabled().performClick() + onAllNodesWithTag("foundation-overlay").assertCountEquals(0) + } + + @Test fun referenceFocusWrapsAcrossInputAndActions() = runComposeUiTest { val state = OverlayState(current = FoundationOverlay.OpenNostrReference())