app

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

commit 33685e3a1e8352eb859bc0007f48a5f5760d81a6
parent e3efd8a0d875382b5f8b771530dfe1ec0d60cfa7
Author: triesap <tyson@radroots.org>
Date:   Tue, 11 Aug 2026 21:17:42 +0000

navigation: align shell routes with the registry

- Expose one ScreenKey from every executable application route.
- Derive shell navigation state and availability from registry descriptors.
- Keep Settings sections nested and out of global route history.
- Separate the disabled add-farm action from screen navigation.

Diffstat:
Mapp/shared/src/commonMain/kotlin/org/harvestcircle/application/HarvestCircleShellPresenter.kt | 5+++--
Mapp/shared/src/commonMain/kotlin/org/harvestcircle/application/ShellAvailability.kt | 79+++++++++++++++++++++++++++++++++++++++++++++++++++----------------------------
Mapp/shared/src/commonMain/kotlin/org/harvestcircle/application/ShellReducer.kt | 5+++--
Mapp/shared/src/commonMain/kotlin/org/harvestcircle/navigation/Navigation.kt | 101++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------------
Mapp/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/HarvestCircleShell.kt | 25+++++++++----------------
Mapp/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/WorkspaceSidebar.kt | 36+++++++++++++++++++++++++++++-------
Mapp/shared/src/commonTest/kotlin/org/harvestcircle/application/HarvestCircleShellPresenterTest.kt | 5+++--
Mapp/shared/src/commonTest/kotlin/org/harvestcircle/application/ShellAvailabilityTest.kt | 10++++++----
Mapp/shared/src/commonTest/kotlin/org/harvestcircle/application/ShellReducerTest.kt | 5+++--
Mapp/shared/src/commonTest/kotlin/org/harvestcircle/navigation/NavigationTest.kt | 45+++++++++++++++++++++++++++++++++++++++++++--
Mapp/shared/src/desktopTest/kotlin/org/harvestcircle/ui/shell/WorkspaceSidebarTest.kt | 10+++++-----
11 files changed, 234 insertions(+), 92 deletions(-)

diff --git a/app/shared/src/commonMain/kotlin/org/harvestcircle/application/HarvestCircleShellPresenter.kt b/app/shared/src/commonMain/kotlin/org/harvestcircle/application/HarvestCircleShellPresenter.kt @@ -14,6 +14,7 @@ import org.harvestcircle.design.TextSizePreference import org.harvestcircle.design.ThemePreference import org.harvestcircle.navigation.AppRoute import org.harvestcircle.navigation.NavigationIntent +import org.harvestcircle.product.ScreenKey interface IdentityPresentationPort { val state: StateFlow<HarvestCirclePresenterState> @@ -39,7 +40,7 @@ sealed interface HarvestCircleShellIntent { data object EnterReadOnly : HarvestCircleShellIntent data class Navigate( - val destination: ShellDestination, + val screenKey: ScreenKey, ) : HarvestCircleShellIntent data class Navigation( @@ -79,7 +80,7 @@ class HarvestCircleShellPresenter( when (intent) { is HarvestCircleShellIntent.Identity -> identityPresenter.dispatch(intent.intent) HarvestCircleShellIntent.EnterReadOnly -> reduce(ShellEvent.EnterReadOnly) - is HarvestCircleShellIntent.Navigate -> reduce(ShellEvent.Navigate(intent.destination)) + is HarvestCircleShellIntent.Navigate -> reduce(ShellEvent.Navigate(intent.screenKey)) is HarvestCircleShellIntent.Navigation -> reduce(ShellEvent.Navigation(intent.intent)) is HarvestCircleShellIntent.Overlay -> reduce(ShellEvent.Overlay(intent.intent)) is HarvestCircleShellIntent.SetTheme -> reduce(ShellEvent.SetTheme(intent.theme)) diff --git a/app/shared/src/commonMain/kotlin/org/harvestcircle/application/ShellAvailability.kt b/app/shared/src/commonMain/kotlin/org/harvestcircle/application/ShellAvailability.kt @@ -5,7 +5,10 @@ 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 +import org.harvestcircle.navigation.toExecutableRoute +import org.harvestcircle.product.FeatureAvailability +import org.harvestcircle.product.NavigationKind +import org.harvestcircle.product.ScreenKey data class ShellSessionState( val readOnly: Boolean = false, @@ -29,10 +32,8 @@ sealed interface LocalUsability { data object Unusable : LocalUsability } -enum class ShellDestination { Today, Explore, Activity, Network, Settings, AddFarm } - data class ShellNavigationItem( - val destination: ShellDestination, + val screenKey: ScreenKey, val label: String, val enabled: Boolean, val unavailableExplanation: String? = null, @@ -44,19 +45,30 @@ data class ShellNavigationItem( } } -val shellNavigationItems: List<ShellNavigationItem> = +private val PERMANENT_SHELL_SCREEN_ORDER = 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 a farm workspace"), + ScreenKey.PersonalToday, + ScreenKey.Explore, + ScreenKey.Activity, + ScreenKey.Network, + ).also { keys -> require(keys.all { it.descriptor.navigation == NavigationKind.Permanent }) } + +val shellNavigationItems: List<ShellNavigationItem> = + PERMANENT_SHELL_SCREEN_ORDER.map(::navigationItem) + +val shellSettingsItem: ShellNavigationItem = navigationItem(ScreenKey.Settings) + +data class ShellWorkspaceAction( + val label: String, + val enabled: Boolean, + val unavailableExplanation: String, +) + +val addFarmWorkspaceAction = + ShellWorkspaceAction( + label = "Add a farm workspace", + enabled = false, + unavailableExplanation = "Available after collective contracts are implemented.", ) sealed interface ShellRoot { @@ -143,21 +155,32 @@ private val USABLE_DEGRADATION_CATEGORIES = ApplicationErrorCategory.Credential, ) -fun activateShellDestination( +fun activateShellScreen( state: NavigationState, - destination: ShellDestination, + screenKey: ScreenKey, ): NavigationState { - val item = shellNavigationItems.single { it.destination == destination } + val item = (shellNavigationItems + shellSettingsItem).singleOrNull { it.screenKey == screenKey } ?: return state 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.", +private fun navigationItem(screenKey: ScreenKey): ShellNavigationItem { + val route = screenKey.toExecutableRoute() + val enabled = screenKey.descriptor.availability == FeatureAvailability.Foundation && route != null + return ShellNavigationItem( + screenKey = screenKey, + label = screenKey.label(), + enabled = enabled, + unavailableExplanation = if (enabled) null else "Available after collective contracts are implemented.", + route = route.takeIf { enabled }, ) +} + +private fun ScreenKey.label(): String = + when (this) { + ScreenKey.PersonalToday -> "Today" + ScreenKey.Explore -> "Explore" + ScreenKey.Activity -> "Activity" + ScreenKey.Network -> "Network" + ScreenKey.Settings -> "Settings" + else -> error("Screen is not presented in shell navigation: ${descriptor.externalKey}") + } diff --git a/app/shared/src/commonMain/kotlin/org/harvestcircle/application/ShellReducer.kt b/app/shared/src/commonMain/kotlin/org/harvestcircle/application/ShellReducer.kt @@ -5,6 +5,7 @@ import org.harvestcircle.design.TextSizePreference import org.harvestcircle.design.ThemePreference import org.harvestcircle.navigation.NavigationIntent import org.harvestcircle.navigation.NavigationReducer +import org.harvestcircle.product.ScreenKey sealed interface ShellEvent { data class IdentityObserved( @@ -14,7 +15,7 @@ sealed interface ShellEvent { data object EnterReadOnly : ShellEvent data class Navigate( - val destination: ShellDestination, + val screenKey: ScreenKey, ) : ShellEvent data class Navigation( @@ -46,7 +47,7 @@ object ShellReducer { when (event) { is ShellEvent.IdentityObserved -> observeIdentity(state, event.identity) ShellEvent.EnterReadOnly -> updateSession(state, state.session.enterReadOnly()) - is ShellEvent.Navigate -> updateNavigation(state) { activateShellDestination(it, event.destination) } + is ShellEvent.Navigate -> updateNavigation(state) { activateShellScreen(it, event.screenKey) } is ShellEvent.Navigation -> updateNavigation(state, event.intent) is ShellEvent.Overlay -> state.copy(overlays = OverlayReducer.reduce(state.overlays, event.intent)) is ShellEvent.SetTheme -> state.copy(appearance = state.appearance.copy(theme = event.theme)) diff --git a/app/shared/src/commonMain/kotlin/org/harvestcircle/navigation/Navigation.kt b/app/shared/src/commonMain/kotlin/org/harvestcircle/navigation/Navigation.kt @@ -1,5 +1,6 @@ package org.harvestcircle.navigation +import org.harvestcircle.product.ScreenKey import org.harvestcircle.product.WorkspaceKind enum class BootstrapStep { @@ -14,30 +15,44 @@ enum class BootstrapStep { enum class SettingsSection { Appearance, Project } sealed interface AppRoute { + val screenKey: ScreenKey + data class Bootstrap( val step: BootstrapStep, - ) : AppRoute + ) : AppRoute { + override val screenKey: ScreenKey = ScreenKey.Bootstrap + } - data object PersonalToday : AppRoute + data object PersonalToday : AppRoute { + override val screenKey: ScreenKey = ScreenKey.PersonalToday + } - data object Network : AppRoute + data object Network : AppRoute { + override val screenKey: ScreenKey = ScreenKey.Network + } - data class Settings( - val section: SettingsSection, - ) : AppRoute + data object Settings : AppRoute { + override val screenKey: ScreenKey = ScreenKey.Settings + } } +data class SettingsUiState( + val section: SettingsSection = SettingsSection.Appearance, +) + 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, + val settings: SettingsUiState = SettingsUiState(), ) { init { require(backStack.size <= HISTORY_LIMIT && forwardStack.size <= HISTORY_LIMIT) - require(backStack.none { it is AppRoute.Bootstrap }) - require(forwardStack.none { it is AppRoute.Bootstrap }) + require(backStack.none(AppRoute::isTransient)) + require(forwardStack.none(AppRoute::isTransient)) + require(settingsReturnRoute !is AppRoute.Bootstrap && settingsReturnRoute != AppRoute.Settings) } } @@ -50,6 +65,10 @@ sealed interface NavigationIntent { val step: BootstrapStep, ) : NavigationIntent + data class SelectSettingsSection( + val section: SettingsSection, + ) : NavigationIntent + data object Back : NavigationIntent data object Forward : NavigationIntent @@ -65,9 +84,10 @@ object NavigationReducer { when (intent) { is NavigationIntent.Navigate -> navigate(state, intent.route) is NavigationIntent.SelectBootstrapStep -> selectBootstrapStep(state, intent.step) + is NavigationIntent.SelectSettingsSection -> selectSettingsSection(state, intent.section) NavigationIntent.Back -> moveBack(state) NavigationIntent.Forward -> moveForward(state) - NavigationIntent.ReturnFromSettings -> state.settingsReturnRoute?.let { navigate(state, it) } ?: state + NavigationIntent.ReturnFromSettings -> returnFromSettings(state) } private fun navigate( @@ -75,19 +95,22 @@ object NavigationReducer { route: AppRoute, ): NavigationState { if (route is AppRoute.Bootstrap || route == state.current) return state - val returnRoute = + val enteringSettings = route == AppRoute.Settings + val leavingSettings = state.current == AppRoute.Settings + val prior = state.current.takeUnless(AppRoute::isTransient) + val existingRouteIndex = state.backStack.indexOfLast { it == route } + val history = when { - route is AppRoute.Settings && state.current !is AppRoute.Settings -> state.current - route !is AppRoute.Settings -> null - else -> state.settingsReturnRoute + leavingSettings && existingRouteIndex >= 0 -> state.backStack.take(existingRouteIndex) + leavingSettings -> state.backStack + else -> prior?.let { (state.backStack + it).takeLast(HISTORY_LIMIT) }.orEmpty() } - val prior = state.current.takeUnless { it is AppRoute.Bootstrap } return state.copy( current = route, - backStack = prior?.let { (state.backStack + it).takeLast(HISTORY_LIMIT) }.orEmpty(), + backStack = history, forwardStack = emptyList(), workspace = route.workspace(), - settingsReturnRoute = returnRoute, + settingsReturnRoute = if (enteringSettings) state.current else null, ) } @@ -101,14 +124,25 @@ object NavigationReducer { state } + private fun selectSettingsSection( + state: NavigationState, + section: SettingsSection, + ): NavigationState = + if (state.current == AppRoute.Settings) { + state.copy(settings = state.settings.copy(section = section)) + } else { + state + } + private fun moveBack(state: NavigationState): NavigationState { + if (state.current == AppRoute.Settings) return returnFromSettings(state) 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, + settingsReturnRoute = null, ) } @@ -119,16 +153,39 @@ object NavigationReducer { 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, + settingsReturnRoute = if (destination == AppRoute.Settings) state.current else null, + ) + } + + private fun returnFromSettings(state: NavigationState): NavigationState { + if (state.current != AppRoute.Settings) return state + val destination = state.settingsReturnRoute ?: state.backStack.lastOrNull() ?: return state + val backStack = + if (state.backStack.lastOrNull() == destination) { + state.backStack.dropLast(1) + } else { + state.backStack + } + return state.copy( + current = destination, + backStack = backStack, + forwardStack = state.forwardStack, + workspace = destination.workspace(), + settingsReturnRoute = null, ) } } -private fun AppRoute.workspace(): WorkspaceKind = +fun ScreenKey.toExecutableRoute(): AppRoute? = when (this) { - is AppRoute.Bootstrap -> WorkspaceKind.System - AppRoute.PersonalToday -> WorkspaceKind.Personal - AppRoute.Network, is AppRoute.Settings -> WorkspaceKind.Shared + ScreenKey.PersonalToday -> AppRoute.PersonalToday + ScreenKey.Network -> AppRoute.Network + ScreenKey.Settings -> AppRoute.Settings + else -> null } +private fun AppRoute.workspace(): WorkspaceKind = screenKey.descriptor.workspace + +private fun AppRoute.isTransient(): Boolean = this is AppRoute.Bootstrap || this == AppRoute.Settings + private const val HISTORY_LIMIT = 32 diff --git a/app/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/HarvestCircleShell.kt b/app/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/HarvestCircleShell.kt @@ -17,7 +17,6 @@ import org.harvestcircle.application.HarvestCircleIntent import org.harvestcircle.application.HarvestCircleShellIntent import org.harvestcircle.application.HarvestCircleShellState import org.harvestcircle.application.OverlayIntent -import org.harvestcircle.application.ShellDestination import org.harvestcircle.application.ShellRoot import org.harvestcircle.application.SignerAvailability import org.harvestcircle.identities.ui.HarvestCirclePlatformActions @@ -27,6 +26,7 @@ import org.harvestcircle.identities.ui.toUiModel import org.harvestcircle.navigation.AppRoute import org.harvestcircle.navigation.BootstrapStep import org.harvestcircle.navigation.NavigationIntent +import org.harvestcircle.product.ScreenKey @Composable fun HarvestCircleShell( @@ -152,13 +152,6 @@ private fun DashboardRoot( dispatch: (HarvestCircleShellIntent) -> Unit, ) { val route = root.navigation.current - val destination = - when (route) { - AppRoute.PersonalToday -> ShellDestination.Today - AppRoute.Network -> ShellDestination.Network - is AppRoute.Settings -> ShellDestination.Settings - is AppRoute.Bootstrap -> ShellDestination.Today - } DashboardScaffold( windowWidthDp = ShellDimensions.PREFERRED_WINDOW_WIDTH_DP, inspectorVisible = false, @@ -174,7 +167,7 @@ private fun DashboardRoot( onIntent = { intent -> dispatchTopBar(intent, dispatch) }, ) }, - sidebar = { WorkspaceSidebar(destination) { dispatch(HarvestCircleShellIntent.Navigate(it)) } }, + sidebar = { WorkspaceSidebar(route.screenKey) { dispatch(HarvestCircleShellIntent.Navigate(it)) } }, mainHeader = { MainPanelHeader(MainPanelHeaderModel(title = route.title())) }, mainBody = { RouteFocusTarget(route.toString(), "${route.title()} main content") { @@ -196,9 +189,9 @@ private fun DashboardRoot( refreshProfile = identityActions.refreshActiveProfile, signOut = identityActions.signOut, ) - is AppRoute.Settings -> + AppRoute.Settings -> FoundationSettingsScreen( - section = route.section, + section = root.navigation.settings.section, appearance = state.appearance, buildInfo = state.buildInfo, actions = @@ -206,7 +199,7 @@ private fun DashboardRoot( selectSection = { dispatch( HarvestCircleShellIntent.Navigation( - NavigationIntent.Navigate(AppRoute.Settings(it)), + NavigationIntent.SelectSettingsSection(it), ), ) }, @@ -233,8 +226,8 @@ private fun dispatchShortcut( ShellShortcut.Forward -> HarvestCircleShellIntent.Navigation(NavigationIntent.Forward) ShellShortcut.OpenNostrReference -> HarvestCircleShellIntent.Overlay(OverlayIntent.Open(FoundationOverlay.OpenNostrReference())) - ShellShortcut.Today -> HarvestCircleShellIntent.Navigate(ShellDestination.Today) - ShellShortcut.Settings -> HarvestCircleShellIntent.Navigate(ShellDestination.Settings) + ShellShortcut.Today -> HarvestCircleShellIntent.Navigate(ScreenKey.PersonalToday) + ShellShortcut.Settings -> HarvestCircleShellIntent.Navigate(ScreenKey.Settings) ShellShortcut.CloseOverlay -> HarvestCircleShellIntent.Overlay(OverlayIntent.Escape) } dispatch(intent) @@ -263,7 +256,7 @@ private fun dispatchTopBar( GlobalTopBarIntent.ShowSignerStatus -> HarvestCircleShellIntent.Overlay(OverlayIntent.Open(FoundationOverlay.SignerStatus(SignerStatusLabel.SignedOut))) GlobalTopBarIntent.OpenApplicationMenu -> - HarvestCircleShellIntent.Navigate(ShellDestination.Settings) + HarvestCircleShellIntent.Navigate(ScreenKey.Settings) } dispatch(shellIntent) } @@ -293,6 +286,6 @@ private fun AppRoute.title(): String = when (this) { AppRoute.PersonalToday -> "Today" AppRoute.Network -> "Network" - is AppRoute.Settings -> "Settings" + AppRoute.Settings -> "Settings" is AppRoute.Bootstrap -> "HarvestCircle" } diff --git a/app/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/WorkspaceSidebar.kt b/app/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/WorkspaceSidebar.kt @@ -16,21 +16,25 @@ import androidx.compose.ui.semantics.role import androidx.compose.ui.semantics.selected import androidx.compose.ui.semantics.semantics import androidx.compose.ui.unit.dp -import org.harvestcircle.application.ShellDestination import org.harvestcircle.application.ShellNavigationItem +import org.harvestcircle.application.addFarmWorkspaceAction import org.harvestcircle.application.shellNavigationItems +import org.harvestcircle.application.shellSettingsItem +import org.harvestcircle.product.ScreenKey @Composable fun WorkspaceSidebar( - selected: ShellDestination, - onDestination: (ShellDestination) -> Unit, + selected: ScreenKey, + onScreen: (ScreenKey) -> Unit, ) { Column(Modifier.testTag("workspace-sidebar")) { BasicText("Workspace", Modifier.testTag("workspace-label")) BasicText("Personal", Modifier.testTag("workspace-personal")) shellNavigationItems.forEach { item -> - SidebarItem(item, selected == item.destination, onDestination) + SidebarItem(item, selected == item.screenKey, onScreen) } + DisabledWorkspaceAction() + SidebarItem(shellSettingsItem, selected == shellSettingsItem.screenKey, onScreen) } } @@ -38,7 +42,7 @@ fun WorkspaceSidebar( private fun SidebarItem( item: ShellNavigationItem, selected: Boolean, - onDestination: (ShellDestination) -> Unit, + onScreen: (ScreenKey) -> Unit, ) { val description = if (item.enabled) { @@ -59,11 +63,29 @@ private fun SidebarItem( if (!item.enabled) disabled() }.then( if (item.enabled) { - Modifier.clickable(role = Role.Tab) { onDestination(item.destination) } + Modifier.clickable(role = Role.Tab) { onScreen(item.screenKey) } } else { Modifier }, ).padding(horizontal = 16.dp, vertical = 12.dp) - .testTag("sidebar-${item.destination.name}"), + .testTag("sidebar-${item.screenKey.name}"), + ) +} + +@Composable +private fun DisabledWorkspaceAction() { + val action = addFarmWorkspaceAction + BasicText( + text = action.label, + modifier = + Modifier + .fillMaxWidth() + .heightIn(min = 44.dp) + .semantics { + contentDescription = "${action.label}. ${action.unavailableExplanation}" + role = Role.Button + disabled() + }.padding(horizontal = 16.dp, vertical = 12.dp) + .testTag("sidebar-add-farm"), ) } diff --git a/app/shared/src/commonTest/kotlin/org/harvestcircle/application/HarvestCircleShellPresenterTest.kt b/app/shared/src/commonTest/kotlin/org/harvestcircle/application/HarvestCircleShellPresenterTest.kt @@ -7,6 +7,7 @@ import kotlinx.coroutines.test.runTest import org.harvestcircle.design.MotionPreference import org.harvestcircle.design.ThemePreference import org.harvestcircle.navigation.AppRoute +import org.harvestcircle.product.ScreenKey import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertTrue @@ -22,7 +23,7 @@ class HarvestCircleShellPresenterTest { assertTrue(presenter.state.value.root is ShellRoot.BootstrapCanvas) presenter.dispatch(HarvestCircleShellIntent.EnterReadOnly) - presenter.dispatch(HarvestCircleShellIntent.Navigate(ShellDestination.Network)) + presenter.dispatch(HarvestCircleShellIntent.Navigate(ScreenKey.Network)) presenter.dispatch(HarvestCircleShellIntent.SetTheme(ThemePreference.Dark)) presenter.dispatch(HarvestCircleShellIntent.SetMotion(MotionPreference.Reduced)) presenter.dispatch( @@ -54,7 +55,7 @@ class HarvestCircleShellPresenterTest { val identity = FakeIdentityPresentation(activePresenterState(ApplicationLifecycle.Ready, null, 1UL)) val presenter = HarvestCircleShellPresenter(identity, BuildInfo.unknown(), this) runCurrent() - presenter.dispatch(HarvestCircleShellIntent.Navigate(ShellDestination.Network)) + presenter.dispatch(HarvestCircleShellIntent.Navigate(ScreenKey.Network)) identity.state.value = activePresenterState( diff --git a/app/shared/src/commonTest/kotlin/org/harvestcircle/application/ShellAvailabilityTest.kt b/app/shared/src/commonTest/kotlin/org/harvestcircle/application/ShellAvailabilityTest.kt @@ -3,6 +3,7 @@ package org.harvestcircle.application import org.harvestcircle.navigation.AppRoute import org.harvestcircle.navigation.BootstrapStep import org.harvestcircle.navigation.NavigationState +import org.harvestcircle.product.ScreenKey import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse @@ -98,11 +99,12 @@ class ShellAvailabilityTest { @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) + assertSame(state, activateShellScreen(state, ScreenKey.Explore)) + assertSame(state, activateShellScreen(state, ScreenKey.Activity)) + assertSame(state, activateShellScreen(state, ScreenKey.FarmOverview)) + assertEquals(AppRoute.Network, activateShellScreen(state, ScreenKey.Network).current) assertTrue(shellNavigationItems.filterNot(ShellNavigationItem::enabled).all { it.route == null }) + assertFalse(addFarmWorkspaceAction.enabled) } } diff --git a/app/shared/src/commonTest/kotlin/org/harvestcircle/application/ShellReducerTest.kt b/app/shared/src/commonTest/kotlin/org/harvestcircle/application/ShellReducerTest.kt @@ -10,6 +10,7 @@ import org.harvestcircle.design.MotionPreference import org.harvestcircle.design.TextSizePreference import org.harvestcircle.design.ThemePreference import org.harvestcircle.navigation.AppRoute +import org.harvestcircle.product.ScreenKey import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertTrue @@ -22,7 +23,7 @@ class ShellReducerTest { val events = listOf( ShellEvent.IdentityObserved(activePresenterState(3UL)), - ShellEvent.Navigate(ShellDestination.Network), + ShellEvent.Navigate(ScreenKey.Network), ShellEvent.SetTheme(ThemePreference.Dark), ShellEvent.SetTextSize(TextSizePreference.VeryLarge), ShellEvent.SetMotion(MotionPreference.Reduced), @@ -57,7 +58,7 @@ class ShellReducerTest { runCurrent() listOf( - HarvestCircleShellIntent.Navigate(ShellDestination.Network), + HarvestCircleShellIntent.Navigate(ScreenKey.Network), HarvestCircleShellIntent.SetTheme(ThemePreference.Dark), HarvestCircleShellIntent.SetTextSize(TextSizePreference.VeryLarge), HarvestCircleShellIntent.SetMotion(MotionPreference.Reduced), diff --git a/app/shared/src/commonTest/kotlin/org/harvestcircle/navigation/NavigationTest.kt b/app/shared/src/commonTest/kotlin/org/harvestcircle/navigation/NavigationTest.kt @@ -1,5 +1,6 @@ package org.harvestcircle.navigation +import org.harvestcircle.product.ScreenKey import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertSame @@ -12,7 +13,7 @@ class NavigationTest { 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))) + val replaced = NavigationReducer.reduce(back, NavigationIntent.Navigate(AppRoute.Settings)) assertEquals(emptyList(), replaced.forwardStack) } @@ -43,7 +44,47 @@ class NavigationTest { state = NavigationReducer.reduce(state, NavigationIntent.Navigate(route)) } assertEquals(32, state.backStack.size) - state = NavigationReducer.reduce(state, NavigationIntent.Navigate(AppRoute.Settings(SettingsSection.Appearance))) + state = NavigationReducer.reduce(state, NavigationIntent.Navigate(AppRoute.Settings)) assertEquals(AppRoute.PersonalToday, NavigationReducer.reduce(state, NavigationIntent.ReturnFromSettings).current) } + + @Test + fun settingsSectionsAreHistoryNeutralAndLeavingRemovesSettings() { + val network = NavigationReducer.reduce(NavigationState(AppRoute.PersonalToday), NavigationIntent.Navigate(AppRoute.Network)) + val settings = NavigationReducer.reduce(network, NavigationIntent.Navigate(AppRoute.Settings)) + val project = + NavigationReducer.reduce( + settings, + NavigationIntent.SelectSettingsSection(SettingsSection.Project), + ) + assertEquals(SettingsSection.Project, project.settings.section) + assertEquals(settings.backStack, project.backStack) + + val returned = NavigationReducer.reduce(project, NavigationIntent.Back) + assertEquals(AppRoute.Network, returned.current) + assertEquals(listOf(AppRoute.PersonalToday), returned.backStack) + assertEquals(emptyList(), returned.forwardStack) + } + + @Test + fun routesExposeRegistryIdentityAndDeferredKeysCannotConstructRoutes() { + assertEquals(ScreenKey.Bootstrap, AppRoute.Bootstrap(BootstrapStep.Welcome).screenKey) + assertEquals(ScreenKey.PersonalToday, AppRoute.PersonalToday.screenKey) + assertEquals(ScreenKey.Network, AppRoute.Network.screenKey) + assertEquals(ScreenKey.Settings, AppRoute.Settings.screenKey) + assertEquals( + setOf(ScreenKey.PersonalToday, ScreenKey.Network, ScreenKey.Settings), + ScreenKey.entries.filter { it.toExecutableRoute() != null }.toSet(), + ) + } + + @Test + fun directNavigationAwayFromSettingsNeverAddsSettingsToHistory() { + val network = NavigationReducer.reduce(NavigationState(AppRoute.PersonalToday), NavigationIntent.Navigate(AppRoute.Network)) + val settings = NavigationReducer.reduce(network, NavigationIntent.Navigate(AppRoute.Settings)) + val today = NavigationReducer.reduce(settings, NavigationIntent.Navigate(AppRoute.PersonalToday)) + assertEquals(AppRoute.PersonalToday, today.current) + assertEquals(emptyList(), today.backStack) + assertEquals(emptyList(), today.forwardStack) + } } diff --git a/app/shared/src/desktopTest/kotlin/org/harvestcircle/ui/shell/WorkspaceSidebarTest.kt b/app/shared/src/desktopTest/kotlin/org/harvestcircle/ui/shell/WorkspaceSidebarTest.kt @@ -7,7 +7,7 @@ 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.ShellDestination +import org.harvestcircle.product.ScreenKey import kotlin.test.Test import kotlin.test.assertEquals @@ -16,15 +16,15 @@ class WorkspaceSidebarTest { @Test fun sidebarSelectsFoundationRoutesAndDisablesDeferredEntries() = runComposeUiTest { - val selected = mutableListOf<ShellDestination>() - setContent { WorkspaceSidebar(ShellDestination.Today, selected::add) } - onNodeWithTag("sidebar-Today").assertIsSelected() + val selected = mutableListOf<ScreenKey>() + setContent { WorkspaceSidebar(ScreenKey.PersonalToday, selected::add) } + onNodeWithTag("sidebar-PersonalToday").assertIsSelected() onNodeWithText("Explore").assertIsNotEnabled() onNodeWithText("Activity").assertIsNotEnabled() onNodeWithText("Add a farm workspace").assertIsNotEnabled() onNodeWithText("Explore").performClick() onNodeWithText("Network").performClick() onNodeWithText("Settings").performClick() - assertEquals(listOf(ShellDestination.Network, ShellDestination.Settings), selected) + assertEquals(listOf(ScreenKey.Network, ScreenKey.Settings), selected) } }