commit 6892dc9344c517285897f1db619ffe1cd286a041 parent 075ba18de239ee7b3e1af88b6d96fbf52d3905a7 Author: triesap <tyson@radroots.org> Date: Tue, 11 Aug 2026 21:42:08 +0000 application: derive live shell status - derive signer, relay synchronization, and banner priority from shell state - replace copied status overlay payloads with stable typed keys - render top-bar, banner, and open-dialog values from the same current model - cover live updates and local-data, connection, signer, and read-only priority Diffstat:
10 files changed, 221 insertions(+), 78 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 @@ -1,8 +1,5 @@ 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, @@ -11,12 +8,8 @@ sealed interface FoundationOverlay { val action: ConfirmationAction, ) : FoundationOverlay - data class SignerStatus( - val status: SignerStatusLabel, - ) : FoundationOverlay - - data class SyncStatus( - val status: SyncStatusLabel, + data class Status( + val key: StatusOverlayKey, ) : FoundationOverlay data class OpenNostrReference( @@ -27,16 +20,10 @@ sealed interface FoundationOverlay { enum class ConfirmationAction { RemoveLocalIdentity } -enum class BannerSeverity { Information, Caution, Critical } - -data class GlobalStatusBanner( - val message: String, - val severity: BannerSeverity, -) +enum class StatusOverlayKey { Signer, Sync } data class OverlayState( val current: FoundationOverlay? = null, - val banner: GlobalStatusBanner? = null, ) sealed interface OverlayIntent { diff --git a/app/shared/src/commonMain/kotlin/org/harvestcircle/application/ShellStatus.kt b/app/shared/src/commonMain/kotlin/org/harvestcircle/application/ShellStatus.kt @@ -0,0 +1,77 @@ +package org.harvestcircle.application + +enum class SyncStatusLabel( + val text: String, +) { + NotYetObserved("Not yet observed"), + Available("Synced"), + Degraded("Limited connection"), + Unavailable("Offline"), +} + +enum class SignerStatusLabel( + val text: String, +) { + ReadOnly("Read-only"), + SignedOut("Signed out"), + Available("Local identity active"), + CredentialMissing("Signer unavailable"), +} + +enum class BannerSeverity { Information, Caution, Critical } + +data class GlobalStatusBanner( + val title: String, + val message: String, + val severity: BannerSeverity, +) + +data class ShellStatusModel( + val sync: SyncStatusLabel, + val signer: SignerStatusLabel, + val banner: GlobalStatusBanner?, +) + +fun deriveShellStatus(state: HarvestCircleShellState): ShellStatusModel { + val sync = + when ( + state.identity.snapshot.activeIdentity + ?.relays + ?.state + ) { + RelayConnectionState.Connected -> SyncStatusLabel.Available + RelayConnectionState.Degraded -> SyncStatusLabel.Degraded + RelayConnectionState.Error, RelayConnectionState.Disconnected -> SyncStatusLabel.Unavailable + RelayConnectionState.Connecting, null -> SyncStatusLabel.NotYetObserved + } + val signer = + when { + state.session.readOnly -> SignerStatusLabel.ReadOnly + state.identity.snapshot.activeIdentity == null -> SignerStatusLabel.SignedOut + state.identity.snapshot.activeIdentity.identity.signer.availability == SignerAvailability.Available -> + SignerStatusLabel.Available + else -> SignerStatusLabel.CredentialMissing + } + return ShellStatusModel(sync, signer, deriveBanner(state, sync, signer)) +} + +private fun deriveBanner( + state: HarvestCircleShellState, + sync: SyncStatusLabel, + signer: SignerStatusLabel, +): GlobalStatusBanner? = + when { + state.identity.lastProblem?.category == ApplicationErrorCategory.Storage -> + GlobalStatusBanner("Local data needs attention", "Review the local runtime status before continuing.", BannerSeverity.Critical) + sync == SyncStatusLabel.Unavailable -> + GlobalStatusBanner("Offline", "Public views may be out of date.", BannerSeverity.Caution) + sync == SyncStatusLabel.Degraded -> + GlobalStatusBanner("Limited connection", "Some configured services are unavailable.", BannerSeverity.Caution) + signer == SignerStatusLabel.CredentialMissing -> + GlobalStatusBanner("Signer unavailable", "Signing and private actions are unavailable.", BannerSeverity.Caution) + signer == SignerStatusLabel.SignedOut -> + GlobalStatusBanner("No active identity", "Public browsing remains available.", BannerSeverity.Information) + signer == SignerStatusLabel.ReadOnly -> + GlobalStatusBanner("Read-only", "Signing and private actions are unavailable.", BannerSeverity.Information) + else -> null + } 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 @@ -19,18 +19,25 @@ import androidx.compose.ui.window.Dialog 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 @Composable fun FoundationOverlayHost( state: OverlayState, + status: ShellStatusModel, busy: Boolean = false, onIntent: (OverlayIntent) -> Unit, ) { - state.banner?.let { banner -> - ShellBadge( - banner.message, - Modifier.semantics { contentDescription = "Status: ${banner.message}" }.testTag("global-status-banner"), - ) + 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) + } + } } state.current?.let { overlay -> Dialog(onDismissRequest = { if (!busy) onIntent(OverlayIntent.Close) }) { @@ -43,8 +50,11 @@ fun FoundationOverlayHost( ) { when (overlay) { 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.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, busy, onIntent) } } 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 @@ -7,24 +7,8 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.platform.testTag import androidx.compose.ui.unit.dp - -enum class SyncStatusLabel( - val text: String, -) { - NotYetObserved("Not yet observed"), - Available("Available"), - Degraded("Degraded"), - Unavailable("Unavailable"), -} - -enum class SignerStatusLabel( - val text: String, -) { - ReadOnly("Read-only"), - SignedOut("No signer"), - Available("Signer available"), - CredentialMissing("Credential missing"), -} +import org.harvestcircle.application.SignerStatusLabel +import org.harvestcircle.application.SyncStatusLabel data class GlobalTopBarModel( val canGoBack: Boolean, 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 @@ -13,7 +13,8 @@ import org.harvestcircle.application.HarvestCircleShellIntent import org.harvestcircle.application.HarvestCircleShellState import org.harvestcircle.application.OverlayIntent import org.harvestcircle.application.ShellRoot -import org.harvestcircle.application.SignerAvailability +import org.harvestcircle.application.StatusOverlayKey +import org.harvestcircle.application.deriveShellStatus import org.harvestcircle.identities.ui.HarvestCirclePlatformActions import org.harvestcircle.identities.ui.HarvestCircleScreen import org.harvestcircle.identities.ui.HarvestCircleUiActions @@ -80,7 +81,9 @@ private fun HarvestCircleShellContent( } is ShellRoot.Dashboard -> DashboardRoot(state, root, identityActions, platformActions, dispatch) } - FoundationOverlayHost(state.overlays, state.identity.busy) { dispatch(HarvestCircleShellIntent.Overlay(it)) } + FoundationOverlayHost(state.overlays, deriveShellStatus(state), state.identity.busy) { + dispatch(HarvestCircleShellIntent.Overlay(it)) + } } @Composable @@ -140,6 +143,7 @@ private fun DashboardRoot( dispatch: (HarvestCircleShellIntent) -> Unit, ) { val route = root.navigation.current + val status = deriveShellStatus(state) DashboardScaffold( windowWidthDp = ShellDimensions.PREFERRED_WINDOW_WIDTH_DP, inspectorVisible = false, @@ -149,8 +153,8 @@ private fun DashboardRoot( GlobalTopBarModel( canGoBack = root.navigation.backStack.isNotEmpty(), canGoForward = root.navigation.forwardStack.isNotEmpty(), - syncStatus = syncStatus(state), - signerStatus = signerStatus(state), + syncStatus = status.sync, + signerStatus = status.signer, ), onIntent = { intent -> dispatchTopBar(intent, dispatch) }, ) @@ -240,36 +244,15 @@ private fun dispatchTopBar( GlobalTopBarIntent.OpenNostrReference -> HarvestCircleShellIntent.Overlay(OverlayIntent.Open(FoundationOverlay.OpenNostrReference())) GlobalTopBarIntent.ShowSyncStatus -> - HarvestCircleShellIntent.Overlay(OverlayIntent.Open(FoundationOverlay.SyncStatus(SyncStatusLabel.NotYetObserved))) + HarvestCircleShellIntent.Overlay(OverlayIntent.Open(FoundationOverlay.Status(StatusOverlayKey.Sync))) GlobalTopBarIntent.ShowSignerStatus -> - HarvestCircleShellIntent.Overlay(OverlayIntent.Open(FoundationOverlay.SignerStatus(SignerStatusLabel.SignedOut))) + HarvestCircleShellIntent.Overlay(OverlayIntent.Open(FoundationOverlay.Status(StatusOverlayKey.Signer))) GlobalTopBarIntent.OpenApplicationMenu -> HarvestCircleShellIntent.Navigate(ScreenKey.Settings) } dispatch(shellIntent) } -private fun syncStatus(state: HarvestCircleShellState): SyncStatusLabel = - when ( - state.identity.snapshot.activeIdentity - ?.relays - ?.state - ) { - org.harvestcircle.application.RelayConnectionState.Connected -> SyncStatusLabel.Available - org.harvestcircle.application.RelayConnectionState.Degraded -> SyncStatusLabel.Degraded - org.harvestcircle.application.RelayConnectionState.Error -> SyncStatusLabel.Unavailable - else -> SyncStatusLabel.NotYetObserved - } - -private fun signerStatus(state: HarvestCircleShellState): SignerStatusLabel = - when { - state.session.readOnly -> SignerStatusLabel.ReadOnly - state.identity.snapshot.activeIdentity == null -> SignerStatusLabel.SignedOut - state.identity.snapshot.activeIdentity.identity.signer.availability == SignerAvailability.Available -> - SignerStatusLabel.Available - else -> SignerStatusLabel.CredentialMissing - } - private fun AppRoute.title(): String = when (this) { AppRoute.PersonalToday -> "Today" diff --git a/app/shared/src/commonTest/kotlin/org/harvestcircle/application/HarvestCircleShellPresenterTest.kt b/app/shared/src/commonTest/kotlin/org/harvestcircle/application/HarvestCircleShellPresenterTest.kt @@ -29,13 +29,13 @@ class HarvestCircleShellPresenterTest { presenter.dispatch(HarvestCircleShellIntent.SetMotion(MotionPreference.Reduced)) presenter.dispatch( HarvestCircleShellIntent.Overlay( - OverlayIntent.Open(FoundationOverlay.SyncStatus(org.harvestcircle.ui.shell.SyncStatusLabel.Degraded)), + OverlayIntent.Open(FoundationOverlay.Status(StatusOverlayKey.Sync)), ), ) assertEquals(AppRoute.Network, presenter.state.value.currentRoute) assertEquals(ThemePreference.Dark, presenter.state.value.appearance.theme) - assertTrue(presenter.state.value.overlays.current is FoundationOverlay.SyncStatus) + assertEquals(FoundationOverlay.Status(StatusOverlayKey.Sync), presenter.state.value.overlays.current) presenter.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 @@ -21,14 +21,14 @@ class ShellOverlaysTest { val first = OverlayReducer.reduce( OverlayState(), - OverlayIntent.Open(FoundationOverlay.SignerStatus(org.harvestcircle.ui.shell.SignerStatusLabel.Available)), + OverlayIntent.Open(FoundationOverlay.Status(StatusOverlayKey.Signer)), ) val second = OverlayReducer.reduce( first, - OverlayIntent.Open(FoundationOverlay.SyncStatus(org.harvestcircle.ui.shell.SyncStatusLabel.Degraded)), + OverlayIntent.Open(FoundationOverlay.Status(StatusOverlayKey.Sync)), ) - assertTrue(second.current is FoundationOverlay.SyncStatus) + assertEquals(FoundationOverlay.Status(StatusOverlayKey.Sync), second.current) assertNull(OverlayReducer.reduce(second, OverlayIntent.Escape).current) } diff --git a/app/shared/src/commonTest/kotlin/org/harvestcircle/application/ShellStatusTest.kt b/app/shared/src/commonTest/kotlin/org/harvestcircle/application/ShellStatusTest.kt @@ -0,0 +1,75 @@ +package org.harvestcircle.application + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class ShellStatusTest { + @Test + fun derivesLiveSignerAndSyncFromCurrentEvidence() { + val active = deriveShellStatus(shellState(RelayConnectionState.Connected)) + assertEquals(SyncStatusLabel.Available, active.sync) + assertEquals(SignerStatusLabel.Available, active.signer) + assertNull(active.banner) + + val readOnly = deriveShellStatus(shellState(active = false, readOnly = true)) + assertEquals(SignerStatusLabel.ReadOnly, readOnly.signer) + assertEquals("Read-only", readOnly.banner?.title) + } + + @Test + fun bannerPriorityPrefersLocalDataThenConnectionThenSigner() { + val storageProblem = problem(ApplicationErrorCategory.Storage, ApplicationErrorCode.StorageUnavailable) + val localData = deriveShellStatus(shellState(RelayConnectionState.Error, lastProblem = storageProblem)) + assertEquals("Local data needs attention", localData.banner?.title) + + val offline = deriveShellStatus(shellState(RelayConnectionState.Error)) + assertEquals("Offline", offline.banner?.title) + + val signerMissing = deriveShellStatus(shellState(signer = SignerAvailability.CredentialMissing)) + assertEquals("Signer unavailable", signerMissing.banner?.title) + } +} + +private fun shellState( + relay: RelayConnectionState = RelayConnectionState.Connected, + signer: SignerAvailability = SignerAvailability.Available, + active: Boolean = true, + readOnly: Boolean = false, + lastProblem: ApplicationProblem? = null, +): HarvestCircleShellState { + val identity = + IdentitySummary( + IdentityId.fromPublicKeyHex("03".repeat(32)), + "npub1status", + "Status identity", + SignerBindingSummary(SignerBindingKind.LocalKeyring, signer), + UnixSeconds(1), + null, + ) + val snapshot = + ApplicationSnapshot( + SnapshotRevision(1UL), + ApplicationLifecycle.Ready, + lifecycleProblem = null, + configuredRelays = emptyList(), + identities = listOf(identity), + selectedIdentityId = identity.id, + session = if (active) SessionLifecycle.Active else SessionLifecycle.SignedOut, + sessionSubjectIdentityId = identity.id.takeIf { active }, + sessionProblem = null, + activeIdentity = + ActiveIdentity(identity, RelaySummary(emptyList(), relay), ProfileLoadState.Empty, null).takeIf { active }, + recoverableProblem = null, + ) + return HarvestCircleShellState( + identity = HarvestCirclePresenterState(snapshot, lastProblem = lastProblem), + buildInfo = BuildInfo.unknown(), + session = ShellSessionState(readOnly), + ) +} + +private fun problem( + category: ApplicationErrorCategory, + code: ApplicationErrorCode, +) = ApplicationProblem(code, category, true, RecoveryAction.Retry, null, "Safe status problem.") 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 @@ -9,6 +9,7 @@ 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.onNodeWithText import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performTextInput import androidx.compose.ui.test.v2.runComposeUiTest @@ -19,6 +20,9 @@ import org.harvestcircle.application.GlobalStatusBanner import org.harvestcircle.application.OverlayIntent import org.harvestcircle.application.OverlayReducer import org.harvestcircle.application.OverlayState +import org.harvestcircle.application.ShellStatusModel +import org.harvestcircle.application.SignerStatusLabel +import org.harvestcircle.application.SyncStatusLabel import kotlin.test.Test @OptIn(ExperimentalTestApi::class) @@ -29,10 +33,16 @@ class FoundationOverlayHostTest { var state by mutableStateOf( OverlayState( current = FoundationOverlay.OpenNostrReference(), - banner = GlobalStatusBanner("Limited connection", BannerSeverity.Caution), ), ) - setContent { FoundationOverlayHost(state) { state = OverlayReducer.reduce(state, it) } } + setContent { + FoundationOverlayHost( + state, + status(banner = GlobalStatusBanner("Limited connection", "Some services are unavailable.", BannerSeverity.Caution)), + ) { + state = OverlayReducer.reduce(state, it) + } + } onAllNodesWithTag("foundation-overlay").assertCountEquals(1) onAllNodesWithTag("global-status-banner").assertCountEquals(1) onNodeWithTag("nostr-reference-input").assertIsFocused().performTextInput("note1qqqqqq") @@ -56,7 +66,7 @@ class FoundationOverlayHostTest { ConfirmationAction.RemoveLocalIdentity, ), ) - setContent { FoundationOverlayHost(state, onIntent = intents::add) } + setContent { FoundationOverlayHost(state, status(), onIntent = intents::add) } onNodeWithTag("overlay-confirm").assertIsFocused().performClick() kotlin.test.assertEquals(listOf<OverlayIntent>(OverlayIntent.Confirm), intents) @@ -76,10 +86,25 @@ class FoundationOverlayHostTest { ConfirmationAction.RemoveLocalIdentity, ), ) - setContent { FoundationOverlayHost(state, busy = true, onIntent = intents::add) } + setContent { FoundationOverlayHost(state, status(), busy = true, onIntent = intents::add) } onNodeWithTag("overlay-confirm").assertIsNotEnabled().performClick() onNodeWithTag("overlay-cancel").assertIsNotEnabled() kotlin.test.assertTrue(intents.isEmpty()) } + + @Test + fun openStatusDialogRendersTheLatestStatusModel() = + runComposeUiTest { + var status by mutableStateOf(status()) + val overlay = OverlayState(FoundationOverlay.Status(org.harvestcircle.application.StatusOverlayKey.Sync)) + setContent { FoundationOverlayHost(overlay, status, onIntent = {}) } + + onNodeWithText("Not yet observed").assertExists() + status = ShellStatusModel(SyncStatusLabel.Degraded, SignerStatusLabel.SignedOut, null) + onNodeWithText("Limited connection").assertExists() + } } + +private fun status(banner: GlobalStatusBanner? = null) = + ShellStatusModel(SyncStatusLabel.NotYetObserved, SignerStatusLabel.SignedOut, banner) diff --git a/app/shared/src/desktopTest/kotlin/org/harvestcircle/ui/shell/GlobalTopBarTest.kt b/app/shared/src/desktopTest/kotlin/org/harvestcircle/ui/shell/GlobalTopBarTest.kt @@ -6,6 +6,8 @@ import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick import androidx.compose.ui.test.v2.runComposeUiTest +import org.harvestcircle.application.SignerStatusLabel +import org.harvestcircle.application.SyncStatusLabel import kotlin.test.Test import kotlin.test.assertEquals @@ -26,7 +28,7 @@ class GlobalTopBarTest { intents::add, ) } - onNodeWithText("Degraded").performClick() + onNodeWithText("Limited connection").performClick() onNodeWithText("Read-only").performClick() onNodeWithTag("top-bar-open-reference").performClick() onNodeWithTag("top-bar-forward").assertIsNotEnabled()