app

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

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:
Aapp/shared/src/commonMain/kotlin/org/harvestcircle/application/ShellAvailability.kt | 98+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aapp/shared/src/commonTest/kotlin/org/harvestcircle/application/ShellAvailabilityTest.kt | 60++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
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, + )