commit 04d8cf8038c9da70152fab240eada9a7724fc397 parent 33685e3a1e8352eb859bc0007f48a5f5760d81a6 Author: triesap <tyson@radroots.org> Date: Tue, 11 Aug 2026 21:20:59 +0000 application: type shell presentation state - Preserve signer, relay, profile, runtime, and destination enums in UI models. - Replace English equality checks with exhaustive typed branches. - Keep relay capabilities as booleans until their render boundary. - Update legacy and active consumers without changing user-visible copy. Diffstat:
9 files changed, 100 insertions(+), 71 deletions(-)
diff --git a/app/shared/src/commonMain/kotlin/org/harvestcircle/identities/ui/HarvestCircleScreen.kt b/app/shared/src/commonMain/kotlin/org/harvestcircle/identities/ui/HarvestCircleScreen.kt @@ -208,8 +208,8 @@ private fun ActiveIdentityHome( BasicText("NIP-05 (unverified): ${active.profile.nip05}") BasicText("About: ${active.profile.about}", Modifier.testTag("active-profile-about")) BasicText("Picture: ${active.profile.picture}") - BasicText("Relay: ${active.relayState}", Modifier.testTag("relay-state")) - BasicText("Profile: ${active.profileState}", Modifier.testTag("profile-state")) + BasicText("Relay: ${active.relayState.displayText()}", Modifier.testTag("relay-state")) + BasicText("Profile: ${active.profileState.displayText()}", Modifier.testTag("profile-state")) BasicText("Configured relays") if (model.configuredRelays.isEmpty()) { BasicText("None") @@ -412,7 +412,7 @@ private fun ColumnScope.SavedIdentityList( ) { BasicText(identity.label) BasicText(identity.npub) - BasicText("Key: ${identity.signerAvailability}") + BasicText("Key: ${identity.signerAvailability.displayText()}") if (identity.selected) BasicText("Selected") if (identity.active) BasicText("Active") TextAction( diff --git a/app/shared/src/commonMain/kotlin/org/harvestcircle/identities/ui/IdentityUiModel.kt b/app/shared/src/commonMain/kotlin/org/harvestcircle/identities/ui/IdentityUiModel.kt @@ -12,13 +12,14 @@ import org.harvestcircle.application.RelayConnectionState import org.harvestcircle.application.RemovalImpactState import org.harvestcircle.application.RemovalStatus import org.harvestcircle.application.SessionLifecycle +import org.harvestcircle.application.SignerAvailability data class IdentityUiModel( val publicKeyHex: String, val npub: String, val shortNpub: String, val label: String, - val signerAvailability: String, + val signerAvailability: SignerAvailability, val selected: Boolean, val active: Boolean, ) @@ -34,8 +35,8 @@ data class ProfileUiModel( data class ActiveIdentityUiModel( val identity: IdentityUiModel, val heading: String, - val relayState: String, - val profileState: String, + val relayState: RelayConnectionState, + val profileState: ProfileLoadState, val profile: ProfileUiModel, ) @@ -141,10 +142,7 @@ private fun IdentitySummary.toUiModel( npub = npub, shortNpub = shortenNpub(npub), label = displayLabel.ifBlank { shortenNpub(npub) }, - signerAvailability = - signer.availability.name - .lowercase() - .replace('_', ' '), + signerAvailability = signer.availability, selected = selected, active = active, ) @@ -160,8 +158,8 @@ private fun ActiveIdentity.toUiModel(selectedPublicKeyHex: String?) = profile?.displayName?.takeIf(String::isNotBlank) ?: profile?.name?.takeIf(String::isNotBlank) ?: identity.displayLabel.ifBlank { shortenNpub(identity.npub) }, - relayState = relays.state.toDisplayText(), - profileState = profileState.toDisplayText(), + relayState = relays.state, + profileState = profileState, profile = ProfileUiModel( name = profile?.name.orEmpty(), @@ -172,6 +170,8 @@ private fun ActiveIdentity.toUiModel(selectedPublicKeyHex: String?) = ), ) -private fun RelayConnectionState.toDisplayText(): String = name.lowercase().replace('_', ' ') +fun SignerAvailability.displayText(): String = name.lowercase().replace('_', ' ') -private fun ProfileLoadState.toDisplayText(): String = name.lowercase().replace('_', ' ') +fun RelayConnectionState.displayText(): String = name.lowercase().replace('_', ' ') + +fun ProfileLoadState.displayText(): String = name.lowercase().replace('_', ' ') diff --git a/app/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/BootstrapRecoveryAndChooser.kt b/app/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/BootstrapRecoveryAndChooser.kt @@ -99,7 +99,7 @@ private fun IdentityRow( BasicText(identity.label) BasicText(identity.shortNpub) BasicText( - if (identity.signerAvailability == "available") { + if (identity.signerAvailability == org.harvestcircle.application.SignerAvailability.Available) { "Local credential available" } else { "Local credential unavailable" 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 @@ -15,22 +15,27 @@ 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.RelayDestination import org.harvestcircle.application.SignerAvailability +enum class NetworkIdentityState { ReadOnly, Active, CredentialUnavailable, Available, SignedOut } + +enum class RelayObservationState { NotYetObserved, Available, Degraded, Unavailable } + data class NetworkRelayModel( val url: String, - val destination: String, - val readState: String, - val writeState: String, + val destination: RelayDestination, + val read: Boolean, + val write: Boolean, ) data class FoundationNetworkModel( - val identityState: String, + val identityState: NetworkIdentityState, val identityLabel: String?, val profileLabel: String?, - val relayState: String, + val relayState: RelayObservationState, val relays: List<NetworkRelayModel>, - val runtimeState: String, + val runtimeState: ApplicationLifecycle, val runtimeProblem: String?, val pendingOperations: Int, ) @@ -40,27 +45,27 @@ fun foundationNetworkModel(state: HarvestCircleShellState): FoundationNetworkMod 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" + state.session.readOnly -> NetworkIdentityState.ReadOnly + active?.identity?.signer?.availability == SignerAvailability.Available -> NetworkIdentityState.Active + active != null -> NetworkIdentityState.CredentialUnavailable + snapshot.identities.isNotEmpty() -> NetworkIdentityState.Available + else -> NetworkIdentityState.SignedOut } return FoundationNetworkModel( identityState = identityState, identityLabel = active?.identity?.displayLabel ?: snapshot.identities.firstOrNull()?.displayLabel, profileLabel = active?.profile?.displayName ?: active?.profile?.name, - relayState = active?.relays?.state?.label() ?: "Not yet observed", + relayState = active?.relays?.state?.toObservation() ?: RelayObservationState.NotYetObserved, 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", + destination = relay.destination, + read = relay.read, + write = relay.write, ) }, - runtimeState = snapshot.lifecycle.label(), + runtimeState = snapshot.lifecycle, runtimeProblem = snapshot.lifecycleProblem?.safeMessage ?: snapshot.sessionProblem?.safeMessage @@ -116,35 +121,35 @@ private fun NetworkDetail( ) { 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("Signer", model.identityState.label()) + Fact("Public relay reads", model.relays.count(NetworkRelayModel::read).toString()) + Fact("Public relay writes", model.relays.count(NetworkRelayModel::write).toString()) + Fact("Local runtime", model.runtimeState.label()) Fact("Pending operations", model.pendingOperations.toString()) BasicText("No managed HarvestCircle service is configured.") } "identity" -> { - BasicText(model.identityState, Modifier.testTag("network-identity-state")) + BasicText(model.identityState.label(), Modifier.testTag("network-identity-state")) model.identityLabel?.let { BasicText(it, Modifier.testTag("network-identity-label")) } model.profileLabel?.let { BasicText("Display name: $it", Modifier.testTag("network-profile-label")) } - if (model.identityState == "Local identity active") { + if (model.identityState == NetworkIdentityState.Active) { ShellAction("Refresh profile", "Refresh active profile", "refresh-profile", onClick = refreshProfile) ShellAction("Sign out", "Sign out", "sign-out", onClick = signOut) } } "public_relays" -> { - BasicText(model.relayState, Modifier.testTag("network-relay-state")) + BasicText(model.relayState.label(), 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) + BasicText(relay.destination.label()) + BasicText(if (relay.read) "Read available" else "Read unavailable") + BasicText(if (relay.write) "Write available" else "Write unavailable") } } } "runtime" -> { - Fact("Local runtime", model.runtimeState) + Fact("Local runtime", model.runtimeState.label()) Fact("Pending operations", model.pendingOperations.toString()) model.runtimeProblem?.let { BasicText(it, Modifier.testTag("network-runtime-problem")) } } @@ -163,15 +168,33 @@ private fun Fact( } } -private fun RelayConnectionState.label(): String = +private fun RelayConnectionState.toObservation(): RelayObservationState = + when (this) { + RelayConnectionState.Disconnected, RelayConnectionState.Error -> RelayObservationState.Unavailable + RelayConnectionState.Connecting -> RelayObservationState.NotYetObserved + RelayConnectionState.Connected -> RelayObservationState.Available + RelayConnectionState.Degraded -> RelayObservationState.Degraded + } + +private fun NetworkIdentityState.label(): String = when (this) { - RelayConnectionState.Disconnected -> "Unavailable" - RelayConnectionState.Connecting -> "Not yet observed" - RelayConnectionState.Connected -> "Available" - RelayConnectionState.Degraded -> "Degraded" - RelayConnectionState.Error -> "Unavailable" + NetworkIdentityState.ReadOnly -> "Read-only" + NetworkIdentityState.Active -> "Local identity active" + NetworkIdentityState.CredentialUnavailable -> "Credential unavailable" + NetworkIdentityState.Available -> "Local identity available" + NetworkIdentityState.SignedOut -> "Signed out" } +private fun RelayObservationState.label(): String = + when (this) { + RelayObservationState.NotYetObserved -> "Not yet observed" + RelayObservationState.Available -> "Available" + RelayObservationState.Degraded -> "Degraded" + RelayObservationState.Unavailable -> "Unavailable" + } + +private fun RelayDestination.label(): String = name + private fun ApplicationLifecycle.label(): String = when (this) { ApplicationLifecycle.Ready -> "Available" diff --git a/app/shared/src/commonTest/kotlin/org/harvestcircle/identities/ui/IdentityUiModelTest.kt b/app/shared/src/commonTest/kotlin/org/harvestcircle/identities/ui/IdentityUiModelTest.kt @@ -48,8 +48,9 @@ class IdentityUiModelTest { val model = HarvestCirclePresenterState(snapshot).toUiModel() assertEquals("Alice", model.activeIdentity?.heading) - assertEquals("connected", model.activeIdentity?.relayState) - assertEquals("fresh", model.activeIdentity?.profileState) + assertEquals(RelayConnectionState.Connected, model.activeIdentity?.relayState) + assertEquals(ProfileLoadState.Fresh, model.activeIdentity?.profileState) + assertEquals(SignerAvailability.Available, model.identities.single().signerAvailability) assertEquals("alice@example.com", model.activeIdentity?.profile?.nip05) assertEquals(listOf("ws://localhost:8080"), model.configuredRelays) assertFalse(model.identityChooserVisible) 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 @@ -32,14 +32,14 @@ class FoundationNetworkModelTest { @Test fun mapsActiveRelayRuntimeAndOperationTruth() { val model = foundationNetworkModel(shellState()) - assertEquals("Local identity active", model.identityState) + assertEquals(NetworkIdentityState.Active, model.identityState) assertEquals("Grower identity", model.identityLabel) assertEquals(null, model.profileLabel) - 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(RelayObservationState.Degraded, model.relayState) + assertEquals(RelayDestination.Public, model.relays.single().destination) + assertEquals(true, model.relays.single().read) + assertEquals(false, model.relays.single().write) + assertEquals(ApplicationLifecycle.Degraded, model.runtimeState) assertEquals("Runtime degraded.", model.runtimeProblem) assertEquals(1, model.pendingOperations) } @@ -47,8 +47,8 @@ class FoundationNetworkModelTest { @Test fun readOnlyOverridesSignerAuthorityWithoutInventingNetworkState() { val model = foundationNetworkModel(shellState(readOnly = true, active = false)) - assertEquals("Read-only", model.identityState) - assertEquals("Not yet observed", model.relayState) + assertEquals(NetworkIdentityState.ReadOnly, model.identityState) + assertEquals(RelayObservationState.NotYetObserved, model.relayState) } } diff --git a/app/shared/src/desktopTest/kotlin/org/harvestcircle/identities/ui/HarvestCircleScreenTest.kt b/app/shared/src/desktopTest/kotlin/org/harvestcircle/identities/ui/HarvestCircleScreenTest.kt @@ -20,10 +20,13 @@ import androidx.compose.ui.test.v2.runComposeUiTest import org.harvestcircle.application.HarvestCircleRoute import org.harvestcircle.application.IdentityEntryMode import org.harvestcircle.application.IdentityId +import org.harvestcircle.application.ProfileLoadState import org.harvestcircle.application.RecoveryAction +import org.harvestcircle.application.RelayConnectionState import org.harvestcircle.application.RemovalImpactState import org.harvestcircle.application.RemovalStatus import org.harvestcircle.application.SessionLifecycle +import org.harvestcircle.application.SignerAvailability import org.harvestcircle.application.UnixSeconds import kotlin.test.Test import kotlin.test.assertEquals @@ -255,8 +258,8 @@ class HarvestCircleScreenTest { ActiveIdentityUiModel( identity = identity, heading = "Alice", - relayState = "connected", - profileState = "fresh", + relayState = RelayConnectionState.Connected, + profileState = ProfileLoadState.Fresh, profile = ProfileUiModel( name = "alice", @@ -307,8 +310,8 @@ class HarvestCircleScreenTest { ActiveIdentityUiModel( identity = first, heading = first.label, - relayState = "connected", - profileState = "cached", + relayState = RelayConnectionState.Connected, + profileState = ProfileLoadState.Cached, profile = ProfileUiModel("", "", "", "", ""), ) var chooserVisible by mutableStateOf(false) @@ -385,7 +388,7 @@ private fun identityUi( npub = "npub1${publicKeyHex.take(12)}", shortNpub = "npub1${publicKeyHex.take(12)}", label = "Identity ${publicKeyHex.take(2)}", - signerAvailability = "available", + signerAvailability = SignerAvailability.Available, selected = selected, active = active, ) diff --git a/app/shared/src/desktopTest/kotlin/org/harvestcircle/ui/shell/BootstrapRecoveryAndChooserTest.kt b/app/shared/src/desktopTest/kotlin/org/harvestcircle/ui/shell/BootstrapRecoveryAndChooserTest.kt @@ -93,7 +93,7 @@ private fun identity( npub = "npub1$id", shortNpub = "npub1$id", label = id.replaceFirstChar(Char::uppercaseChar), - signerAvailability = "available", + signerAvailability = org.harvestcircle.application.SignerAvailability.Available, selected = selected, active = false, ) 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 @@ -7,6 +7,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.ApplicationLifecycle +import org.harvestcircle.application.RelayDestination import kotlin.test.Test @OptIn(ExperimentalTestApi::class) @@ -53,7 +55,7 @@ class FoundationNetworkScreenTest { @Test fun signedOutAndReadOnlyStatesRemainExplicit() = runComposeUiTest { - setContent { FoundationNetworkScreen(model(identityState = "Read-only", relays = emptyList())) } + setContent { FoundationNetworkScreen(model(identityState = NetworkIdentityState.ReadOnly, relays = emptyList())) } onNodeWithText("Read-only").assertExists() onNodeWithTag("network-tab-public_relays").performClick() onNodeWithText("Not yet observed").assertExists() @@ -61,23 +63,23 @@ class FoundationNetworkScreenTest { } private fun model( - identityState: String = "Local identity active", + identityState: NetworkIdentityState = NetworkIdentityState.Active, relays: List<NetworkRelayModel> = listOf( NetworkRelayModel( url = "wss://relay.example", - destination = "Public", - readState = "Read available", - writeState = "Write unavailable", + destination = RelayDestination.Public, + read = true, + write = false, ), ), ) = FoundationNetworkModel( identityState = identityState, identityLabel = "Grower identity", profileLabel = "Farm Identity", - relayState = if (relays.isEmpty()) "Not yet observed" else "Degraded", + relayState = if (relays.isEmpty()) RelayObservationState.NotYetObserved else RelayObservationState.Degraded, relays = relays, - runtimeState = "Degraded", + runtimeState = ApplicationLifecycle.Degraded, runtimeProblem = "Storage is temporarily unavailable.", pendingOperations = 1, )