app

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

commit 256e834ecf9fdc159a0f4cbdae0eaf82da41f606
parent bf43951acb2c9490dc29f3933f3c58ed46bb393c
Author: triesap <tyson@radroots.org>
Date:   Tue, 11 Aug 2026 16:36:50 +0000

application: add the HarvestCircle shell presenter

- compose existing identity presentation without native duplication
- derive lifecycle bootstrap and dashboard roots
- own only navigation appearance overlays and read-only state
- retain supplied build and snapshot presentation data

Diffstat:
Mapp/shared/src/commonMain/kotlin/org/harvestcircle/application/HarvestCirclePresenter.kt | 6+++---
Aapp/shared/src/commonMain/kotlin/org/harvestcircle/application/HarvestCircleShellPresenter.kt | 125+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aapp/shared/src/commonTest/kotlin/org/harvestcircle/application/HarvestCircleShellPresenterTest.kt | 80+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
3 files changed, 208 insertions(+), 3 deletions(-)

diff --git a/app/shared/src/commonMain/kotlin/org/harvestcircle/application/HarvestCirclePresenter.kt b/app/shared/src/commonMain/kotlin/org/harvestcircle/application/HarvestCirclePresenter.kt @@ -21,7 +21,7 @@ class HarvestCirclePresenter( private val scope: CoroutineScope, private val clock: ApplicationClock, private val operationIds: OperationIdSource, -) { +) : IdentityPresentationPort { private val mutableState = MutableStateFlow(HarvestCirclePresenterState(runtime.currentSnapshot())) private val mutableEffects = MutableSharedFlow<HarvestCircleEffect>(extraBufferCapacity = EFFECT_BUFFER_CAPACITY) private var subscriptionJob: Job? = null @@ -33,7 +33,7 @@ class HarvestCirclePresenter( private var closeReceipt: ShutdownReceipt? = null private var closed = false - val state: StateFlow<HarvestCirclePresenterState> = mutableState.asStateFlow() + override val state: StateFlow<HarvestCirclePresenterState> = mutableState.asStateFlow() val effects: SharedFlow<HarvestCircleEffect> = mutableEffects.asSharedFlow() init { @@ -52,7 +52,7 @@ class HarvestCirclePresenter( } } - fun dispatch(intent: HarvestCircleIntent) { + override fun dispatch(intent: HarvestCircleIntent) { when (intent) { is HarvestCircleIntent.EditImportDraft -> editImportDraft(intent.value) HarvestCircleIntent.ChooseCreateIdentity -> updateState { copy(identityEntryMode = IdentityEntryMode.CREATE, problem = null) } diff --git a/app/shared/src/commonMain/kotlin/org/harvestcircle/application/HarvestCircleShellPresenter.kt b/app/shared/src/commonMain/kotlin/org/harvestcircle/application/HarvestCircleShellPresenter.kt @@ -0,0 +1,125 @@ +package org.harvestcircle.application + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.launch +import org.harvestcircle.design.AppearanceState +import org.harvestcircle.design.MotionPreference +import org.harvestcircle.design.TextSizePreference +import org.harvestcircle.design.ThemePreference +import org.harvestcircle.navigation.AppRoute +import org.harvestcircle.navigation.NavigationIntent +import org.harvestcircle.navigation.NavigationReducer +import org.harvestcircle.navigation.NavigationState + +interface IdentityPresentationPort { + val state: StateFlow<HarvestCirclePresenterState> + + fun dispatch(intent: HarvestCircleIntent) +} + +data class HarvestCircleShellState( + val identity: HarvestCirclePresenterState, + val buildInfo: BuildInfo, + val session: ShellSessionState = ShellSessionState(), + val root: ShellRoot = deriveShellRoot(identity, session), + val appearance: AppearanceState = AppearanceState(), + val overlays: OverlayState = OverlayState(), +) + +sealed interface HarvestCircleShellIntent { + data class Identity( + val intent: HarvestCircleIntent, + ) : HarvestCircleShellIntent + + data object EnterReadOnly : HarvestCircleShellIntent + + data class Navigate( + val destination: ShellDestination, + ) : HarvestCircleShellIntent + + data class Navigation( + val intent: NavigationIntent, + ) : HarvestCircleShellIntent + + data class Overlay( + val intent: OverlayIntent, + ) : HarvestCircleShellIntent + + data class SetTheme( + val theme: ThemePreference, + ) : HarvestCircleShellIntent + + data class SetTextSize( + val textSize: TextSizePreference, + ) : HarvestCircleShellIntent + + data class SetMotion( + val motion: MotionPreference, + ) : HarvestCircleShellIntent +} + +class HarvestCircleShellPresenter( + private val identityPresenter: IdentityPresentationPort, + buildInfo: BuildInfo, + scope: CoroutineScope, +) { + private val mutableState = MutableStateFlow(HarvestCircleShellState(identityPresenter.state.value, buildInfo)) + val state: StateFlow<HarvestCircleShellState> = mutableState.asStateFlow() + private val observation: Job = + scope.launch { + identityPresenter.state.collect { identity -> updateIdentity(identity) } + } + + fun dispatch(intent: HarvestCircleShellIntent) { + when (intent) { + is HarvestCircleShellIntent.Identity -> identityPresenter.dispatch(intent.intent) + HarvestCircleShellIntent.EnterReadOnly -> updateSession(mutableState.value.session.enterReadOnly()) + is HarvestCircleShellIntent.Navigate -> updateNavigation { activateShellDestination(it, intent.destination) } + is HarvestCircleShellIntent.Navigation -> updateNavigation { state -> NavigationReducer.reduce(state, intent.intent) } + is HarvestCircleShellIntent.Overlay -> mutate { copy(overlays = OverlayReducer.reduce(overlays, intent.intent)) } + is HarvestCircleShellIntent.SetTheme -> mutate { copy(appearance = appearance.copy(theme = intent.theme)) } + is HarvestCircleShellIntent.SetTextSize -> mutate { copy(appearance = appearance.copy(textSize = intent.textSize)) } + is HarvestCircleShellIntent.SetMotion -> mutate { copy(appearance = appearance.copy(motion = intent.motion)) } + } + } + + fun close() { + observation.cancel() + } + + private fun updateIdentity(identity: HarvestCirclePresenterState) { + mutate { + val derived = deriveShellRoot(identity, session) + val retained = + if (derived is ShellRoot.Dashboard && root is ShellRoot.Dashboard) { + derived.copy(navigation = root.navigation) + } else { + derived + } + copy(identity = identity, root = retained) + } + } + + private fun updateSession(session: ShellSessionState) { + mutate { copy(session = session, root = deriveShellRoot(identity, session)) } + } + + private fun updateNavigation(block: (NavigationState) -> NavigationState) { + mutate { + val dashboard = root as? ShellRoot.Dashboard ?: return@mutate this + copy(root = dashboard.copy(navigation = block(dashboard.navigation))) + } + } + + private fun mutate(block: HarvestCircleShellState.() -> HarvestCircleShellState) { + mutableState.value = mutableState.value.block() + } +} + +val HarvestCircleShellState.currentRoute: AppRoute? + get() = (root as? ShellRoot.Dashboard)?.navigation?.current diff --git a/app/shared/src/commonTest/kotlin/org/harvestcircle/application/HarvestCircleShellPresenterTest.kt b/app/shared/src/commonTest/kotlin/org/harvestcircle/application/HarvestCircleShellPresenterTest.kt @@ -0,0 +1,80 @@ +package org.harvestcircle.application + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.harvestcircle.design.MotionPreference +import org.harvestcircle.design.ThemePreference +import org.harvestcircle.navigation.AppRoute +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +@OptIn(ExperimentalCoroutinesApi::class) +class HarvestCircleShellPresenterTest { + @Test + fun shellComposesRootNavigationAppearanceAndOverlays() = + runTest { + val identity = FakeIdentityPresentation(presenterState(HarvestCircleRoute.IDENTITIES)) + val presenter = HarvestCircleShellPresenter(identity, BuildInfo.unknown(), this) + runCurrent() + assertTrue(presenter.state.value.root is ShellRoot.BootstrapCanvas) + + presenter.dispatch(HarvestCircleShellIntent.EnterReadOnly) + presenter.dispatch(HarvestCircleShellIntent.Navigate(ShellDestination.Network)) + presenter.dispatch(HarvestCircleShellIntent.SetTheme(ThemePreference.Dark)) + presenter.dispatch(HarvestCircleShellIntent.SetMotion(MotionPreference.Reduced)) + presenter.dispatch( + HarvestCircleShellIntent.Overlay( + OverlayIntent.Open(FoundationOverlay.SyncStatus(org.harvestcircle.ui.shell.SyncStatusLabel.Degraded)), + ), + ) + + assertEquals(AppRoute.Network, presenter.state.value.currentRoute) + assertEquals(ThemePreference.Dark, presenter.state.value.appearance.theme) + assertTrue(presenter.state.value.overlays.current is FoundationOverlay.SyncStatus) + presenter.close() + } + + @Test + fun identityCommandsRemainOwnedByTheExistingPresenter() = + runTest { + val identity = FakeIdentityPresentation(presenterState(HarvestCircleRoute.IDENTITIES)) + val presenter = HarvestCircleShellPresenter(identity, BuildInfo.unknown(), this) + presenter.dispatch(HarvestCircleShellIntent.Identity(HarvestCircleIntent.ChooseCreateIdentity)) + assertEquals(1, identity.intents.size) + assertTrue(identity.intents.single() === HarvestCircleIntent.ChooseCreateIdentity) + presenter.close() + } +} + +private class FakeIdentityPresentation( + initial: HarvestCirclePresenterState, +) : IdentityPresentationPort { + override val state = MutableStateFlow(initial) + val intents = mutableListOf<HarvestCircleIntent>() + + override fun dispatch(intent: HarvestCircleIntent) { + intents += intent + } +} + +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, + )