app

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

commit 2f7d2f98e4bd2899ed3c6e47b9281e3d4d4464e9
parent 1b17496d1b4e802183bd2f639c0fa087137e8ade
Author: triesap <tyson@radroots.org>
Date:   Tue, 11 Aug 2026 16:57:53 +0000

ui: implement the foundation Network screen

- Map signer, relay, runtime, and pending-operation state from snapshots.
- Render Overview, Identity, Public relays, and Runtime foundation views.
- State the absence of managed service without inventing inbox or authority data.
- Verify mapper, Compose, formatting, Rust, and desktop test suites.

Diffstat:
Aapp/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/FoundationNetworkScreen.kt | 168+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mapp/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/HarvestCircleShell.kt | 1+
Aapp/shared/src/commonTest/kotlin/org/harvestcircle/ui/shell/FoundationNetworkModelTest.kt | 103+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aapp/shared/src/desktopTest/kotlin/org/harvestcircle/ui/shell/FoundationNetworkScreenTest.kt | 70++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
4 files changed, 342 insertions(+), 0 deletions(-)

diff --git a/app/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/FoundationNetworkScreen.kt b/app/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/FoundationNetworkScreen.kt @@ -0,0 +1,168 @@ +package org.harvestcircle.ui.shell + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.text.BasicText +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.unit.dp +import org.harvestcircle.application.ApplicationLifecycle +import org.harvestcircle.application.HarvestCircleShellState +import org.harvestcircle.application.RelayConnectionState +import org.harvestcircle.application.SignerAvailability + +data class NetworkRelayModel( + val url: String, + val destination: String, + val readState: String, + val writeState: String, +) + +data class FoundationNetworkModel( + val identityState: String, + val identityLabel: String?, + val relayState: String, + val relays: List<NetworkRelayModel>, + val runtimeState: String, + val runtimeProblem: String?, + val pendingOperations: Int, +) + +fun foundationNetworkModel(state: HarvestCircleShellState): FoundationNetworkModel { + val snapshot = state.identity.snapshot + val active = snapshot.activeIdentity + val identityState = + when { + state.session.readOnly -> "Read-only" + active?.identity?.signer?.availability == SignerAvailability.Available -> "Local identity active" + active != null -> "Credential unavailable" + snapshot.identities.isNotEmpty() -> "Local identity available" + else -> "Signed out" + } + return FoundationNetworkModel( + identityState = identityState, + identityLabel = active?.identity?.displayLabel ?: snapshot.identities.firstOrNull()?.displayLabel, + relayState = active?.relays?.state?.label() ?: "Not yet observed", + relays = + snapshot.configuredRelays.map { relay -> + NetworkRelayModel( + url = relay.url, + destination = relay.destination.name, + readState = if (relay.read) "Read available" else "Read unavailable", + writeState = if (relay.write) "Write available" else "Write unavailable", + ) + }, + runtimeState = snapshot.lifecycle.label(), + runtimeProblem = + snapshot.lifecycleProblem?.safeMessage + ?: snapshot.sessionProblem?.safeMessage + ?: snapshot.recoverableProblem?.safeMessage + ?: state.identity.problem, + pendingOperations = if (state.identity.busy) 1 else 0, + ) +} + +@Composable +fun FoundationNetworkScreen(model: FoundationNetworkModel) { + val tabs = + listOf( + TemplateTab(TemplateSelectionKey("overview"), "Overview"), + TemplateTab(TemplateSelectionKey("identity"), "Identity"), + TemplateTab(TemplateSelectionKey("public_relays"), "Public relays"), + TemplateTab(TemplateSelectionKey("runtime"), "Runtime"), + ) + var selected by remember { mutableStateOf(tabs.first().key) } + TabbedDetailTemplate( + tabs = tabs, + selected = selected, + tabRail = { available, current -> + Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { + available.forEach { tab -> + ShellAction( + label = tab.label, + description = "Show ${tab.label}", + tag = "network-tab-${tab.key.value}", + enabled = tab.key != current, + ) { selected = tab.key } + } + } + }, + detail = { selection -> NetworkDetail(selection, model) }, + ) +} + +@Composable +private fun NetworkDetail( + selection: TemplateSelectionKey, + model: FoundationNetworkModel, +) { + Column( + Modifier.testTag("network-${selection.value}"), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + when (selection.value) { + "overview" -> { + Fact("Signer", model.identityState) + Fact("Public relay reads", model.relays.count { it.readState == "Read available" }.toString()) + Fact("Public relay writes", model.relays.count { it.writeState == "Write available" }.toString()) + Fact("Local runtime", model.runtimeState) + Fact("Pending operations", model.pendingOperations.toString()) + BasicText("No managed HarvestCircle service is configured.") + } + "identity" -> { + BasicText(model.identityState, Modifier.testTag("network-identity-state")) + model.identityLabel?.let { BasicText(it, Modifier.testTag("network-identity-label")) } + } + "public_relays" -> { + BasicText(model.relayState, Modifier.testTag("network-relay-state")) + model.relays.forEach { relay -> + Column(Modifier.testTag("network-relay:${relay.url}")) { + BasicText(relay.url) + BasicText(relay.destination) + BasicText(relay.readState) + BasicText(relay.writeState) + } + } + } + "runtime" -> { + Fact("Local runtime", model.runtimeState) + Fact("Pending operations", model.pendingOperations.toString()) + model.runtimeProblem?.let { BasicText(it, Modifier.testTag("network-runtime-problem")) } + } + } + } +} + +@Composable +private fun Fact( + label: String, + value: String, +) { + Column { + BasicText(label) + BasicText(value, Modifier.testTag("network-fact-${label.lowercase().replace(' ', '-') }")) + } +} + +private fun RelayConnectionState.label(): String = + when (this) { + RelayConnectionState.Disconnected -> "Unavailable" + RelayConnectionState.Connecting -> "Not yet observed" + RelayConnectionState.Connected -> "Available" + RelayConnectionState.Degraded -> "Degraded" + RelayConnectionState.Error -> "Unavailable" + } + +private fun ApplicationLifecycle.label(): String = + when (this) { + ApplicationLifecycle.Ready -> "Available" + ApplicationLifecycle.Degraded -> "Degraded" + ApplicationLifecycle.Blocked, ApplicationLifecycle.Fatal, ApplicationLifecycle.Closed -> "Unavailable" + else -> "Not yet observed" + } 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 @@ -161,6 +161,7 @@ private fun DashboardRoot( ) }, ) + AppRoute.Network -> FoundationNetworkScreen(foundationNetworkModel(state)) else -> BasicText(route.title(), Modifier.testTag("foundation-route-body")) } }, diff --git a/app/shared/src/commonTest/kotlin/org/harvestcircle/ui/shell/FoundationNetworkModelTest.kt b/app/shared/src/commonTest/kotlin/org/harvestcircle/ui/shell/FoundationNetworkModelTest.kt @@ -0,0 +1,103 @@ +package org.harvestcircle.ui.shell + +import org.harvestcircle.application.ActiveIdentity +import org.harvestcircle.application.ApplicationErrorCategory +import org.harvestcircle.application.ApplicationErrorCode +import org.harvestcircle.application.ApplicationLifecycle +import org.harvestcircle.application.ApplicationProblem +import org.harvestcircle.application.ApplicationSnapshot +import org.harvestcircle.application.BuildInfo +import org.harvestcircle.application.HarvestCirclePresenterState +import org.harvestcircle.application.HarvestCircleRoute +import org.harvestcircle.application.HarvestCircleShellState +import org.harvestcircle.application.IdentityId +import org.harvestcircle.application.IdentitySummary +import org.harvestcircle.application.ProfileLoadState +import org.harvestcircle.application.RecoveryAction +import org.harvestcircle.application.RelayConnectionState +import org.harvestcircle.application.RelayDestination +import org.harvestcircle.application.RelayEndpoint +import org.harvestcircle.application.RelaySummary +import org.harvestcircle.application.SessionLifecycle +import org.harvestcircle.application.ShellSessionState +import org.harvestcircle.application.SignerAvailability +import org.harvestcircle.application.SignerBindingKind +import org.harvestcircle.application.SignerBindingSummary +import org.harvestcircle.application.SnapshotRevision +import org.harvestcircle.application.UnixSeconds +import kotlin.test.Test +import kotlin.test.assertEquals + +class FoundationNetworkModelTest { + @Test + fun mapsActiveRelayRuntimeAndOperationTruth() { + val model = foundationNetworkModel(shellState()) + assertEquals("Local identity active", model.identityState) + assertEquals("Grower identity", model.identityLabel) + assertEquals("Degraded", model.relayState) + assertEquals("Public", model.relays.single().destination) + assertEquals("Read available", model.relays.single().readState) + assertEquals("Write unavailable", model.relays.single().writeState) + assertEquals("Degraded", model.runtimeState) + assertEquals("Runtime degraded.", model.runtimeProblem) + assertEquals(1, model.pendingOperations) + } + + @Test + fun readOnlyOverridesSignerAuthorityWithoutInventingNetworkState() { + val model = foundationNetworkModel(shellState(readOnly = true, active = false)) + assertEquals("Read-only", model.identityState) + assertEquals("Not yet observed", model.relayState) + } +} + +private fun shellState( + readOnly: Boolean = false, + active: Boolean = true, +): HarvestCircleShellState { + val identity = + IdentitySummary( + IdentityId.fromPublicKeyHex("11".repeat(32)), + "npub1grower", + "Grower identity", + SignerBindingSummary(SignerBindingKind.LocalKeyring, SignerAvailability.Available), + UnixSeconds(1), + null, + ) + val relay = RelayEndpoint("wss://relay.example", RelayDestination.Public, read = true, write = false) + val problem = + ApplicationProblem( + ApplicationErrorCode.StorageUnavailable, + ApplicationErrorCategory.Storage, + retryable = true, + RecoveryAction.Retry, + operationId = null, + safeMessage = "Runtime degraded.", + ) + val snapshot = + ApplicationSnapshot( + SnapshotRevision(2UL), + ApplicationLifecycle.Degraded, + lifecycleProblem = problem, + configuredRelays = listOf(relay), + 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(listOf(relay), RelayConnectionState.Degraded), + ProfileLoadState.Empty, + profile = null, + ).takeIf { active }, + recoverableProblem = null, + ) + val presenter = HarvestCirclePresenterState(snapshot, route = HarvestCircleRoute.ACTIVE_IDENTITY, busy = true) + return HarvestCircleShellState( + identity = presenter, + buildInfo = BuildInfo.unknown(), + session = ShellSessionState(readOnly), + ) +} diff --git a/app/shared/src/desktopTest/kotlin/org/harvestcircle/ui/shell/FoundationNetworkScreenTest.kt b/app/shared/src/desktopTest/kotlin/org/harvestcircle/ui/shell/FoundationNetworkScreenTest.kt @@ -0,0 +1,70 @@ +package org.harvestcircle.ui.shell + +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.assertCountEquals +import androidx.compose.ui.test.onAllNodesWithText +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 kotlin.test.Test + +@OptIn(ExperimentalTestApi::class) +class FoundationNetworkScreenTest { + @Test + fun overviewIsTruthfulAndClaimsNoManagedService() = + runComposeUiTest { + setContent { FoundationNetworkScreen(model()) } + onNodeWithText("Signer").assertExists() + onNodeWithText("Local identity active").assertExists() + onNodeWithText("No managed HarvestCircle service is configured.").assertExists() + onAllNodesWithText("Authority").assertCountEquals(0) + onAllNodesWithText("Inbox").assertCountEquals(0) + } + + @Test + fun identityRelaysAndRuntimeUseOnlyTheSuppliedFoundationState() = + runComposeUiTest { + setContent { FoundationNetworkScreen(model()) } + onNodeWithTag("network-tab-identity").performClick() + onNodeWithText("Grower identity").assertExists() + onNodeWithTag("network-tab-public_relays").performClick() + onNodeWithText("wss://relay.example").assertExists() + onNodeWithText("Public").assertExists() + onNodeWithText("Read available").assertExists() + onNodeWithText("Write unavailable").assertExists() + onNodeWithText("Degraded").assertExists() + onNodeWithTag("network-tab-runtime").performClick() + onNodeWithText("Storage is temporarily unavailable.").assertExists() + } + + @Test + fun signedOutAndReadOnlyStatesRemainExplicit() = + runComposeUiTest { + setContent { FoundationNetworkScreen(model(identityState = "Read-only", relays = emptyList())) } + onNodeWithText("Read-only").assertExists() + onNodeWithTag("network-tab-public_relays").performClick() + onNodeWithText("Not yet observed").assertExists() + } +} + +private fun model( + identityState: String = "Local identity active", + relays: List<NetworkRelayModel> = + listOf( + NetworkRelayModel( + url = "wss://relay.example", + destination = "Public", + readState = "Read available", + writeState = "Write unavailable", + ), + ), +) = FoundationNetworkModel( + identityState = identityState, + identityLabel = "Grower identity", + relayState = if (relays.isEmpty()) "Not yet observed" else "Degraded", + relays = relays, + runtimeState = "Degraded", + runtimeProblem = "Storage is temporarily unavailable.", + pendingOperations = 1, +)