app

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

commit 403226dcaf1dbe08ca602e998d6250fc5174e704
parent cbc2e4671166d673c32cd598e93cdc319f0d2265
Author: triesap <tyson@radroots.org>
Date:   Tue, 11 Aug 2026 16:16:25 +0000

navigation: add typed foundation routing

- define typed bootstrap and settings destinations
- reduce bounded back and forward history purely
- keep bootstrap steps outside dashboard history
- preserve return behavior for contextual settings

Diffstat:
Aapp/shared/src/commonMain/kotlin/org/harvestcircle/navigation/Navigation.kt | 134+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aapp/shared/src/commonTest/kotlin/org/harvestcircle/navigation/NavigationTest.kt | 49+++++++++++++++++++++++++++++++++++++++++++++++++
2 files changed, 183 insertions(+), 0 deletions(-)

diff --git a/app/shared/src/commonMain/kotlin/org/harvestcircle/navigation/Navigation.kt b/app/shared/src/commonMain/kotlin/org/harvestcircle/navigation/Navigation.kt @@ -0,0 +1,134 @@ +package org.harvestcircle.navigation + +import org.harvestcircle.product.WorkspaceKind + +enum class BootstrapStep { + Welcome, + CreateIdentity, + ImportIdentity, + GeneratedRecovery, + IdentityChooser, + ActivationProgress, +} + +enum class SettingsSection { Appearance, Project } + +sealed interface AppRoute { + data class Bootstrap( + val step: BootstrapStep, + ) : AppRoute + + data object PersonalToday : AppRoute + + data object Network : AppRoute + + data class Settings( + val section: SettingsSection, + ) : AppRoute +} + +data class NavigationState( + val current: AppRoute, + val backStack: List<AppRoute> = emptyList(), + val forwardStack: List<AppRoute> = emptyList(), + val workspace: WorkspaceKind = current.workspace(), + val settingsReturnRoute: AppRoute? = null, +) { + init { + require(backStack.size <= HISTORY_LIMIT && forwardStack.size <= HISTORY_LIMIT) + require(backStack.none { it is AppRoute.Bootstrap }) + require(forwardStack.none { it is AppRoute.Bootstrap }) + } +} + +sealed interface NavigationIntent { + data class Navigate( + val route: AppRoute, + ) : NavigationIntent + + data class SelectBootstrapStep( + val step: BootstrapStep, + ) : NavigationIntent + + data object Back : NavigationIntent + + data object Forward : NavigationIntent + + data object ReturnFromSettings : NavigationIntent +} + +object NavigationReducer { + fun reduce( + state: NavigationState, + intent: NavigationIntent, + ): NavigationState = + when (intent) { + is NavigationIntent.Navigate -> navigate(state, intent.route) + is NavigationIntent.SelectBootstrapStep -> selectBootstrapStep(state, intent.step) + NavigationIntent.Back -> moveBack(state) + NavigationIntent.Forward -> moveForward(state) + NavigationIntent.ReturnFromSettings -> state.settingsReturnRoute?.let { navigate(state, it) } ?: state + } + + private fun navigate( + state: NavigationState, + route: AppRoute, + ): NavigationState { + if (route is AppRoute.Bootstrap || route == state.current) return state + val returnRoute = + when { + route is AppRoute.Settings && state.current !is AppRoute.Settings -> state.current + route !is AppRoute.Settings -> null + else -> state.settingsReturnRoute + } + val prior = state.current.takeUnless { it is AppRoute.Bootstrap } + return state.copy( + current = route, + backStack = prior?.let { (state.backStack + it).takeLast(HISTORY_LIMIT) }.orEmpty(), + forwardStack = emptyList(), + workspace = route.workspace(), + settingsReturnRoute = returnRoute, + ) + } + + private fun selectBootstrapStep( + state: NavigationState, + step: BootstrapStep, + ): NavigationState = + if (state.current is AppRoute.Bootstrap) { + state.copy(current = AppRoute.Bootstrap(step), workspace = WorkspaceKind.System) + } else { + state + } + + private fun moveBack(state: NavigationState): NavigationState { + val destination = state.backStack.lastOrNull() ?: return state + return state.copy( + current = destination, + backStack = state.backStack.dropLast(1), + forwardStack = (listOf(state.current) + state.forwardStack).take(HISTORY_LIMIT), + workspace = destination.workspace(), + settingsReturnRoute = if (destination is AppRoute.Settings) state.settingsReturnRoute else null, + ) + } + + private fun moveForward(state: NavigationState): NavigationState { + val destination = state.forwardStack.firstOrNull() ?: return state + return state.copy( + current = destination, + backStack = (state.backStack + state.current).takeLast(HISTORY_LIMIT), + forwardStack = state.forwardStack.drop(1), + workspace = destination.workspace(), + settingsReturnRoute = if (destination is AppRoute.Settings) state.current else null, + ) + } +} + +private fun AppRoute.workspace(): WorkspaceKind = + when (this) { + is AppRoute.Bootstrap -> WorkspaceKind.System + AppRoute.PersonalToday -> WorkspaceKind.Personal + AppRoute.Network, is AppRoute.Settings -> WorkspaceKind.Shared + } + +private const val HISTORY_LIMIT = 32 diff --git a/app/shared/src/commonTest/kotlin/org/harvestcircle/navigation/NavigationTest.kt b/app/shared/src/commonTest/kotlin/org/harvestcircle/navigation/NavigationTest.kt @@ -0,0 +1,49 @@ +package org.harvestcircle.navigation + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertSame + +class NavigationTest { + @Test + fun navigationSupportsBackForwardAndClearsForward() { + val today = NavigationState(AppRoute.PersonalToday) + val network = NavigationReducer.reduce(today, NavigationIntent.Navigate(AppRoute.Network)) + val back = NavigationReducer.reduce(network, NavigationIntent.Back) + assertEquals(AppRoute.PersonalToday, back.current) + assertEquals(AppRoute.Network, NavigationReducer.reduce(back, NavigationIntent.Forward).current) + val replaced = NavigationReducer.reduce(back, NavigationIntent.Navigate(AppRoute.Settings(SettingsSection.Project))) + assertEquals(emptyList(), replaced.forwardStack) + } + + @Test + fun duplicateAndUnavailableBootstrapNavigationAreNoOps() { + val state = NavigationState(AppRoute.PersonalToday) + assertSame(state, NavigationReducer.reduce(state, NavigationIntent.Navigate(AppRoute.PersonalToday))) + assertSame( + state, + NavigationReducer.reduce(state, NavigationIntent.Navigate(AppRoute.Bootstrap(BootstrapStep.Welcome))), + ) + } + + @Test + fun bootstrapStepsDoNotEnterHistory() { + val state = NavigationState(AppRoute.Bootstrap(BootstrapStep.Welcome)) + val create = NavigationReducer.reduce(state, NavigationIntent.SelectBootstrapStep(BootstrapStep.CreateIdentity)) + assertEquals(AppRoute.Bootstrap(BootstrapStep.CreateIdentity), create.current) + assertEquals(emptyList(), create.backStack) + assertEquals(emptyList(), create.forwardStack) + } + + @Test + fun historyIsBoundedAndSettingsReturnsToPriorRoute() { + var state = NavigationState(AppRoute.PersonalToday) + repeat(40) { index -> + val route = if (index % 2 == 0) AppRoute.Network else AppRoute.PersonalToday + state = NavigationReducer.reduce(state, NavigationIntent.Navigate(route)) + } + assertEquals(32, state.backStack.size) + state = NavigationReducer.reduce(state, NavigationIntent.Navigate(AppRoute.Settings(SettingsSection.Appearance))) + assertEquals(AppRoute.PersonalToday, NavigationReducer.reduce(state, NavigationIntent.ReturnFromSettings).current) + } +}