commit 61d4b5f8cee9595c9d4e96122c9c1b76f7980e2a
parent 403226dcaf1dbe08ca602e998d6250fc5174e704
Author: triesap <tyson@radroots.org>
Date: Tue, 11 Aug 2026 16:18:01 +0000
application: model shell feature availability
- derive bootstrap and dashboard roots from native presentation
- keep read-only entry local to one shell session
- expose only enabled foundation navigation routes
- make deferred destinations honest disabled no-ops
Diffstat:
2 files changed, 158 insertions(+), 0 deletions(-)
diff --git a/app/shared/src/commonMain/kotlin/org/harvestcircle/application/ShellAvailability.kt b/app/shared/src/commonMain/kotlin/org/harvestcircle/application/ShellAvailability.kt
@@ -0,0 +1,98 @@
+package org.harvestcircle.application
+
+import org.harvestcircle.navigation.AppRoute
+import org.harvestcircle.navigation.BootstrapStep
+import org.harvestcircle.navigation.NavigationIntent
+import org.harvestcircle.navigation.NavigationReducer
+import org.harvestcircle.navigation.NavigationState
+import org.harvestcircle.navigation.SettingsSection
+
+data class ShellSessionState(
+ val readOnly: Boolean = false,
+) {
+ fun enterReadOnly(): ShellSessionState = copy(readOnly = true)
+}
+
+enum class ShellDestination { Today, Explore, Activity, Network, Settings, AddFarm }
+
+data class ShellNavigationItem(
+ val destination: ShellDestination,
+ val label: String,
+ val enabled: Boolean,
+ val unavailableExplanation: String? = null,
+ val route: AppRoute? = null,
+) {
+ init {
+ require(enabled == (route != null))
+ require(enabled || !unavailableExplanation.isNullOrBlank())
+ }
+}
+
+val shellNavigationItems: List<ShellNavigationItem> =
+ listOf(
+ ShellNavigationItem(ShellDestination.Today, "Today", true, route = AppRoute.PersonalToday),
+ unavailable(ShellDestination.Explore, "Explore"),
+ unavailable(ShellDestination.Activity, "Activity"),
+ ShellNavigationItem(ShellDestination.Network, "Network", true, route = AppRoute.Network),
+ ShellNavigationItem(
+ ShellDestination.Settings,
+ "Settings",
+ true,
+ route = AppRoute.Settings(SettingsSection.Appearance),
+ ),
+ unavailable(ShellDestination.AddFarm, "Add farm"),
+ )
+
+sealed interface ShellRoot {
+ data class LifecycleCanvas(
+ val route: HarvestCircleRoute,
+ ) : ShellRoot
+
+ data class BootstrapCanvas(
+ val step: BootstrapStep,
+ ) : ShellRoot
+
+ data class Dashboard(
+ val navigation: NavigationState,
+ ) : ShellRoot
+}
+
+fun deriveShellRoot(
+ presenterState: HarvestCirclePresenterState,
+ session: ShellSessionState,
+): ShellRoot =
+ when (presenterState.route) {
+ HarvestCircleRoute.ACTIVE_IDENTITY -> ShellRoot.Dashboard(NavigationState(AppRoute.PersonalToday))
+ HarvestCircleRoute.IDENTITIES ->
+ if (session.readOnly) {
+ ShellRoot.Dashboard(NavigationState(AppRoute.PersonalToday))
+ } else {
+ val step =
+ if (presenterState.snapshot.identities.isEmpty()) {
+ BootstrapStep.Welcome
+ } else {
+ BootstrapStep.IdentityChooser
+ }
+ ShellRoot.BootstrapCanvas(step)
+ }
+ else -> ShellRoot.LifecycleCanvas(presenterState.route)
+ }
+
+fun activateShellDestination(
+ state: NavigationState,
+ destination: ShellDestination,
+): NavigationState {
+ val item = shellNavigationItems.single { it.destination == destination }
+ return item.route?.let { NavigationReducer.reduce(state, NavigationIntent.Navigate(it)) } ?: state
+}
+
+private fun unavailable(
+ destination: ShellDestination,
+ label: String,
+): ShellNavigationItem =
+ ShellNavigationItem(
+ destination = destination,
+ label = label,
+ enabled = false,
+ unavailableExplanation = "Available after collective contracts are implemented.",
+ )
diff --git a/app/shared/src/commonTest/kotlin/org/harvestcircle/application/ShellAvailabilityTest.kt b/app/shared/src/commonTest/kotlin/org/harvestcircle/application/ShellAvailabilityTest.kt
@@ -0,0 +1,60 @@
+package org.harvestcircle.application
+
+import org.harvestcircle.navigation.AppRoute
+import org.harvestcircle.navigation.BootstrapStep
+import org.harvestcircle.navigation.NavigationState
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertFalse
+import kotlin.test.assertSame
+import kotlin.test.assertTrue
+
+class ShellAvailabilityTest {
+ @Test
+ fun nativeRootAndSessionDeriveTruthfulFoundationRoutes() {
+ val signedOut = presenterState(HarvestCircleRoute.IDENTITIES)
+ assertEquals(ShellRoot.BootstrapCanvas(BootstrapStep.Welcome), deriveShellRoot(signedOut, ShellSessionState()))
+ val readOnly = deriveShellRoot(signedOut, ShellSessionState().enterReadOnly()) as ShellRoot.Dashboard
+ assertEquals(AppRoute.PersonalToday, readOnly.navigation.current)
+ val active = deriveShellRoot(presenterState(HarvestCircleRoute.ACTIVE_IDENTITY), ShellSessionState())
+ assertTrue(active is ShellRoot.Dashboard)
+ assertTrue(deriveShellRoot(presenterState(HarvestCircleRoute.FATAL), ShellSessionState()) is ShellRoot.LifecycleCanvas)
+ }
+
+ @Test
+ fun readOnlyIsSessionLocalAndRestartDefaultsToBootstrap() {
+ assertTrue(ShellSessionState().enterReadOnly().readOnly)
+ assertFalse(ShellSessionState().readOnly)
+ val restarted = deriveShellRoot(presenterState(HarvestCircleRoute.IDENTITIES), ShellSessionState())
+ assertTrue(restarted is ShellRoot.BootstrapCanvas)
+ }
+
+ @Test
+ fun disabledFeaturesCannotDispatch() {
+ val state = NavigationState(AppRoute.PersonalToday)
+ assertSame(state, activateShellDestination(state, ShellDestination.Explore))
+ assertSame(state, activateShellDestination(state, ShellDestination.Activity))
+ assertSame(state, activateShellDestination(state, ShellDestination.AddFarm))
+ assertEquals(AppRoute.Network, activateShellDestination(state, ShellDestination.Network).current)
+ assertTrue(shellNavigationItems.filterNot(ShellNavigationItem::enabled).all { it.route == null })
+ }
+}
+
+private fun presenterState(route: HarvestCircleRoute): HarvestCirclePresenterState =
+ HarvestCirclePresenterState(
+ snapshot =
+ ApplicationSnapshot(
+ revision = SnapshotRevision(1UL),
+ lifecycle = ApplicationLifecycle.Ready,
+ lifecycleProblem = null,
+ configuredRelays = emptyList(),
+ identities = emptyList(),
+ selectedIdentityId = null,
+ session = SessionLifecycle.SignedOut,
+ sessionSubjectIdentityId = null,
+ sessionProblem = null,
+ activeIdentity = null,
+ recoverableProblem = null,
+ ),
+ route = route,
+ )