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