app

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

commit e3efd8a0d875382b5f8b771530dfe1ec0d60cfa7
parent 799cab96994574b09b0cdd10d3444088f51e71eb
Author: triesap <tyson@radroots.org>
Date:   Tue, 11 Aug 2026 21:12:41 +0000

application: reduce shell state atomically

- Route observed identity and UI events through one pure shell reducer.
- Apply state transitions atomically with MutableStateFlow update.
- Reject stale snapshot revisions and retain independent dashboard state.
- Prove deterministic interleavings and idempotent observation closure.

Diffstat:
Mapp/shared/src/commonMain/kotlin/org/harvestcircle/application/HarvestCircleShellPresenter.kt | 65+++++++++++------------------------------------------------------
Mapp/shared/src/commonMain/kotlin/org/harvestcircle/application/ShellOverlays.kt | 13+++++++++++++
Aapp/shared/src/commonMain/kotlin/org/harvestcircle/application/ShellReducer.kt | 114+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aapp/shared/src/commonTest/kotlin/org/harvestcircle/application/ShellReducerTest.kt | 136+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
4 files changed, 274 insertions(+), 54 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 @@ -6,6 +6,7 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import org.harvestcircle.design.AppearanceState import org.harvestcircle.design.MotionPreference @@ -13,8 +14,6 @@ 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> @@ -79,13 +78,13 @@ class HarvestCircleShellPresenter( 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 -> updateNavigationIntent(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)) } + HarvestCircleShellIntent.EnterReadOnly -> reduce(ShellEvent.EnterReadOnly) + is HarvestCircleShellIntent.Navigate -> reduce(ShellEvent.Navigate(intent.destination)) + is HarvestCircleShellIntent.Navigation -> reduce(ShellEvent.Navigation(intent.intent)) + is HarvestCircleShellIntent.Overlay -> reduce(ShellEvent.Overlay(intent.intent)) + is HarvestCircleShellIntent.SetTheme -> reduce(ShellEvent.SetTheme(intent.theme)) + is HarvestCircleShellIntent.SetTextSize -> reduce(ShellEvent.SetTextSize(intent.textSize)) + is HarvestCircleShellIntent.SetMotion -> reduce(ShellEvent.SetMotion(intent.motion)) } } @@ -94,53 +93,11 @@ class HarvestCircleShellPresenter( } private fun updateIdentity(identity: HarvestCirclePresenterState) { - mutate { - val derived = deriveShellRoot(identity, session) - val retained = - when { - derived is ShellRoot.Dashboard && root is ShellRoot.Dashboard -> - derived.copy(navigation = root.navigation) - else -> derived - } - copy( - identity = identity, - localUsability = deriveLocalUsability(identity.snapshot), - 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 updateNavigationIntent(intent: NavigationIntent) { - mutate { - when (val currentRoot = root) { - is ShellRoot.Dashboard -> - copy( - root = currentRoot.copy(navigation = NavigationReducer.reduce(currentRoot.navigation, intent)), - ) - is ShellRoot.BootstrapCanvas -> - if (intent is NavigationIntent.SelectBootstrapStep) { - copy(root = currentRoot.copy(step = intent.step)) - } else { - this - } - is ShellRoot.LifecycleCanvas -> this - } - } + reduce(ShellEvent.IdentityObserved(identity)) } - private fun mutate(block: HarvestCircleShellState.() -> HarvestCircleShellState) { - mutableState.value = mutableState.value.block() + private fun reduce(event: ShellEvent) { + mutableState.update { current -> ShellReducer.reduce(current, event) } } } diff --git a/app/shared/src/commonMain/kotlin/org/harvestcircle/application/ShellOverlays.kt b/app/shared/src/commonMain/kotlin/org/harvestcircle/application/ShellOverlays.kt @@ -47,6 +47,10 @@ sealed interface OverlayIntent { data object SubmitReference : OverlayIntent + data class ApplyReferenceResult( + val result: ReferenceResult, + ) : OverlayIntent + data object Confirm : OverlayIntent data object Close : OverlayIntent @@ -64,6 +68,7 @@ object OverlayReducer { is OverlayIntent.EditReference -> state.copy(current = (state.current as? FoundationOverlay.OpenNostrReference)?.copy(input = intent.value)) OverlayIntent.SubmitReference -> submitReference(state) + is OverlayIntent.ApplyReferenceResult -> applyReferenceResult(state, intent.result) OverlayIntent.Confirm, OverlayIntent.Close, OverlayIntent.Escape -> state.copy(current = null) } @@ -71,6 +76,14 @@ object OverlayReducer { val overlay = state.current as? FoundationOverlay.OpenNostrReference ?: return state return state.copy(current = overlay.copy(result = validateNostrReference(overlay.input))) } + + private fun applyReferenceResult( + state: OverlayState, + result: ReferenceResult, + ): OverlayState { + val overlay = state.current as? FoundationOverlay.OpenNostrReference ?: return state + return state.copy(current = overlay.copy(result = result)) + } } enum class ReferenceResult( diff --git a/app/shared/src/commonMain/kotlin/org/harvestcircle/application/ShellReducer.kt b/app/shared/src/commonMain/kotlin/org/harvestcircle/application/ShellReducer.kt @@ -0,0 +1,114 @@ +package org.harvestcircle.application + +import org.harvestcircle.design.MotionPreference +import org.harvestcircle.design.TextSizePreference +import org.harvestcircle.design.ThemePreference +import org.harvestcircle.navigation.NavigationIntent +import org.harvestcircle.navigation.NavigationReducer + +sealed interface ShellEvent { + data class IdentityObserved( + val identity: HarvestCirclePresenterState, + ) : ShellEvent + + data object EnterReadOnly : ShellEvent + + data class Navigate( + val destination: ShellDestination, + ) : ShellEvent + + data class Navigation( + val intent: NavigationIntent, + ) : ShellEvent + + data class Overlay( + val intent: OverlayIntent, + ) : ShellEvent + + data class SetTheme( + val theme: ThemePreference, + ) : ShellEvent + + data class SetTextSize( + val textSize: TextSizePreference, + ) : ShellEvent + + data class SetMotion( + val motion: MotionPreference, + ) : ShellEvent +} + +object ShellReducer { + fun reduce( + state: HarvestCircleShellState, + event: ShellEvent, + ): HarvestCircleShellState = + 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.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)) + is ShellEvent.SetTextSize -> state.copy(appearance = state.appearance.copy(textSize = event.textSize)) + is ShellEvent.SetMotion -> state.copy(appearance = state.appearance.copy(motion = event.motion)) + } + + private fun observeIdentity( + state: HarvestCircleShellState, + identity: HarvestCirclePresenterState, + ): HarvestCircleShellState { + if (identity.snapshot.revision.value < state.identity.snapshot.revision.value) return state + val derived = deriveShellRoot(identity, state.session) + return state.copy( + identity = identity, + localUsability = deriveLocalUsability(identity.snapshot), + root = retainDashboardNavigation(state.root, derived), + ) + } + + private fun updateSession( + state: HarvestCircleShellState, + session: ShellSessionState, + ): HarvestCircleShellState { + val derived = deriveShellRoot(state.identity, session) + return state.copy( + session = session, + root = retainDashboardNavigation(state.root, derived), + ) + } + + private fun updateNavigation( + state: HarvestCircleShellState, + block: (org.harvestcircle.navigation.NavigationState) -> org.harvestcircle.navigation.NavigationState, + ): HarvestCircleShellState { + val dashboard = state.root as? ShellRoot.Dashboard ?: return state + return state.copy(root = dashboard.copy(navigation = block(dashboard.navigation))) + } + + private fun updateNavigation( + state: HarvestCircleShellState, + intent: NavigationIntent, + ): HarvestCircleShellState = + when (val root = state.root) { + is ShellRoot.Dashboard -> + state.copy(root = root.copy(navigation = NavigationReducer.reduce(root.navigation, intent))) + is ShellRoot.BootstrapCanvas -> + if (intent is NavigationIntent.SelectBootstrapStep) { + state.copy(root = root.copy(step = intent.step)) + } else { + state + } + is ShellRoot.LifecycleCanvas -> state + } + + private fun retainDashboardNavigation( + current: ShellRoot, + derived: ShellRoot, + ): ShellRoot = + if (current is ShellRoot.Dashboard && derived is ShellRoot.Dashboard) { + derived.copy(navigation = current.navigation) + } else { + derived + } +} diff --git a/app/shared/src/commonTest/kotlin/org/harvestcircle/application/ShellReducerTest.kt b/app/shared/src/commonTest/kotlin/org/harvestcircle/application/ShellReducerTest.kt @@ -0,0 +1,136 @@ +package org.harvestcircle.application + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.harvestcircle.design.MotionPreference +import org.harvestcircle.design.TextSizePreference +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 ShellReducerTest { + @Test + fun reducerRetainsIndependentStateAcrossDeterministicInterleavings() { + val initial = HarvestCircleShellState(activePresenterState(1UL), BuildInfo.unknown()) + val events = + listOf( + ShellEvent.IdentityObserved(activePresenterState(3UL)), + ShellEvent.Navigate(ShellDestination.Network), + ShellEvent.SetTheme(ThemePreference.Dark), + ShellEvent.SetTextSize(TextSizePreference.VeryLarge), + ShellEvent.SetMotion(MotionPreference.Reduced), + ShellEvent.Overlay(OverlayIntent.Open(FoundationOverlay.OpenNostrReference())), + ) + + val forward = events.fold(initial, ShellReducer::reduce) + val reverse = events.reversed().fold(initial, ShellReducer::reduce) + + listOf(forward, reverse).forEach { state -> + assertEquals(3UL, state.identity.snapshot.revision.value) + assertEquals(AppRoute.Network, state.currentRoute) + assertEquals(ThemePreference.Dark, state.appearance.theme) + assertEquals(TextSizePreference.VeryLarge, state.appearance.textSize) + assertEquals(MotionPreference.Reduced, state.appearance.motion) + assertTrue(state.overlays.current is FoundationOverlay.OpenNostrReference) + } + } + + @Test + fun reducerRejectsStaleIdentityObservations() { + val current = HarvestCircleShellState(activePresenterState(3UL), BuildInfo.unknown()) + val reduced = ShellReducer.reduce(current, ShellEvent.IdentityObserved(activePresenterState(2UL))) + assertTrue(reduced === current) + } + + @Test + fun presenterAtomicallyRetainsConcurrentIndependentEvents() = + runTest { + val identity = ReducerIdentityPresentation(activePresenterState(1UL)) + val presenter = HarvestCircleShellPresenter(identity, BuildInfo.unknown(), this) + runCurrent() + + listOf( + HarvestCircleShellIntent.Navigate(ShellDestination.Network), + HarvestCircleShellIntent.SetTheme(ThemePreference.Dark), + HarvestCircleShellIntent.SetTextSize(TextSizePreference.VeryLarge), + HarvestCircleShellIntent.SetMotion(MotionPreference.Reduced), + HarvestCircleShellIntent.EnterReadOnly, + HarvestCircleShellIntent.Overlay( + OverlayIntent.Open(FoundationOverlay.OpenNostrReference()), + ), + ).map { intent -> async { presenter.dispatch(intent) } } + .awaitAll() + + val state = presenter.state.value + assertEquals(AppRoute.Network, state.currentRoute) + assertEquals(ThemePreference.Dark, state.appearance.theme) + assertEquals(TextSizePreference.VeryLarge, state.appearance.textSize) + assertEquals(MotionPreference.Reduced, state.appearance.motion) + assertTrue(state.session.readOnly) + assertTrue(state.overlays.current is FoundationOverlay.OpenNostrReference) + presenter.close() + } + + @Test + fun observationCloseIsIdempotentAndTerminal() = + runTest { + val identity = ReducerIdentityPresentation(activePresenterState(1UL)) + val presenter = HarvestCircleShellPresenter(identity, BuildInfo.unknown(), this) + runCurrent() + + presenter.close() + presenter.close() + identity.state.value = activePresenterState(2UL) + runCurrent() + + assertEquals(1UL, presenter.state.value.identity.snapshot.revision.value) + } +} + +private class ReducerIdentityPresentation( + initial: HarvestCirclePresenterState, +) : IdentityPresentationPort { + override val state = MutableStateFlow(initial) + + override fun dispatch(intent: HarvestCircleIntent) = Unit +} + +private fun activePresenterState(revision: ULong): HarvestCirclePresenterState { + val identity = + IdentitySummary( + id = IdentityId.fromPublicKeyHex("03".repeat(32)), + npub = "npub1reducer", + displayLabel = "Reducer identity", + signer = SignerBindingSummary(SignerBindingKind.LocalKeyring, SignerAvailability.Available), + createdAt = UnixSeconds(1), + lastUsedAt = null, + ) + return HarvestCirclePresenterState( + ApplicationSnapshot( + revision = SnapshotRevision(revision), + lifecycle = ApplicationLifecycle.Ready, + lifecycleProblem = null, + configuredRelays = emptyList(), + identities = listOf(identity), + selectedIdentityId = identity.id, + session = SessionLifecycle.Active, + sessionSubjectIdentityId = identity.id, + sessionProblem = null, + activeIdentity = + ActiveIdentity( + identity = identity, + relays = RelaySummary(emptyList(), RelayConnectionState.Disconnected), + profileState = ProfileLoadState.Empty, + profile = null, + ), + recoverableProblem = null, + ), + ) +}