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:
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)
+ }
+}