From fc31f8b1a49fadbfae452856cd635f454ca5e4a9 Mon Sep 17 00:00:00 2001 From: Karenkov Igor Date: Sun, 26 Apr 2026 13:36:36 +0700 Subject: [PATCH 01/26] Refactored navigation state management to use StateFlow and Coroutines. Removed the `NavigationRenderer` interface and simplified `NavModel` to hold state in a `MutableStateFlow`. Updated `ComposeRenderer` to collect state changes within a managed `CoroutineScope`. Changed `LocalStackNavigation` and `LocalMultiScreenNavigation` to provide screen instances directly, and updated samples and tests to reflect these changes. Added coroutine testing support to the compose module. --- .gitignore | 7 ++- gradle/libs.versions.toml | 1 + modo-compose/build.gradle.kts | 1 + .../com/github/terrakok/modo/ComposeRender.kt | 46 ++++++++++++------ .../github/terrakok/modo/ContainerScreen.kt | 47 ++++++++----------- .../com/github/terrakok/modo/ModoModels.kt | 26 ++-------- .../terrakok/modo/multiscreen/MultiScreen.kt | 2 +- .../github/terrakok/modo/stack/StackScreen.kt | 4 +- .../terrakok/modo/ModoRootScreenCacheTest.kt | 11 +++++ .../modo/sample/screens/MainScreen.kt | 8 ++-- .../screens/stack/StackActionsScreen.kt | 9 ++-- .../workshop/screens/auth/EmailScreen.kt | 4 +- .../workshop/screens/auth/EmailScreenFinal.kt | 4 +- .../ProfileSetupFlowViewModelFinal.kt | 7 ++- 14 files changed, 94 insertions(+), 83 deletions(-) diff --git a/.gitignore b/.gitignore index aba67d8c..59432973 100644 --- a/.gitignore +++ b/.gitignore @@ -16,4 +16,9 @@ settings.xml .DS_Store # GPG -*.gpg \ No newline at end of file +*.gpg + +# Task working docs (opt-in commit per task by whitelisting the subfolder) +tasks/* +!tasks/navmodel-encapsulation/ +!tasks/.gitkeep \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 1d821e63..556618f9 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -55,6 +55,7 @@ mockk = { group = "io.mockk", name = "mockk", version.ref = "mockk" } debug-logcat = { group = "com.squareup.logcat", name = "logcat", version = "0.1" } kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version = "1.8.1" } +kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version = "1.8.1" } # Dependencies of the included build-logic android-gradlePlugin = { group = "com.android.tools.build", name = "gradle", version.ref = "androidGradlePlugin" } diff --git a/modo-compose/build.gradle.kts b/modo-compose/build.gradle.kts index b496b63f..f0fcb459 100644 --- a/modo-compose/build.gradle.kts +++ b/modo-compose/build.gradle.kts @@ -39,6 +39,7 @@ dependencies { testImplementation(kotlin("test")) testImplementation(libs.test.androidx.arch.core) testImplementation(libs.mockk) + testImplementation(libs.kotlinx.coroutines.test) } tasks.withType(Test::class) { diff --git a/modo-compose/src/main/java/com/github/terrakok/modo/ComposeRender.kt b/modo-compose/src/main/java/com/github/terrakok/modo/ComposeRender.kt index 60089b68..2f8afa0f 100644 --- a/modo-compose/src/main/java/com/github/terrakok/modo/ComposeRender.kt +++ b/modo-compose/src/main/java/com/github/terrakok/modo/ComposeRender.kt @@ -24,6 +24,13 @@ import com.github.terrakok.modo.logs.devLogV import com.github.terrakok.modo.model.ScreenModelStore import com.github.terrakok.modo.model.dependenciesSortedByRemovePriority import com.github.terrakok.modo.util.currentOrThrow +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.launch typealias RendererContent = @Composable ComposeRendererScope.(Modifier) -> Unit @@ -151,24 +158,32 @@ class ComposeRendererScope( */ internal class ComposeRenderer( private val containerScreen: ContainerScreen<*, *>, -) : NavigationRenderer { + navigationStateFlow: StateFlow, +) { + internal val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) private var lastState: State? = null - var state: State? by mutableStateOf(null, neverEqualPolicy()) + var state: State by mutableStateOf(navigationStateFlow.value, neverEqualPolicy()) private set // TODO: share removed screen for whole structure? private val removedScreens = mutableSetOf() - override fun render(state: State) { - this.state?.let { currentState -> - removedScreens.addAll(calculateRemovedScreens(currentState, state)) + init { + scope.launch { + navigationStateFlow.drop(1).collect { newState -> + removedScreens.addAll(calculateRemovedScreens(state, newState)) + lastState = state + state = newState + // Handling a case when updating state doesn't cause UI to update. But if some screens was removed, we need to move them to destroy state. + // F.e. removing previous screen causes this case. + onPreDispose() + } } - lastState = this.state - this.state = state - // Handling a case when updating state doesn't cause UI to update. But if some screens was removed, we need to move them to destroy state. - // F.e. removing previous screen causes this case. - onPreDispose() + } + + internal fun dispose() { + scope.cancel() } @Suppress("UnusedPrivateProperty", "SpreadOperator") @@ -216,7 +231,7 @@ internal class ComposeRenderer( } if (clearAll) { - state?.getChildScreens()?.clearStates(stateHolder) + state.getChildScreens().clearStates(stateHolder) } // There can be several transition of different screens on the screen, // so it is important properly clear screens that are not visible for user. @@ -239,7 +254,7 @@ internal class ComposeRenderer( } if (clearAll) { - state?.getChildScreens()?.onPreDispose() + state.getChildScreens().onPreDispose() } // There can be several transition of different screens on the screen, // so it is important properly clear screens that are not visible for user. @@ -263,7 +278,10 @@ internal class ComposeRenderer( ModoDevOptions.onScreenDisposeListener?.invoke(this) // clear nested screens using recursion - ((this as? ContainerScreen<*, *>)?.renderer as? ComposeRenderer<*>)?.clearScreens(stateHolder, clearAll = true) + (this as? ContainerScreen<*, *>)?.renderer?.let { nested -> + nested.clearScreens(stateHolder, clearAll = true) + nested.dispose() + } } // need for correct handling lifecycle @@ -273,7 +291,7 @@ internal class ComposeRenderer( .filterIsInstance() .forEach { it.onPreDispose() } // send onPreDispose to nested screens - ((this as? ContainerScreen<*, *>)?.renderer as? ComposeRenderer<*>)?.onPreDispose(clearAll = true) + (this as? ContainerScreen<*, *>)?.renderer?.onPreDispose(clearAll = true) } private fun calculateRemovedScreens(oldState: NavigationState, newState: NavigationState): List { diff --git a/modo-compose/src/main/java/com/github/terrakok/modo/ContainerScreen.kt b/modo-compose/src/main/java/com/github/terrakok/modo/ContainerScreen.kt index c1c68454..a38f7f4e 100644 --- a/modo-compose/src/main/java/com/github/terrakok/modo/ContainerScreen.kt +++ b/modo-compose/src/main/java/com/github/terrakok/modo/ContainerScreen.kt @@ -9,6 +9,9 @@ import androidx.compose.runtime.saveable.SaveableStateHolder import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.ui.Modifier +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow val LocalContainerScreen = staticCompositionLocalOf?> { null } @@ -26,16 +29,15 @@ abstract class ContainerScreen? = null - internal val renderer: NavigationRenderer? - get() = navModel.renderer + internal val renderer: ComposeRenderer = ComposeRenderer(this, navModel.navigationStateFlow) final override val screenKey: ScreenKey = navModel.screenKey + /** Compose-observable view of the current navigation state. */ + val navigationState: State get() = renderer.state + init { - navModel.init( - reducerProvider = { reducer }, - renderer = ComposeRenderer(this) - ) + navModel.init(reducerProvider = { reducer }) } /** @@ -65,8 +67,7 @@ abstract class ContainerScreen = defaultRendererContent ) { - val composeRenderer = renderer as ComposeRenderer - composeRenderer.Content(screen, modifier, provideCompositionLocals(), content) + renderer.Content(screen, modifier, provideCompositionLocals(), content) } override fun toString(): String = this::class.java.simpleName + "(navModel: $navModel)" @@ -76,7 +77,9 @@ abstract class ContainerScreen = () -> NavigationReducer? /** - * Container for simple using [ContainerScreen] with [Parcelize] + * Pure UDF implementation of [NavigationContainer]. Holds state in a [MutableStateFlow] and mutates it + * exclusively through [dispatch]. Parcelable so it survives process death. + * Intended to be owned by a [ContainerScreen], which delegates [NavigationContainer] to it. */ @Stable class NavModel>( @@ -84,41 +87,31 @@ class NavModel>( val screenKey: ScreenKey = generateScreenKey() ) : NavigationContainer, Parcelable { - override var navigationState: State = initialState - get() = renderer?.state ?: field - set(value) { - field = value - renderer?.render(value) - } + private val _navigationState = MutableStateFlow(initialState) + override val navigationStateFlow: StateFlow = _navigationState.asStateFlow() private var reducerProvider: ReducerProvider? = null - internal var renderer: ComposeRenderer? = null - private set - internal fun init( - reducerProvider: ReducerProvider, - renderer: ComposeRenderer - ) { - assert(this.reducerProvider == null && this.renderer == null) { + internal fun init(reducerProvider: ReducerProvider) { + assert(this.reducerProvider == null) { "Trying to initialize navigation model again" } this.reducerProvider = reducerProvider - this.renderer = renderer.also { it.render(navigationState) } } override fun dispatch(action: Action, vararg actions: Action) { val reducer = reducerProvider!!() - var state = reduce(reducer, navigationState, action) + var state = reduce(reducer, _navigationState.value, action) for (varargAction in actions) { state = reduce(reducer, state, varargAction) } - navigationState = state + _navigationState.value = state } override fun describeContents(): Int = 0 override fun writeToParcel(parcel: Parcel, flags: Int) { - parcel.writeParcelable(navigationState, flags) + parcel.writeParcelable(_navigationState.value, flags) parcel.writeString(screenKey.value) } @@ -131,7 +124,7 @@ class NavModel>( // TODO: print logs when fallback to state ?: state - override fun toString(): String = "NavModel(navigationState=$navigationState, screenKey=$screenKey)" + override fun toString(): String = "NavModel(navigationState=${_navigationState.value}, screenKey=$screenKey)" companion object CREATOR : Parcelable.Creator> { override fun createFromParcel(parcel: Parcel): NavModel { diff --git a/modo-compose/src/main/java/com/github/terrakok/modo/ModoModels.kt b/modo-compose/src/main/java/com/github/terrakok/modo/ModoModels.kt index b3e76af7..7af8bdb3 100644 --- a/modo-compose/src/main/java/com/github/terrakok/modo/ModoModels.kt +++ b/modo-compose/src/main/java/com/github/terrakok/modo/ModoModels.kt @@ -2,12 +2,7 @@ package com.github.terrakok.modo import android.os.Parcelable import androidx.compose.runtime.Stable -import androidx.compose.runtime.snapshotFlow -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.stateIn /** * State of navigation used in [NavigationContainer]. Can be any type. @@ -32,27 +27,14 @@ fun interface NavigationReducer> { - val navigationState: State + val navigationStateFlow: StateFlow fun dispatch(action: Action, vararg actions: Action) - -} - -fun > NavigationContainer.navigationStateFlow(): Flow = - snapshotFlow { navigationState } - -fun > NavigationContainer.navigationStateStateFlow( - coroutineScope: CoroutineScope, -): StateFlow = - snapshotFlow { navigationState } - .stateIn(coroutineScope, started = SharingStarted.WhileSubscribed(), initialValue = navigationState) - -interface NavigationRenderer { - fun render(state: State) } diff --git a/modo-compose/src/main/java/com/github/terrakok/modo/multiscreen/MultiScreen.kt b/modo-compose/src/main/java/com/github/terrakok/modo/multiscreen/MultiScreen.kt index 0b9db673..43ef2887 100644 --- a/modo-compose/src/main/java/com/github/terrakok/modo/multiscreen/MultiScreen.kt +++ b/modo-compose/src/main/java/com/github/terrakok/modo/multiscreen/MultiScreen.kt @@ -9,7 +9,7 @@ import com.github.terrakok.modo.RendererContent import com.github.terrakok.modo.Screen import com.github.terrakok.modo.defaultRendererContent -val LocalMultiScreenNavigation: ProvidableCompositionLocal = staticCompositionLocalOf { +val LocalMultiScreenNavigation: ProvidableCompositionLocal = staticCompositionLocalOf { error("There is no MultiScreenContainer in hierarchy, or maybe you override provideCompositionLocal and forgot to call supper.") } diff --git a/modo-compose/src/main/java/com/github/terrakok/modo/stack/StackScreen.kt b/modo-compose/src/main/java/com/github/terrakok/modo/stack/StackScreen.kt index a7d62092..eab2c6b8 100644 --- a/modo-compose/src/main/java/com/github/terrakok/modo/stack/StackScreen.kt +++ b/modo-compose/src/main/java/com/github/terrakok/modo/stack/StackScreen.kt @@ -29,7 +29,7 @@ import com.github.terrakok.modo.defaultRendererContent import com.github.terrakok.modo.generateScreenKey import kotlinx.parcelize.Parcelize -val LocalStackNavigation: ProvidableCompositionLocal = staticCompositionLocalOf { +val LocalStackNavigation: ProvidableCompositionLocal = staticCompositionLocalOf { error("There is no LocalStackNavigation in hierarchy, or maybe you override provideCompositionLocal and forgot to call supper.") } @@ -51,7 +51,7 @@ abstract class StackScreen( TopScreenContent(modifier) } - override fun provideNavigationContainer(): ProvidedValue = + override fun provideNavigationContainer(): ProvidedValue = LocalStackNavigation provides this /** diff --git a/modo-compose/src/test/java/com/github/terrakok/modo/ModoRootScreenCacheTest.kt b/modo-compose/src/test/java/com/github/terrakok/modo/ModoRootScreenCacheTest.kt index 30c7c725..e95bd556 100644 --- a/modo-compose/src/test/java/com/github/terrakok/modo/ModoRootScreenCacheTest.kt +++ b/modo-compose/src/test/java/com/github/terrakok/modo/ModoRootScreenCacheTest.kt @@ -4,6 +4,11 @@ import android.os.Bundle import com.github.terrakok.modo.model.ScreenModelStore import io.mockk.every import io.mockk.mockk +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test import kotlin.test.assertNotSame @@ -15,6 +20,7 @@ class ModoRootScreenCacheTest { @BeforeEach fun setup() { + Dispatchers.setMain(UnconfinedTestDispatcher()) Modo.rootScreens.clear() screenCounterKey.set(-1) ScreenModelStore.removedScreenKeys.clear() @@ -25,6 +31,11 @@ class ModoRootScreenCacheTest { ModoDevOptions.onIllegalScreenModelStoreAccess = ModoDevOptions.ValidationFailedStrategy { } } + @AfterEach + fun tearDown() { + Dispatchers.resetMain() + } + // region Scenario 3: first initialization (savedState == null, inMemoryScreen == null) @Test diff --git a/sample/src/main/java/com/github/terrakok/modo/sample/screens/MainScreen.kt b/sample/src/main/java/com/github/terrakok/modo/sample/screens/MainScreen.kt index 65422fb2..a2d1ac2e 100644 --- a/sample/src/main/java/com/github/terrakok/modo/sample/screens/MainScreen.kt +++ b/sample/src/main/java/com/github/terrakok/modo/sample/screens/MainScreen.kt @@ -33,8 +33,8 @@ import com.github.terrakok.modo.sample.screens.lifecycle.LifecycleSampleScreen import com.github.terrakok.modo.sample.screens.stack.StackActionsScreen import com.github.terrakok.modo.sample.screens.viewmodel.AndroidViewModelSampleScreen import com.github.terrakok.modo.stack.LocalStackNavigation -import com.github.terrakok.modo.stack.StackNavContainer import com.github.terrakok.modo.stack.StackNavModel +import com.github.terrakok.modo.stack.StackScreen import com.github.terrakok.modo.stack.back import com.github.terrakok.modo.stack.forward import com.github.terrakok.modo.util.getActivity @@ -71,7 +71,7 @@ class MainScreen( internal fun Screen.MainScreenContent( screenIndex: Int, screenKey: ScreenKey, - navigation: StackNavContainer?, + navigation: StackScreen?, modifier: Modifier = Modifier, canOpenFragment: Boolean = false, ) { @@ -92,7 +92,7 @@ internal fun Screen.MainScreenContent( internal fun Screen.MainScreenContent( screenIndex: Int, counter: Int, - navigation: StackNavContainer, + navigation: StackScreen, modifier: Modifier = Modifier, canOpenFragment: Boolean = false, ) { @@ -118,7 +118,7 @@ internal fun Screen.MainScreenContent( @Composable private fun rememberButtons( screenKey: ScreenKey, - navigation: StackNavContainer?, + navigation: StackScreen?, i: Int, canOpenFragment: Boolean ): GroupedButtonsState { diff --git a/sample/src/main/java/com/github/terrakok/modo/sample/screens/stack/StackActionsScreen.kt b/sample/src/main/java/com/github/terrakok/modo/sample/screens/stack/StackActionsScreen.kt index 567f06fd..0d8ff21e 100644 --- a/sample/src/main/java/com/github/terrakok/modo/sample/screens/stack/StackActionsScreen.kt +++ b/sample/src/main/java/com/github/terrakok/modo/sample/screens/stack/StackActionsScreen.kt @@ -22,7 +22,7 @@ import com.github.terrakok.modo.sample.screens.dialogs.SampleDialogWithStack import com.github.terrakok.modo.stack.Back import com.github.terrakok.modo.stack.Forward import com.github.terrakok.modo.stack.LocalStackNavigation -import com.github.terrakok.modo.stack.StackNavContainer +import com.github.terrakok.modo.stack.StackScreen import com.github.terrakok.modo.stack.StackState import com.github.terrakok.modo.stack.back import com.github.terrakok.modo.stack.backTo @@ -62,14 +62,15 @@ internal class StackActionsScreen( @Suppress("LongMethod", "MagicNumber") @Composable private fun rememberButtons( - navigation: StackNavContainer, + navigation: StackScreen, screenKey: ScreenKey, screenIndex: Int ): GroupedButtonsState { val coroutineScope = rememberCoroutineScope() + val navigationState = navigation.navigationState val isFirstScreen by remember { derivedStateOf { - navigation.navigationState.stack.first().screenKey == screenKey + navigationState.stack.first().screenKey == screenKey } } return remember(navigation, isFirstScreen) { @@ -99,7 +100,7 @@ private fun rememberButtons( } }, ModoButtonSpec("Remove previous") { - val prevScreenIndex = navigation.navigationState.stack.lastIndex - 1 + val prevScreenIndex = navigation.navigationStateFlow.value.stack.lastIndex - 1 navigation.removeScreens { pos, screen -> pos == prevScreenIndex } }, ModoButtonSpec("Back to '3'") { diff --git a/workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/screens/auth/EmailScreen.kt b/workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/screens/auth/EmailScreen.kt index 9d5b4381..21fed1e3 100644 --- a/workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/screens/auth/EmailScreen.kt +++ b/workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/screens/auth/EmailScreen.kt @@ -20,7 +20,7 @@ import com.github.terrakok.modo.Screen import com.github.terrakok.modo.ScreenKey import com.github.terrakok.modo.generateScreenKey import com.github.terrakok.modo.stack.LocalStackNavigation -import com.github.terrakok.modo.stack.StackNavContainer +import com.github.terrakok.modo.stack.StackScreen import com.github.terrakok.modo.stack.forward import kotlinx.parcelize.Parcelize @@ -32,7 +32,7 @@ class EmailScreen( @Composable override fun Content(modifier: Modifier) { - val navigation: StackNavContainer = LocalStackNavigation.current + val navigation: StackScreen = LocalStackNavigation.current EmailScreenContent( modifier = modifier, onContinueClick = { email -> diff --git a/workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/screens/auth/EmailScreenFinal.kt b/workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/screens/auth/EmailScreenFinal.kt index 53726ddd..ef02db5e 100644 --- a/workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/screens/auth/EmailScreenFinal.kt +++ b/workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/screens/auth/EmailScreenFinal.kt @@ -6,7 +6,7 @@ import com.github.terrakok.modo.Screen import com.github.terrakok.modo.ScreenKey import com.github.terrakok.modo.generateScreenKey import com.github.terrakok.modo.stack.LocalStackNavigation -import com.github.terrakok.modo.stack.StackNavContainer +import com.github.terrakok.modo.stack.StackScreen import com.github.terrakok.modo.stack.forward import kotlinx.parcelize.Parcelize @@ -17,7 +17,7 @@ class EmailScreenFinal( @Composable override fun Content(modifier: Modifier) { - val navigation: StackNavContainer = LocalStackNavigation.current + val navigation: StackScreen = LocalStackNavigation.current EmailScreenContent( modifier = modifier, onContinueClick = { email -> diff --git a/workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/screens/profile_setup/ProfileSetupFlowViewModelFinal.kt b/workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/screens/profile_setup/ProfileSetupFlowViewModelFinal.kt index 55627639..fff837b2 100644 --- a/workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/screens/profile_setup/ProfileSetupFlowViewModelFinal.kt +++ b/workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/screens/profile_setup/ProfileSetupFlowViewModelFinal.kt @@ -2,8 +2,7 @@ package io.github.ikarenkov.workshop.screens.profile_setup import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.github.terrakok.modo.navigationStateStateFlow -import com.github.terrakok.modo.stack.StackNavContainer +import com.github.terrakok.modo.stack.StackScreen import com.github.terrakok.modo.stack.StackState import com.github.terrakok.modo.stack.back import com.github.terrakok.modo.stack.forward @@ -18,7 +17,7 @@ class ProfileSetupFlowViewModelFinal( private val restartFlow: Boolean, // Workshop 5.1.1 - take screens as parametrs private val profileSetupFlowScreen: ProfileSetupFlowScreenFinal, - private val parentNavigation: StackNavContainer, + private val parentNavigation: StackScreen, private val climberProfileRepository: ClimberProfileRepository, ) : ViewModel() { @@ -30,7 +29,7 @@ class ProfileSetupFlowViewModelFinal( // Workshop 5.3 - define state using navigationStateFlow and climberProfileRepository.climberProfile val state: StateFlow = combineStateFlow( - profileSetupFlowScreen.navigationStateStateFlow(viewModelScope), + profileSetupFlowScreen.navigationStateFlow, climberProfileRepository.climberProfile, viewModelScope, ) { navigationState, profile -> From 07d1a6e847505a7a5ec50752459f95a5ae2ddca5 Mon Sep 17 00:00:00 2001 From: Karenkov Igor Date: Sun, 26 Apr 2026 17:12:14 +0700 Subject: [PATCH 02/26] Added ai agents files AGENTS.md and CLAUDE.md. Added task-workflow skill. --- .agents/skills | 1 + .claude/skills/task-workflow/SKILL.md | 210 ++++++++++++++++++++++++++ .gitignore | 7 +- AGENTS.md | 35 +++++ CLAUDE.md | 1 + 5 files changed, 248 insertions(+), 6 deletions(-) create mode 120000 .agents/skills create mode 100644 .claude/skills/task-workflow/SKILL.md create mode 100644 AGENTS.md create mode 120000 CLAUDE.md diff --git a/.agents/skills b/.agents/skills new file mode 120000 index 00000000..454b8427 --- /dev/null +++ b/.agents/skills @@ -0,0 +1 @@ +../.claude/skills \ No newline at end of file diff --git a/.claude/skills/task-workflow/SKILL.md b/.claude/skills/task-workflow/SKILL.md new file mode 100644 index 00000000..5b20520b --- /dev/null +++ b/.claude/skills/task-workflow/SKILL.md @@ -0,0 +1,210 @@ +--- +name: task-workflow +description: Spec-driven workflow for non-trivial work. Scaffolds a tasks// folder with overview/analysis/spec/plan/log docs; completion produces an ADR under adr/. Use when the user asks for architectural analysis, multi-step refactor planning, investigations that span sessions, or any work that would benefit from a durable design record. Skip for trivial fixes, typos, or obviously-specified single-file edits — just do those directly. +--- + +# Task workflow + +Spec-driven development for non-trivial work. Every task is a self-contained folder holding the context, analysis, design, plan, and rationale for one piece of work. + +## When to use this workflow + +**Use it when:** +- The work involves a design decision that will matter in 3 months (why-was-this-done). +- The work spans multiple sessions or agents. +- The user is exploring options, not requesting a pre-specified implementation. +- There's real risk of wasted effort without alignment on approach first. + +**Skip it when:** +- The request is a single-file edit with a clear desired outcome. +- The fix is obvious once the bug is located. +- The user explicitly asks to just do the work. + +When in doubt, propose the workflow to the user and let them decide — "this looks like a multi-step refactor, want me to scaffold a task folder for it?" + +## Folder layout + +``` +tasks// + 00-overview.md # TL;DR: problem, target, status, index + 01-analysis.md # current state — facts only, no decisions + 02-spec.md # target design with decisions baked in + 03-plan.md # ordered implementation steps, checklists, rollback + 04-log.md # decision rationale — options, why, consequence per decision +``` + +Numeric prefixes force readable sort order in directory listings. + +**Naming:** kebab-case topic, e.g. `navmodel-encapsulation`, `screenmodel-threading-fix`. Keep it short and descriptive — it's the primary identifier. + +## What goes in each file + +### 00-overview.md + +- **Status** line (see lifecycle below). +- Scope — which files/modules this touches. +- Problem in one sentence. +- Target in one sentence. +- Index of other files in the folder. +- **Follow-ups / next steps** — appended to as deferred items surface during the task. Anything postponed, split into a follow-up task, or noticed-but-out-of-scope goes here. Each entry: short description + pointer (e.g. `04-log.md#Qn`, `03-plan.md` strikeout, or "noticed during Phase 3"). This is the canonical list a fresh agent reads when picking the work back up, and the source the ADR's `Follow-ups` field is distilled from. +- Post-completion note — ADR filename and commit decision. + +Every task has this file, even if it's the only one. + +### 01-analysis.md + +Current state, facts only. No judgments about what *should* happen. No options. Things that belong here: + +- Types, interfaces, call graph relevant to the task. +- Current problems / smells — described as observations, not prescriptions. +- Why the current code is shaped the way it is (if discernible — helps avoid undoing intentional choices). +- Call-site map for anything being removed/changed. +- Public API surface impact preview. + +This doc should stand alone as a reference even if the task is abandoned. + +### 02-spec.md + +Target design, decisions baked in. Written as if the design is settled (because it is — this file is updated when decisions change, not as an open debate). Things that belong here: + +- Target types with code sketches. +- Key behaviors and invariants. +- Threading/concurrency notes. +- Public API impact. +- Explicit non-goals — what this task does *not* do. + +If there are open questions, they go in `04-log.md` as pending decisions, not here. + +### 03-plan.md + +Ordered steps. Phased so the tree compiles after each phase where possible. Things that belong here: + +- Phase-by-phase checklist. +- Known call sites that need updating (pinned with file:line). +- Rollback strategy if a phase can't land. +- Verification steps (tests, manual checks). +- Out-of-scope items explicitly called out. + +A fresh agent should be able to implement from this doc alone (with the spec as reference). + +**Checklist conventions** — keep the plan in sync with reality as work lands: + +- `- [ ]` — todo (default). +- `- [x]` — completed. Tick as soon as the step lands; don't batch at the end. +- `- [-]` — cancelled or superseded. Append an inline reason: `- [-] Step — superseded by phase X` / `- [-] Step — deferred to follow-up task Y`. Use this instead of deleting the line so the audit trail survives. If the cancellation reflects a design change (not just sequencing), also add a `04-log.md` entry capturing *why*. + +Note: `- [-]` is not standard CommonMark/GFM (it's an Obsidian/Logseq convention) — it renders as plain text on GitHub. Acceptable here because plan docs are read mostly by agents and locally in IDEs, not on GitHub-rendered pages. + +### 04-log.md + +Decision rationale. One entry per decision, structured: + +``` +## Q + +**Options:** A/B/C with short descriptions. +**Decision:** . +**Why:** . +**Consequence:** . +``` + +Both accepted and rejected decisions live here. Captures *why*, not *what* — the what is in the spec. + +## Status lifecycle + +`00-overview.md` carries a **Status** line: + +- `exploring` — problem scoped, no target yet. +- `design in progress` — analysis and spec being written; decisions open. +- `design accepted` — spec and plan locked; implementation hasn't started. +- `implementation in progress` — code changes underway. +- `complete` — code merged; ADR written or pending. +- `abandoned` — task dropped; reason captured in `04-log.md` or an ADR. + +Update the status line when it changes. Avoid leaving stale statuses — a fresh agent reads it first. + +## Commit policy + +`tasks/` is **gitignored by default** (see root `.gitignore`). Working docs are local scratch. To commit a specific task, whitelist its subfolder: + +``` +# in .gitignore +tasks/ +!tasks/.gitkeep +!tasks// +``` + +Default assumption: tasks are local. Agents working on a task need the user to point them to the folder (by path, opening in IDE, or referencing by name). + +## Completion → ADR + +### Pre-completion review + +Before flipping status to `complete` and writing the ADR, run this checklist: + +- Every `03-plan.md` item is `[x]` (done) or `[-]` (cancelled with reason). No stale `[ ]` items — if something is genuinely pending but not blocking, move it to `00-overview.md`'s Follow-ups section first. +- Every `[-]` line has an inline reason (`— superseded by X`, `— deferred to follow-up Y`, etc.). +- Every "deferred / postponed / out-of-scope-but-noticed" item that surfaced during implementation is captured in `00-overview.md`'s **Follow-ups / next steps** section, not just buried inline in the log or as a strikeout. +- Status line in `00-overview.md` accurately reflects current state. +- Verification steps (Phase 6 / equivalent) actually ran — not just listed. + +If the task was abandoned mid-flight, the same review applies: cancelled items marked `[-]`, follow-ups captured (so resuming is possible), status set to `abandoned`. + +### Writing the ADR + +When a task reaches `complete`, distill the outcome into an ADR under `adr/`: + +``` +adr/NNNN-.md +``` + +Numbering is zero-padded, monotonically increasing. Grep existing ADRs for the next number. + +**ADR format** (short — one page): + +```markdown +# . + +## Context +<what problem / why this was decided> + +## Decision +<what was decided, concisely> + +## Consequences +<implications — positive and negative — going forward> + +## Follow-ups +<deferred work, split-out tasks, known limitations to revisit. Distilled from +00-overview.md's Follow-ups section. Omit the section entirely if there are none.> +``` + +ADRs are **always committed**. They are the durable record of why the codebase looks the way it does. Task folders are scratch; ADRs are canon. + +### After the ADR is written + +Decide on the task folder: + +- **Delete** — ADR captures the outcome; working docs were scratch. Most common. +- **Keep and commit** — analysis or plan is valuable reference material beyond the ADR summary. Whitelist in `.gitignore` and commit. +- **Keep local, uncommitted** — rare; only if the task might resume. + +### Abandoned tasks + +- If no ADR is warranted (e.g. the direction was rejected without teaching anything new), just delete the folder. +- If the reasoning for abandoning is worth preserving, write a short ADR describing why the direction was rejected. + +## Agent access note + +`tasks/` being gitignored affects git only. Agents have full filesystem read/write access via their standard tools. Gitignore is about "don't push this"; it is not a permission boundary. + +## Typical flow + +1. User describes a non-trivial piece of work. +2. Agent proposes the task workflow ("this looks like X, want to scaffold a task?"). +3. User confirms → create `tasks/<name>/00-overview.md` with status `exploring` or `design in progress`. +4. Agent populates `01-analysis.md` from code reading. +5. Agent and user converge on design through discussion; `02-spec.md` and `04-log.md` get written together (one captures the settled design, the other captures the why). +6. `03-plan.md` lists phased steps. +7. User agrees → status → `implementation in progress` → code. Tick `03-plan.md` items as each lands (`[x]`); mark cancelled/superseded items `[-]` with a reason. Capture deferred / postponed / out-of-scope-but-noticed items in `00-overview.md`'s Follow-ups section. Update the status line in `00-overview.md` when phases shift. +8. Code merged → run pre-completion review → status → `complete` → write ADR (including `Follow-ups` if any) → delete/commit task folder. diff --git a/.gitignore b/.gitignore index 59432973..aba67d8c 100644 --- a/.gitignore +++ b/.gitignore @@ -16,9 +16,4 @@ settings.xml .DS_Store # GPG -*.gpg - -# Task working docs (opt-in commit per task by whitelisting the subfolder) -tasks/* -!tasks/navmodel-encapsulation/ -!tasks/.gitkeep \ No newline at end of file +*.gpg \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..89fbc2a6 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,35 @@ +# Modo — Agent Orientation + +State-based navigation library for Jetpack Compose. UDF architecture: navigation is a tree of `Screen`s and `ContainerScreen`s driven by `NavigationState` and updated via `dispatch(Action)` on a `NavigationContainer`. + +## Modules + +- `modo-compose/` — the library. Core abstractions (`Screen`, `ContainerScreen`, `NavModel`, `NavigationState`, `NavigationContainer`), Compose integration (`ComposeRenderer`, `SaveableContent`), Android integration (`ModoScreenAndroidAdapter`, lifecycle, saved state), built-in container types (`StackScreen`, `MultiScreen`, `DialogScreen`), and `ScreenModel` infrastructure. +- `sample/` — demo app exercising library features. +- `workshop-app/` — tutorial/workshop codebase used to teach the library. +- `build-logic/` — convention plugins for the Gradle build. +- `Writerside/` — user-facing documentation site (published to GitHub Pages). + +## Build & test + +``` +./gradlew build # full build, all modules +./gradlew :modo-compose:test # library unit tests +./gradlew :modo-compose:testDebugUnitTest +./gradlew :sample:installDebug # run sample app on a connected device +``` + +Check `config/` for shared gradle/lint config and `gradle.properties` for JVM/Compose settings. Kotlin code style is enforced; match existing formatting in the file you're editing. + +## Code conventions + +- All navigation-facing types live under `com.github.terrakok.modo`. +- `NavigationState` implementations must be `Parcelable` and return every held `Screen` from `getChildScreens()` — this drives cleanup and lifecycle. +- Prefer editing existing files over creating new ones. Only add new files when an abstraction genuinely belongs in its own unit. +- Breaking API changes on public types (`NavigationContainer`, `Screen`, `NavModel`, etc.) require a deliberate decision — surface in a task doc (see *Non-trivial work* below) before implementing. + +## Non-trivial work + +For multi-step tasks (refactors, architectural investigations, work likely to span sessions), we use a task-folder workflow — see the **task-workflow** skill (`.agents/skills/task-workflow/`). Invoke it via `/task-workflow` (if supported by your agent), or follow the protocol in the skill doc. Completed tasks produce an ADR under `adr/`. + +For trivial fixes, typos, and single-file edits, just do the work — no scaffolding needed. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 120000 index 00000000..47dc3e3d --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file From 52603b11d158d97d6070a65f32dc87303fdabd64 Mon Sep 17 00:00:00 2001 From: Karenkov Igor <karenkovigor@gmail.com> Date: Mon, 27 Apr 2026 00:36:57 +0700 Subject: [PATCH 03/26] Refactor the navigation API to simplify the generic signature and transition to a reducer-based state update model. - Refactor `ContainerScreen`, `NavModel`, and `NavigationContainer` to remove the `Action` generic type parameter, leaving only `State`. - Introduce `NavigationReducer` as a functional interface for state transformations (State -> State) and make it the primary mechanism for `dispatch`. - Deprecate `NavigationAction` and container-specific action interfaces (e.g., `StackAction`, `MultiScreenAction`, `ListNavigationAction`) in favor of reducers. - Update `StackScreen`, `MultiScreen`, and `ListNavigationContainer` to align with the single-generic parameter. - Implement atomic dispatch for multiple reducers to ensure consistent state transitions. - Update `Modo` utility functions and `RootScreen` to support modern Android `getParcelable` APIs (SDK 33+). - Migrate sample applications and internal library components to use the new reducer-based `dispatch` pattern. - Add unit tests for `ComposeRenderer` to ensure coroutine scope disposal when screens are removed. - Rename action files to reducer files (e.g., `ListNavigationAction.kt` to `ListReducer.kt`) to match the new architecture. --- AGENTS.md | 19 +++-- .../com/github/terrakok/modo/ComposeRender.kt | 6 +- .../github/terrakok/modo/ContainerScreen.kt | 68 ++++++---------- .../java/com/github/terrakok/modo/Modo.kt | 27 +++++-- .../com/github/terrakok/modo/ModoModels.kt | 37 +++++++-- .../com/github/terrakok/modo/RootScreen.kt | 4 +- .../terrakok/modo/list/ListNavigationState.kt | 6 +- ...ListNavigationAction.kt => ListReducer.kt} | 69 ++++++++-------- .../terrakok/modo/multiscreen/MultiScreen.kt | 2 +- .../modo/multiscreen/MultiScreenActions.kt | 25 +++--- .../modo/multiscreen/MultiScreenState.kt | 4 +- .../terrakok/modo/stack/StackActions.kt | 51 ++++++------ .../github/terrakok/modo/stack/StackScreen.kt | 4 +- .../github/terrakok/modo/stack/StackState.kt | 10 +-- .../terrakok/modo/util/NavigationLogger.kt | 4 +- .../modo/ComposeRendererDisposalTest.kt | 81 +++++++++++++++++++ .../terrakok/modo/ModoRootScreenCacheTest.kt | 3 +- ...nsTest.kt => ListReducerAddScreensTest.kt} | 22 ++--- ...est.kt => ListReducerRemoveScreensTest.kt} | 12 +-- ...ActionSetTest.kt => ListReducerSetTest.kt} | 6 +- .../modo/sample/screens/containers/AddTab.kt | 21 +++-- .../containers/HorizontalPagerScreen.kt | 21 ++--- .../screens/containers/RemoveTabAction.kt | 8 -- .../screens/containers/RemoveTabReducer.kt | 16 ++++ .../screens/containers/SampleMultiScreen.kt | 24 +----- .../sample/screens/containers/SampleStack.kt | 26 +++--- .../containers/StackInLazyColumnScreen.kt | 14 ++-- .../custom/RemovableItemContainerScreen.kt | 41 +++------- .../custom/SampleCustomContainerScreen.kt | 28 +++---- .../screens/stack/StackActionsScreen.kt | 1 + .../screens/profile/EnhancedProfileScreen.kt | 12 +-- .../profile/EnhancedProfileScreenFinal.kt | 7 +- .../profile/EnhancedProfileViewModelFinal.kt | 9 +-- 33 files changed, 385 insertions(+), 303 deletions(-) rename modo-compose/src/main/java/com/github/terrakok/modo/list/{ListNavigationAction.kt => ListReducer.kt} (63%) create mode 100644 modo-compose/src/test/java/com/github/terrakok/modo/ComposeRendererDisposalTest.kt rename modo-compose/src/test/java/com/github/terrakok/modo/list/{ListNavigationActionAddScreensTest.kt => ListReducerAddScreensTest.kt} (85%) rename modo-compose/src/test/java/com/github/terrakok/modo/list/{ListNavigationActionRemoveScreensTest.kt => ListReducerRemoveScreensTest.kt} (84%) rename modo-compose/src/test/java/com/github/terrakok/modo/list/{ListNavigationActionSetTest.kt => ListReducerSetTest.kt} (86%) delete mode 100644 sample/src/main/java/com/github/terrakok/modo/sample/screens/containers/RemoveTabAction.kt create mode 100644 sample/src/main/java/com/github/terrakok/modo/sample/screens/containers/RemoveTabReducer.kt diff --git a/AGENTS.md b/AGENTS.md index 89fbc2a6..59afeec5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,10 +1,15 @@ # Modo — Agent Orientation -State-based navigation library for Jetpack Compose. UDF architecture: navigation is a tree of `Screen`s and `ContainerScreen`s driven by `NavigationState` and updated via `dispatch(Action)` on a `NavigationContainer`. +State-based navigation library for Jetpack Compose. UDF architecture: navigation is a tree of `Screen`s and `ContainerScreen`s driven by +`NavigationState` and updated via `dispatch(Action)` on a `NavigationContainer`. + +IMPORTANT: When applicable, prefer using android-studio-index MCP tools for code navigation and refactoring. ## Modules -- `modo-compose/` — the library. Core abstractions (`Screen`, `ContainerScreen`, `NavModel`, `NavigationState`, `NavigationContainer`), Compose integration (`ComposeRenderer`, `SaveableContent`), Android integration (`ModoScreenAndroidAdapter`, lifecycle, saved state), built-in container types (`StackScreen`, `MultiScreen`, `DialogScreen`), and `ScreenModel` infrastructure. +- `modo-compose/` — the library. Core abstractions (`Screen`, `ContainerScreen`, `NavModel`, `NavigationState`, `NavigationContainer`), Compose + integration (`ComposeRenderer`, `SaveableContent`), Android integration (`ModoScreenAndroidAdapter`, lifecycle, saved state), built-in container + types (`StackScreen`, `MultiScreen`, `DialogScreen`), and `ScreenModel` infrastructure. - `sample/` — demo app exercising library features. - `workshop-app/` — tutorial/workshop codebase used to teach the library. - `build-logic/` — convention plugins for the Gradle build. @@ -19,17 +24,21 @@ State-based navigation library for Jetpack Compose. UDF architecture: navigation ./gradlew :sample:installDebug # run sample app on a connected device ``` -Check `config/` for shared gradle/lint config and `gradle.properties` for JVM/Compose settings. Kotlin code style is enforced; match existing formatting in the file you're editing. +Check `config/` for shared gradle/lint config and `gradle.properties` for JVM/Compose settings. Kotlin code style is enforced; match existing +formatting in the file you're editing. ## Code conventions - All navigation-facing types live under `com.github.terrakok.modo`. - `NavigationState` implementations must be `Parcelable` and return every held `Screen` from `getChildScreens()` — this drives cleanup and lifecycle. - Prefer editing existing files over creating new ones. Only add new files when an abstraction genuinely belongs in its own unit. -- Breaking API changes on public types (`NavigationContainer`, `Screen`, `NavModel`, etc.) require a deliberate decision — surface in a task doc (see *Non-trivial work* below) before implementing. +- Breaking API changes on public types (`NavigationContainer`, `Screen`, `NavModel`, etc.) require a deliberate decision — surface in a task doc (see + *Non-trivial work* below) before implementing. ## Non-trivial work -For multi-step tasks (refactors, architectural investigations, work likely to span sessions), we use a task-folder workflow — see the **task-workflow** skill (`.agents/skills/task-workflow/`). Invoke it via `/task-workflow` (if supported by your agent), or follow the protocol in the skill doc. Completed tasks produce an ADR under `adr/`. +For multi-step tasks (refactors, architectural investigations, work likely to span sessions), we use a task-folder workflow — see the **task-workflow +** skill (`.agents/skills/task-workflow/`). Invoke it via `/task-workflow` (if supported by your agent), or follow the protocol in the skill doc. +Completed tasks produce an ADR under `adr/`. For trivial fixes, typos, and single-file edits, just do the work — no scaffolding needed. diff --git a/modo-compose/src/main/java/com/github/terrakok/modo/ComposeRender.kt b/modo-compose/src/main/java/com/github/terrakok/modo/ComposeRender.kt index 2f8afa0f..bda74337 100644 --- a/modo-compose/src/main/java/com/github/terrakok/modo/ComposeRender.kt +++ b/modo-compose/src/main/java/com/github/terrakok/modo/ComposeRender.kt @@ -157,7 +157,7 @@ class ComposeRendererScope<State : NavigationState>( * 2. Storing and clearing composable states inside [SaveableStateHolder] */ internal class ComposeRenderer<State : NavigationState>( - private val containerScreen: ContainerScreen<*, *>, + private val containerScreen: ContainerScreen<State>, navigationStateFlow: StateFlow<State>, ) { internal val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) @@ -278,7 +278,7 @@ internal class ComposeRenderer<State : NavigationState>( ModoDevOptions.onScreenDisposeListener?.invoke(this) // clear nested screens using recursion - (this as? ContainerScreen<*, *>)?.renderer?.let { nested -> + (this as? ContainerScreen<*>)?.renderer?.let { nested -> nested.clearScreens(stateHolder, clearAll = true) nested.dispose() } @@ -291,7 +291,7 @@ internal class ComposeRenderer<State : NavigationState>( .filterIsInstance<LifecycleDependency>() .forEach { it.onPreDispose() } // send onPreDispose to nested screens - (this as? ContainerScreen<*, *>)?.renderer?.onPreDispose(clearAll = true) + (this as? ContainerScreen<*>)?.renderer?.onPreDispose(clearAll = true) } private fun calculateRemovedScreens(oldState: NavigationState, newState: NavigationState): List<Screen> { diff --git a/modo-compose/src/main/java/com/github/terrakok/modo/ContainerScreen.kt b/modo-compose/src/main/java/com/github/terrakok/modo/ContainerScreen.kt index a38f7f4e..ac0d83e1 100644 --- a/modo-compose/src/main/java/com/github/terrakok/modo/ContainerScreen.kt +++ b/modo-compose/src/main/java/com/github/terrakok/modo/ContainerScreen.kt @@ -13,21 +13,28 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow -val LocalContainerScreen = staticCompositionLocalOf<ContainerScreen<*, *>?> { null } +val LocalContainerScreen = staticCompositionLocalOf<ContainerScreen<*>?> { null } -fun interface ReducerAction<State : NavigationState> : NavigationAction<State> { - fun reduce(oldState: State): State -} +@Deprecated( + message = "Use NavigationReducer instead", + replaceWith = ReplaceWith("NavigationReducer<State>") +) +typealias ReducerAction<State> = NavigationReducer<State> @Stable -abstract class ContainerScreen<State : NavigationState, Action : NavigationAction<State>>( - private val navModel: NavModel<State, Action> -) : Screen, NavigationContainer<State, Action> by navModel { +abstract class ContainerScreen<State : NavigationState>( + private val navModel: NavModel<State> +) : Screen, NavigationContainer<State> by navModel { /** * The reducer that can be used to control state updates. */ - open val reducer: NavigationReducer<State, Action>? = null + @Deprecated( + message = "Custom navigation behavior should move from screen-level external reducers to dispatch-time reducers. " + + "This property is no longer used by the navigation system.", + level = DeprecationLevel.ERROR + ) + open val reducer: NavigationReducer<State>? = null internal val renderer: ComposeRenderer<State> = ComposeRenderer(this, navModel.navigationStateFlow) @@ -36,10 +43,6 @@ abstract class ContainerScreen<State : NavigationState, Action : NavigationActio /** Compose-observable view of the current navigation state. */ val navigationState: State get() = renderer.state - init { - navModel.init(reducerProvider = { reducer }) - } - /** * This function can be used to provide composition locals for inner screens. * This is used in implementations of ContainerScreen to provide typed composition locals to container. @@ -54,7 +57,7 @@ abstract class ContainerScreen<State : NavigationState, Action : NavigationActio * Provides composition local for the nested hierarchy to receive NavigationContainer. * @see com.github.terrakok.modo.stack.LocalStackNavigation */ - open fun provideNavigationContainer(): ProvidedValue<out NavigationContainer<*, *>>? = null + open fun provideNavigationContainer(): ProvidedValue<out NavigationContainer<*>>? = null /** * Use this function to render the content of nested screens. It provides correct work of [rememberSaveable] by using [SaveableStateHolder]. @@ -74,38 +77,22 @@ abstract class ContainerScreen<State : NavigationState, Action : NavigationActio } -typealias ReducerProvider<State, Action> = () -> NavigationReducer<State, Action>? - /** * Pure UDF implementation of [NavigationContainer]. Holds state in a [MutableStateFlow] and mutates it * exclusively through [dispatch]. Parcelable so it survives process death. * Intended to be owned by a [ContainerScreen], which delegates [NavigationContainer] to it. */ @Stable -class NavModel<State : NavigationState, Action : NavigationAction<State>>( +class NavModel<State : NavigationState>( initialState: State, val screenKey: ScreenKey = generateScreenKey() -) : NavigationContainer<State, Action>, Parcelable { +) : NavigationContainer<State>, Parcelable { private val _navigationState = MutableStateFlow(initialState) override val navigationStateFlow: StateFlow<State> = _navigationState.asStateFlow() - private var reducerProvider: ReducerProvider<State, Action>? = null - - internal fun init(reducerProvider: ReducerProvider<State, Action>) { - assert(this.reducerProvider == null) { - "Trying to initialize navigation model again" - } - this.reducerProvider = reducerProvider - } - - override fun dispatch(action: Action, vararg actions: Action) { - val reducer = reducerProvider!!() - var state = reduce(reducer, _navigationState.value, action) - for (varargAction in actions) { - state = reduce(reducer, state, varargAction) - } - _navigationState.value = state + override fun dispatch(reducer: NavigationReducer<State>) { + _navigationState.value = reducer.reduce(_navigationState.value) } override fun describeContents(): Int = 0 @@ -115,24 +102,15 @@ class NavModel<State : NavigationState, Action : NavigationAction<State>>( parcel.writeString(screenKey.value) } - private fun reduce(reducer: NavigationReducer<State, Action>?, state: State, action: Action): State = - reducer?.reduce(action, state) - ?: when (action) { - is ReducerAction<*> -> (action as? ReducerAction<State>)?.reduce(state) - else -> null - } - // TODO: print logs when fallback to state - ?: state - override fun toString(): String = "NavModel(navigationState=${_navigationState.value}, screenKey=$screenKey)" - companion object CREATOR : Parcelable.Creator<NavModel<*, *>> { - override fun createFromParcel(parcel: Parcel): NavModel<NavigationState, *> { + companion object CREATOR : Parcelable.Creator<NavModel<*>> { + override fun createFromParcel(parcel: Parcel): NavModel<NavigationState> { val state = parcel.readParcelable<NavigationState>(NavModel::class.java.classLoader)!! val screenKey = parcel.readString()!! return NavModel(state, ScreenKey(screenKey)) } - override fun newArray(size: Int): Array<NavModel<*, *>?> = arrayOfNulls(size) + override fun newArray(size: Int): Array<NavModel<*>?> = arrayOfNulls(size) } } \ No newline at end of file diff --git a/modo-compose/src/main/java/com/github/terrakok/modo/Modo.kt b/modo-compose/src/main/java/com/github/terrakok/modo/Modo.kt index 8adf87cc..7e107db6 100644 --- a/modo-compose/src/main/java/com/github/terrakok/modo/Modo.kt +++ b/modo-compose/src/main/java/com/github/terrakok/modo/Modo.kt @@ -1,6 +1,7 @@ package com.github.terrakok.modo import android.app.Activity +import android.os.Build import android.os.Bundle import android.util.Log import androidx.compose.runtime.Composable @@ -35,7 +36,10 @@ object Modo { /** * Saves provided screen with nested graph to bundle for further restoration. */ - @Deprecated("Use rememberRootScreen, which handles saving and restoring automatically. Will be removed in 1.0.") + @Deprecated( + "Use rememberRootScreen, which handles saving and restoring automatically. Will be removed in 1.0.", + ReplaceWith("this.rememberRootScreen { rootScreen }") + ) fun save(outState: Bundle, rootScreen: Screen?) { outState.putInt(MODO_SCREEN_COUNTER_KEY, screenCounterKey.get()) outState.putParcelable(MODO_GRAPH, rootScreen) @@ -68,9 +72,19 @@ object Modo { * Must be null for Activities and for the very first Fragment creation. * @param rootScreenProvider called only in scenario 3 to construct the initial root screen. */ - @Deprecated("Use rememberRootScreen, which handles all lifecycle concerns automatically. Will be removed in 1.0.") + @Deprecated( + "Use rememberRootScreen, which handles all lifecycle concerns automatically. Will be removed in 1.0.", + ReplaceWith("this.rememberRootScreen(rootScreenProvider)") + ) fun <T : Screen> getOrCreateRootScreen(savedState: Bundle?, inMemoryScreen: RootScreen<T>?, rootScreenProvider: () -> T): RootScreen<T> { - val savedModoGraph = savedState?.getParcelable<RootScreen<T>>(MODO_GRAPH) + val savedModoGraph = savedState?.let { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + it.getParcelable(MODO_GRAPH, RootScreen::class.java) + } else { + @Suppress("DEPRECATION") + it.getParcelable(MODO_GRAPH) + } + } as? RootScreen<T> return if (savedModoGraph != null) { // Scenario 1: bundle restore. // Config change → cache hit, process death → cache miss, savedModoGraph is stored. @@ -90,7 +104,10 @@ object Modo { /** * Must be called to clear all data from [ScreenModelStore], related with removed screens. */ - @Deprecated("Use rememberRootScreen, which handles cleanup automatically. Will be removed in 1.0.") + @Deprecated( + "Use rememberRootScreen, which handles cleanup automatically. Will be removed in 1.0.", + ReplaceWith("Modo.rememberRootScreen") + ) fun <T : Screen> onRootScreenFinished(rootScreen: RootScreen<T>?) = finishRootScreen(rootScreen) private fun <T : Screen> finishRootScreen(rootScreen: RootScreen<T>?) { @@ -201,7 +218,7 @@ object Modo { private fun clearScreenModel(screen: Screen) { ScreenModelStore.remove(screen) - (screen as? ContainerScreen<*, *>)?.navigationState?.getChildScreens()?.forEach(::clearScreenModel) + (screen as? ContainerScreen<*>)?.navigationState?.getChildScreens()?.forEach(::clearScreenModel) } } diff --git a/modo-compose/src/main/java/com/github/terrakok/modo/ModoModels.kt b/modo-compose/src/main/java/com/github/terrakok/modo/ModoModels.kt index 7af8bdb3..1f1ad054 100644 --- a/modo-compose/src/main/java/com/github/terrakok/modo/ModoModels.kt +++ b/modo-compose/src/main/java/com/github/terrakok/modo/ModoModels.kt @@ -17,24 +17,47 @@ interface NavigationState : Parcelable { /** * Marker interface to be able specify type of action for [NavigationContainer]. */ -interface NavigationAction<State> +@Deprecated("Use NavigationReducer directly.", ReplaceWith("NavigationReducer<State>")) +interface NavigationAction<State : NavigationState> -fun interface NavigationReducer<State : NavigationState, Action : NavigationAction<State>> { +/** + * Pure state transformer: takes old state and returns new state. + */ +fun interface NavigationReducer<State : NavigationState> { /** - * Return a new state based on old [state] and incoming action. If returns null, then the work will be addressed to parent reducer. + * Return a new state based on old [oldState]. */ - fun reduce(action: Action, state: State): State? + fun reduce(oldState: State): State } /** * UDF navigation contract. State is exposed as a [StateFlow] and mutated exclusively through [dispatch]. * The pure-Kotlin UDF implementation of this interface is [NavModel]. * @param State - type of state that container manages. - * @param Action - type for actions that can be sent to [dispatch] to request state updates. */ @Stable -interface NavigationContainer<State : NavigationState, in Action : NavigationAction<State>> { +interface NavigationContainer<State : NavigationState> { val navigationStateFlow: StateFlow<State> - fun dispatch(action: Action, vararg actions: Action) + fun dispatch(reducer: NavigationReducer<State>) +} + +/** + * Extension to allow passing several reducers as a single atomic operation. + * Reducers are applied in order, and the resulting state is dispatched once. + * + * This is particularly important for animations and other UI functionalities that depend on + * a single state transition to avoid intermediate inconsistent states or multiple UI updates. + */ +fun <State : NavigationState> NavigationContainer<State>.dispatch( + reducer: NavigationReducer<State>, + vararg reducers: NavigationReducer<State> +) { + dispatch { oldState -> + var state = reducer.reduce(oldState) + for (r in reducers) { + state = r.reduce(state) + } + state + } } diff --git a/modo-compose/src/main/java/com/github/terrakok/modo/RootScreen.kt b/modo-compose/src/main/java/com/github/terrakok/modo/RootScreen.kt index a28d70a4..87925fd5 100644 --- a/modo-compose/src/main/java/com/github/terrakok/modo/RootScreen.kt +++ b/modo-compose/src/main/java/com/github/terrakok/modo/RootScreen.kt @@ -20,8 +20,8 @@ data class RootScreenState<T : Screen>( */ @Parcelize class RootScreen<T : Screen> internal constructor( - private val navModel: NavModel<RootScreenState<T>, NavigationAction<RootScreenState<T>>> -) : ContainerScreen<RootScreenState<T>, NavigationAction<RootScreenState<T>>>( + private val navModel: NavModel<RootScreenState<T>> +) : ContainerScreen<RootScreenState<T>>( navModel ) { diff --git a/modo-compose/src/main/java/com/github/terrakok/modo/list/ListNavigationState.kt b/modo-compose/src/main/java/com/github/terrakok/modo/list/ListNavigationState.kt index c490b087..ea7c3bb9 100644 --- a/modo-compose/src/main/java/com/github/terrakok/modo/list/ListNavigationState.kt +++ b/modo-compose/src/main/java/com/github/terrakok/modo/list/ListNavigationState.kt @@ -7,15 +7,15 @@ import com.github.terrakok.modo.NavigationState import com.github.terrakok.modo.Screen import kotlinx.parcelize.Parcelize -typealias ListNavModel = NavModel<ListNavigationState, ListNavigationAction> +typealias ListNavModel = NavModel<ListNavigationState> fun ListNavModel(screens: List<Screen>): ListNavModel = NavModel(ListNavigationState(screens = screens)) -interface ListNavigationContainer : NavigationContainer<ListNavigationState, ListNavigationAction> +interface ListNavigationContainer : NavigationContainer<ListNavigationState> abstract class ListNavigationContainerScreen( navModel: ListNavModel -) : ListNavigationContainer, ContainerScreen<ListNavigationState, ListNavigationAction>(navModel) +) : ListNavigationContainer, ContainerScreen<ListNavigationState>(navModel) @Parcelize data class ListNavigationState( diff --git a/modo-compose/src/main/java/com/github/terrakok/modo/list/ListNavigationAction.kt b/modo-compose/src/main/java/com/github/terrakok/modo/list/ListReducer.kt similarity index 63% rename from modo-compose/src/main/java/com/github/terrakok/modo/list/ListNavigationAction.kt rename to modo-compose/src/main/java/com/github/terrakok/modo/list/ListReducer.kt index 1472a40a..a46180dc 100644 --- a/modo-compose/src/main/java/com/github/terrakok/modo/list/ListNavigationAction.kt +++ b/modo-compose/src/main/java/com/github/terrakok/modo/list/ListReducer.kt @@ -1,18 +1,21 @@ package com.github.terrakok.modo.list import com.github.terrakok.modo.NavigationContainer -import com.github.terrakok.modo.ReducerAction +import com.github.terrakok.modo.NavigationReducer import com.github.terrakok.modo.Screen import com.github.terrakok.modo.ScreenKey -fun interface ListNavigationAction : ReducerAction<ListNavigationState> { +@Deprecated("Use ListReducer instead.", ReplaceWith("ListReducer")) +typealias ListNavigationAction = ListReducer + +fun interface ListReducer : NavigationReducer<ListNavigationState> { class RemoveScreens private constructor( - private val reducer: ReducerAction<ListNavigationState> - ) : ListNavigationAction { + private val reducer: NavigationReducer<ListNavigationState> + ) : ListReducer { constructor(removeCondition: (pos: Int, screen: Screen) -> Boolean) : this( - ReducerAction { oldState -> + NavigationReducer { oldState -> ListNavigationState( oldState.screens.filterIndexed { index, screen -> !removeCondition(index, screen) } ) @@ -20,7 +23,7 @@ fun interface ListNavigationAction : ReducerAction<ListNavigationState> { ) constructor(screenToRemove: Screen, vararg screensToRemove: Screen) : this( - ReducerAction { oldState -> + NavigationReducer { oldState -> val screensToRemoveSet = screensToRemove.toMutableSet().apply { add(screenToRemove) } ListNavigationState( oldState.screens.filter { screen -> screen !in screensToRemoveSet } @@ -36,7 +39,7 @@ fun interface ListNavigationAction : ReducerAction<ListNavigationState> { // Unable to use vararg because of https://youtrack.jetbrains.com/issue/KT-33565/Allow-vararg-parameter-of-inline-class-type constructor(screenKeysToRemove: Set<ScreenKey>) : this( - ReducerAction { oldState -> + NavigationReducer { oldState -> ListNavigationState( oldState.screens.filter { screen -> screen.screenKey !in screenKeysToRemove } ) @@ -51,11 +54,11 @@ fun interface ListNavigationAction : ReducerAction<ListNavigationState> { } class AddScreens private constructor( - private val reducer: ReducerAction<ListNavigationState> - ) : ListNavigationAction { + private val reducer: NavigationReducer<ListNavigationState> + ) : ListReducer { constructor(pos: Int, screen: Screen, vararg screens: Screen) : this( - ReducerAction { oldState -> + NavigationReducer { oldState -> val newScreensCount = screens.size + 1 ListNavigationState( List(oldState.screens.size + newScreensCount) { @@ -71,7 +74,7 @@ fun interface ListNavigationAction : ReducerAction<ListNavigationState> { ) constructor(screen: Screen, vararg screens: Screen, addToEnd: Boolean = false) : this( - ReducerAction { oldState -> + NavigationReducer { oldState -> ListNavigationState( if (addToEnd) { List(oldState.screens.size + screens.size + 1) { @@ -98,17 +101,17 @@ fun interface ListNavigationAction : ReducerAction<ListNavigationState> { } class SetScreens private constructor( - private val reducer: ReducerAction<ListNavigationState> - ) : ListNavigationAction { + private val reducer: NavigationReducer<ListNavigationState> + ) : ListReducer { constructor(vararg screens: Screen) : this( - ReducerAction { _ -> + NavigationReducer { _ -> ListNavigationState(screens.toList()) } ) constructor(screens: List<Screen>) : this( - ReducerAction { _ -> ListNavigationState(screens) } + NavigationReducer { _ -> ListNavigationState(screens) } ) override fun reduce(oldState: ListNavigationState): ListNavigationState = reducer.reduce(oldState) @@ -116,29 +119,29 @@ fun interface ListNavigationAction : ReducerAction<ListNavigationState> { } -fun NavigationContainer<ListNavigationState, ListNavigationAction>.dispatch(action: (ListNavigationState) -> ListNavigationState) = - dispatch(ListNavigationAction(action)) +fun NavigationContainer<ListNavigationState>.dispatch(action: (ListNavigationState) -> ListNavigationState) = + dispatch(NavigationReducer(action)) -fun NavigationContainer<ListNavigationState, ListNavigationAction>.addScreens(pos: Int, screen: Screen, vararg screens: Screen) = - dispatch(ListNavigationAction.AddScreens(pos, screen, *screens)) +fun NavigationContainer<ListNavigationState>.addScreens(pos: Int, screen: Screen, vararg screens: Screen) = + dispatch(ListReducer.AddScreens(pos, screen, *screens)) -fun NavigationContainer<ListNavigationState, ListNavigationAction>.addScreens(screen: Screen, vararg screens: Screen, addToEnd: Boolean = false) = - dispatch(ListNavigationAction.AddScreens(screen, *screens, addToEnd = addToEnd)) +fun NavigationContainer<ListNavigationState>.addScreens(screen: Screen, vararg screens: Screen, addToEnd: Boolean = false) = + dispatch(ListReducer.AddScreens(screen, *screens, addToEnd = addToEnd)) -fun NavigationContainer<ListNavigationState, ListNavigationAction>.removeScreens(removeCondition: (pos: Int, screen: Screen) -> Boolean) = - dispatch(ListNavigationAction.RemoveScreens(removeCondition)) +fun NavigationContainer<ListNavigationState>.removeScreens(removeCondition: (pos: Int, screen: Screen) -> Boolean) = + dispatch(ListReducer.RemoveScreens(removeCondition)) -fun NavigationContainer<ListNavigationState, ListNavigationAction>.removeScreen(screenKeyToRemove: ScreenKey) = - dispatch(ListNavigationAction.RemoveScreens(screenKeyToRemove)) +fun NavigationContainer<ListNavigationState>.removeScreen(screenKeyToRemove: ScreenKey) = + dispatch(ListReducer.RemoveScreens(screenKeyToRemove)) -fun NavigationContainer<ListNavigationState, ListNavigationAction>.removeScreens(screenToRemove: Screen) = - dispatch(ListNavigationAction.RemoveScreens(screenToRemove)) +fun NavigationContainer<ListNavigationState>.removeScreens(screenToRemove: Screen) = + dispatch(ListReducer.RemoveScreens(screenToRemove)) -inline fun <reified T : Screen> NavigationContainer<ListNavigationState, ListNavigationAction>.removeScreens() = - dispatch(ListNavigationAction.RemoveScreens<T>()) +inline fun <reified T : Screen> NavigationContainer<ListNavigationState>.removeScreens() = + dispatch(ListReducer.RemoveScreens<T>()) -fun NavigationContainer<ListNavigationState, ListNavigationAction>.setScreens(vararg screens: Screen) = - dispatch(ListNavigationAction.SetScreens(*screens)) +fun NavigationContainer<ListNavigationState>.setScreens(vararg screens: Screen) = + dispatch(ListReducer.SetScreens(*screens)) -fun NavigationContainer<ListNavigationState, ListNavigationAction>.removeAllScreens() = - dispatch(ListNavigationAction.SetScreens()) \ No newline at end of file +fun NavigationContainer<ListNavigationState>.removeAllScreens() = + dispatch(ListReducer.SetScreens()) \ No newline at end of file diff --git a/modo-compose/src/main/java/com/github/terrakok/modo/multiscreen/MultiScreen.kt b/modo-compose/src/main/java/com/github/terrakok/modo/multiscreen/MultiScreen.kt index 43ef2887..aae5a27f 100644 --- a/modo-compose/src/main/java/com/github/terrakok/modo/multiscreen/MultiScreen.kt +++ b/modo-compose/src/main/java/com/github/terrakok/modo/multiscreen/MultiScreen.kt @@ -15,7 +15,7 @@ val LocalMultiScreenNavigation: ProvidableCompositionLocal<MultiScreen> = static abstract class MultiScreen( navigationModel: MultiScreenNavModel -) : ContainerScreen<MultiScreenState, MultiScreenAction>(navigationModel), MultiScreenNavContainer { +) : ContainerScreen<MultiScreenState>(navigationModel), MultiScreenNavContainer { @Composable override fun Content(modifier: Modifier) { diff --git a/modo-compose/src/main/java/com/github/terrakok/modo/multiscreen/MultiScreenActions.kt b/modo-compose/src/main/java/com/github/terrakok/modo/multiscreen/MultiScreenActions.kt index 52f6bd21..eda2f4af 100644 --- a/modo-compose/src/main/java/com/github/terrakok/modo/multiscreen/MultiScreenActions.kt +++ b/modo-compose/src/main/java/com/github/terrakok/modo/multiscreen/MultiScreenActions.kt @@ -2,18 +2,15 @@ package com.github.terrakok.modo.multiscreen import com.github.terrakok.modo.NavigationAction import com.github.terrakok.modo.NavigationContainer +import com.github.terrakok.modo.NavigationReducer import com.github.terrakok.modo.ReducerAction -interface MultiScreenAction : NavigationAction<MultiScreenState> -fun interface MultiScreenReducerAction : MultiScreenAction, ReducerAction<MultiScreenState> +@Deprecated("Use MultiScreenReducer instead.", ReplaceWith("MultiScreenReducer")) +typealias MultiScreenReducerAction = MultiScreenReducer -@Deprecated( - message = "Class with this name was renamed to SelectScreen. This typealias will be removed in further releases.", - replaceWith = ReplaceWith("SetMultiScreenState") -) -typealias SetContainers = SetMultiScreenState +fun interface MultiScreenReducer : NavigationReducer<MultiScreenState> -class SetMultiScreenState(val state: MultiScreenState) : MultiScreenReducerAction { +class SetMultiScreenState(val state: MultiScreenState) : MultiScreenReducer { override fun reduce(oldState: MultiScreenState): MultiScreenState = state } @@ -24,25 +21,25 @@ class SetMultiScreenState(val state: MultiScreenState) : MultiScreenReducerActio ) typealias SelectContainer = SelectScreen -class SelectScreen(private val pos: Int) : MultiScreenReducerAction { +class SelectScreen(private val pos: Int) : MultiScreenReducer { override fun reduce(oldState: MultiScreenState): MultiScreenState = oldState.copy(selected = pos) } -fun MultiScreenNavContainer.dispatch(action: (MultiScreenState) -> MultiScreenState) = dispatch(MultiScreenReducerAction(action)) +fun MultiScreenNavContainer.dispatch(action: (MultiScreenState) -> MultiScreenState) = dispatch(NavigationReducer(action)) @Deprecated( message = "This function was renamed to setState. This function will be removed in further releases.", replaceWith = ReplaceWith("setState(state)") ) -fun NavigationContainer<MultiScreenState, MultiScreenAction>.setContainers(state: MultiScreenState) = setState(state) +fun NavigationContainer<MultiScreenState>.setContainers(state: MultiScreenState) = setState(state) @Deprecated( message = "This function was renamed to selectScreen. This function will be removed in further releases.", replaceWith = ReplaceWith("selectScreen(index)") ) -fun NavigationContainer<MultiScreenState, MultiScreenAction>.selectContainer(index: Int) = selectScreen(index) +fun NavigationContainer<MultiScreenState>.selectContainer(index: Int) = selectScreen(index) -fun NavigationContainer<MultiScreenState, MultiScreenAction>.setState(state: MultiScreenState) = dispatch(SetMultiScreenState(state)) +fun NavigationContainer<MultiScreenState>.setState(state: MultiScreenState) = dispatch(SetMultiScreenState(state)) -fun NavigationContainer<MultiScreenState, MultiScreenAction>.selectScreen(pos: Int) = dispatch(SelectScreen(pos)) \ No newline at end of file +fun NavigationContainer<MultiScreenState>.selectScreen(pos: Int) = dispatch(SelectScreen(pos)) \ No newline at end of file diff --git a/modo-compose/src/main/java/com/github/terrakok/modo/multiscreen/MultiScreenState.kt b/modo-compose/src/main/java/com/github/terrakok/modo/multiscreen/MultiScreenState.kt index 80c27d21..bcf80fa6 100644 --- a/modo-compose/src/main/java/com/github/terrakok/modo/multiscreen/MultiScreenState.kt +++ b/modo-compose/src/main/java/com/github/terrakok/modo/multiscreen/MultiScreenState.kt @@ -7,9 +7,9 @@ import com.github.terrakok.modo.NavigationState import com.github.terrakok.modo.Screen import kotlinx.parcelize.Parcelize -typealias MultiScreenNavModel = NavModel<MultiScreenState, MultiScreenAction> +typealias MultiScreenNavModel = NavModel<MultiScreenState> -interface MultiScreenNavContainer : NavigationContainer<MultiScreenState, MultiScreenAction> +interface MultiScreenNavContainer : NavigationContainer<MultiScreenState> fun MultiScreenNavModel( screens: List<Screen>, diff --git a/modo-compose/src/main/java/com/github/terrakok/modo/stack/StackActions.kt b/modo-compose/src/main/java/com/github/terrakok/modo/stack/StackActions.kt index d5be90db..d6cff7b0 100644 --- a/modo-compose/src/main/java/com/github/terrakok/modo/stack/StackActions.kt +++ b/modo-compose/src/main/java/com/github/terrakok/modo/stack/StackActions.kt @@ -1,16 +1,17 @@ package com.github.terrakok.modo.stack -import com.github.terrakok.modo.NavigationAction +import com.github.terrakok.modo.NavigationReducer import com.github.terrakok.modo.NavigationContainer import com.github.terrakok.modo.ReducerAction import com.github.terrakok.modo.Screen import com.github.terrakok.modo.ScreenKey -interface StackAction : NavigationAction<StackState> +@Deprecated("Use StackReducer instead.", ReplaceWith("StackReducer")) +fun interface StackReducerAction : ReducerAction<StackState> -fun interface StackReducerAction : StackAction, ReducerAction<StackState> +fun interface StackReducer : NavigationReducer<StackState> -class SetStack(val state: StackState) : StackReducerAction { +class SetStack(val state: StackState) : StackReducer { @Suppress("SpreadOperator") constructor(screen: Screen, vararg screens: Screen) : this( StackState(listOf(screen, *screens)) @@ -20,14 +21,14 @@ class SetStack(val state: StackState) : StackReducerAction { state } -class Forward(val screen: Screen, vararg val screens: Screen) : StackReducerAction { +class Forward(val screen: Screen, vararg val screens: Screen) : StackReducer { @Suppress("SpreadOperator") override fun reduce(oldState: StackState): StackState = StackState( oldState.stack + listOf(screen, *screens) ) } -class Replace(val screen: Screen, vararg val screens: Screen) : StackReducerAction { +class Replace(val screen: Screen, vararg val screens: Screen) : StackReducer { @Suppress("SpreadOperator") override fun reduce(oldState: StackState): StackState = if (oldState.stack.isNotEmpty()) { StackState( @@ -45,7 +46,7 @@ class Replace(val screen: Screen, vararg val screens: Screen) : StackReducerActi class BackTo( val backToCondition: (pos: Int, screen: Screen) -> Boolean, val including: Boolean = false -) : StackReducerAction { +) : StackReducer { constructor(screenKey: ScreenKey, including: Boolean = false) : this( { _, screen -> @@ -78,17 +79,17 @@ class BackTo( } companion object { - inline operator fun <reified T : Screen> invoke(including: Boolean = false): StackReducerAction = BackTo( + inline operator fun <reified T : Screen> invoke(including: Boolean = false): StackReducer = BackTo( { _, screen -> screen is T }, including ) - operator fun invoke(including: Boolean = false, condition: (pos: Int, screen: Screen) -> Boolean): StackReducerAction = + operator fun invoke(including: Boolean = false, condition: (pos: Int, screen: Screen) -> Boolean): StackReducer = BackTo(condition, including) } } -class RemoveScreens(val condition: (pos: Int, screen: Screen) -> Boolean) : StackReducerAction { +class RemoveScreens(val condition: (pos: Int, screen: Screen) -> Boolean) : StackReducer { override fun reduce(oldState: StackState): StackState = StackState( oldState.stack.filterIndexed { i, screen -> !condition(i, screen) } ) @@ -101,7 +102,7 @@ class RemoveScreens(val condition: (pos: Int, screen: Screen) -> Boolean) : Stac class Back( private val screensToDrop: Int = 1, private val canEmptyStack: Boolean = false -) : StackReducerAction { +) : StackReducer { override fun reduce(oldState: StackState): StackState = if (canEmptyStack || oldState.stack.size > 1) { StackState(oldState.stack.dropLast(screensToDrop)) @@ -110,28 +111,28 @@ class Back( } } -fun StackNavContainer.dispatch(action: (StackState) -> StackState) = dispatch(StackReducerAction(action)) +fun StackNavContainer.dispatch(action: (StackState) -> StackState) = dispatch(NavigationReducer(action)) -fun NavigationContainer<StackState, StackAction>.forward(screen: Screen, vararg screens: Screen) = dispatch(Forward(screen, *screens)) -fun NavigationContainer<StackState, StackAction>.replace(screen: Screen, vararg screens: Screen) = dispatch(Replace(screen, *screens)) -fun NavigationContainer<StackState, StackAction>.setStack(screen: Screen, vararg screens: Screen) = dispatch(SetStack(screen, *screens)) -fun NavigationContainer<StackState, StackAction>.setState(state: StackState) = dispatch(SetStack(state)) -fun NavigationContainer<StackState, StackAction>.clearStack() = dispatch(SetStack(StackState())) +fun NavigationContainer<StackState>.forward(screen: Screen, vararg screens: Screen) = dispatch(Forward(screen, *screens)) +fun NavigationContainer<StackState>.replace(screen: Screen, vararg screens: Screen) = dispatch(Replace(screen, *screens)) +fun NavigationContainer<StackState>.setStack(screen: Screen, vararg screens: Screen) = dispatch(SetStack(screen, *screens)) +fun NavigationContainer<StackState>.setState(state: StackState) = dispatch(SetStack(state)) +fun NavigationContainer<StackState>.clearStack() = dispatch(SetStack(StackState())) -inline fun <reified T : Screen> NavigationContainer<StackState, StackAction>.backTo(including: Boolean = false) = dispatch(BackTo<T>(including)) -fun NavigationContainer<StackState, StackAction>.backTo(screen: Screen, including: Boolean = false) = dispatch(BackTo(screen, including)) -fun NavigationContainer<StackState, StackAction>.backTo(screenKey: ScreenKey, including: Boolean = false) = dispatch(BackTo(screenKey, including)) -fun NavigationContainer<StackState, StackAction>.backTo(pos: Int, including: Boolean = false) = backTo(including) { backToPos, _ -> pos == backToPos } -fun NavigationContainer<StackState, StackAction>.backTo(including: Boolean = false, backToCondition: (pos: Int, screen: Screen) -> Boolean) = +inline fun <reified T : Screen> NavigationContainer<StackState>.backTo(including: Boolean = false) = dispatch(BackTo<T>(including)) +fun NavigationContainer<StackState>.backTo(screen: Screen, including: Boolean = false) = dispatch(BackTo(screen, including)) +fun NavigationContainer<StackState>.backTo(screenKey: ScreenKey, including: Boolean = false) = dispatch(BackTo(screenKey, including)) +fun NavigationContainer<StackState>.backTo(pos: Int, including: Boolean = false) = backTo(including) { backToPos, _ -> pos == backToPos } +fun NavigationContainer<StackState>.backTo(including: Boolean = false, backToCondition: (pos: Int, screen: Screen) -> Boolean) = dispatch(BackTo(including, backToCondition)) -fun NavigationContainer<StackState, StackAction>.backToRoot() = backTo(0) +fun NavigationContainer<StackState>.backToRoot() = backTo(0) -fun NavigationContainer<StackState, StackAction>.removeScreens(condition: (pos: Int, screen: Screen) -> Boolean) = dispatch(RemoveScreens(condition)) +fun NavigationContainer<StackState>.removeScreens(condition: (pos: Int, screen: Screen) -> Boolean) = dispatch(RemoveScreens(condition)) /** * @param screensToDrop count of screens to drop from top of the stack * @param canEmptyStack if true, then stack can be empty after this action */ -fun NavigationContainer<StackState, StackAction>.back(screensToDrop: Int = 1, canEmptyStack: Boolean = false) = +fun NavigationContainer<StackState>.back(screensToDrop: Int = 1, canEmptyStack: Boolean = false) = dispatch(Back(screensToDrop, canEmptyStack)) \ No newline at end of file diff --git a/modo-compose/src/main/java/com/github/terrakok/modo/stack/StackScreen.kt b/modo-compose/src/main/java/com/github/terrakok/modo/stack/StackScreen.kt index eab2c6b8..5446b166 100644 --- a/modo-compose/src/main/java/com/github/terrakok/modo/stack/StackScreen.kt +++ b/modo-compose/src/main/java/com/github/terrakok/modo/stack/StackScreen.kt @@ -39,7 +39,7 @@ val LocalStackNavigation: ProvidableCompositionLocal<StackScreen> = staticCompos @Stable abstract class StackScreen( navigationModel: StackNavModel -) : ContainerScreen<StackState, StackAction>(navigationModel), StackNavContainer { +) : ContainerScreen<StackState>(navigationModel), StackNavContainer { open val defaultBackHandler: Boolean = true @@ -51,7 +51,7 @@ abstract class StackScreen( TopScreenContent(modifier) } - override fun provideNavigationContainer(): ProvidedValue<StackScreen> = + override fun provideNavigationContainer(): ProvidedValue<out StackNavContainer> = LocalStackNavigation provides this /** diff --git a/modo-compose/src/main/java/com/github/terrakok/modo/stack/StackState.kt b/modo-compose/src/main/java/com/github/terrakok/modo/stack/StackState.kt index 76fa8d79..42a9875f 100644 --- a/modo-compose/src/main/java/com/github/terrakok/modo/stack/StackState.kt +++ b/modo-compose/src/main/java/com/github/terrakok/modo/stack/StackState.kt @@ -8,14 +8,14 @@ import com.github.terrakok.modo.NavigationState import com.github.terrakok.modo.Screen import kotlinx.parcelize.Parcelize -typealias StackNavModel = NavModel<StackState, StackAction> +typealias StackNavModel = NavModel<StackState> -fun StackNavModel(stack: List<Screen>): StackNavModel = StackNavModel(StackState(stack)) -fun StackNavModel(screen: Screen): StackNavModel = StackNavModel(listOf(screen)) -fun StackNavModel(vararg screens: Screen): StackNavModel = StackNavModel(screens.toList()) +fun StackNavModel(stack: List<Screen>): StackNavModel = NavModel(StackState(stack)) +fun StackNavModel(screen: Screen): StackNavModel = NavModel(StackState(listOf(screen))) +fun StackNavModel(vararg screens: Screen): StackNavModel = NavModel(StackState(screens.toList())) @Stable -interface StackNavContainer : NavigationContainer<StackState, StackAction> +interface StackNavContainer : NavigationContainer<StackState> @Parcelize data class StackState( diff --git a/modo-compose/src/main/java/com/github/terrakok/modo/util/NavigationLogger.kt b/modo-compose/src/main/java/com/github/terrakok/modo/util/NavigationLogger.kt index 0d315287..3991a5d2 100644 --- a/modo-compose/src/main/java/com/github/terrakok/modo/util/NavigationLogger.kt +++ b/modo-compose/src/main/java/com/github/terrakok/modo/util/NavigationLogger.kt @@ -18,7 +18,7 @@ private fun getNavigationStateString(prefix: String, navigationState: Navigation is StackState -> { navigationState.stack.map { screen -> when (screen) { - is ContainerScreen<*, *> -> buildString { + is ContainerScreen<*> -> buildString { append(prefix) append(screen.screenKey) appendLine() @@ -35,7 +35,7 @@ private fun getNavigationStateString(prefix: String, navigationState: Navigation append(prefix) append(screen.screenKey) appendLine() - if (screen is ContainerScreen<*, *>) { + if (screen is ContainerScreen<*>) { append(getNavigationStateString("$prefix| ", screen.navigationState)) } } diff --git a/modo-compose/src/test/java/com/github/terrakok/modo/ComposeRendererDisposalTest.kt b/modo-compose/src/test/java/com/github/terrakok/modo/ComposeRendererDisposalTest.kt new file mode 100644 index 00000000..da7e47ba --- /dev/null +++ b/modo-compose/src/test/java/com/github/terrakok/modo/ComposeRendererDisposalTest.kt @@ -0,0 +1,81 @@ +package com.github.terrakok.modo + +import android.os.Parcel +import android.os.Parcelable +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.isActive +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import kotlinx.parcelize.Parcelize +import org.junit.jupiter.api.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +@Suppress("DEPRECATION") +class ComposeRendererDisposalTest { + + @Test + fun `When ComposeRenderer is created - Then scope is active`() { + Dispatchers.setMain(UnconfinedTestDispatcher()) + try { + val renderer = createTestRenderer() + assertTrue(renderer.scope.isActive) + } finally { + Dispatchers.resetMain() + } + } + + @Test + fun `When ComposeRenderer dispose is called - Then scope is cancelled`() { + Dispatchers.setMain(UnconfinedTestDispatcher()) + try { + val renderer = createTestRenderer() + assertTrue(renderer.scope.isActive, "Scope should be active after creation") + + renderer.dispose() + + assertFalse(renderer.scope.isActive, "Scope should be inactive after dispose") + } finally { + Dispatchers.resetMain() + } + } + + @Test + fun `When container screen is removed from tree - Then renderer can be disposed`() { + Dispatchers.setMain(UnconfinedTestDispatcher()) + try { + val renderer = createTestRenderer() + + // Simulate removing container screen and calling dispose + assertTrue(renderer.scope.isActive) + renderer.dispose() + assertFalse(renderer.scope.isActive) + } finally { + Dispatchers.resetMain() + } + } + + private fun createTestRenderer(): ComposeRenderer<MockNavigationState> { + val state = MockNavigationState() + val navModel: NavModel<MockNavigationState> = NavModel(state) + @Suppress("UNCHECKED_CAST") + val containerScreen = object : ContainerScreen<MockNavigationState>(navModel), + Parcelable { + @Composable + override fun Content(modifier: Modifier) = Unit + + override fun describeContents(): Int = 0 + + override fun writeToParcel(parcel: Parcel, flags: Int) {} + } + return ComposeRenderer(containerScreen, navModel.navigationStateFlow) + } + + @Parcelize + private class MockNavigationState : NavigationState { + override fun getChildScreens(): List<Screen> = emptyList() + } +} diff --git a/modo-compose/src/test/java/com/github/terrakok/modo/ModoRootScreenCacheTest.kt b/modo-compose/src/test/java/com/github/terrakok/modo/ModoRootScreenCacheTest.kt index e95bd556..e7e101df 100644 --- a/modo-compose/src/test/java/com/github/terrakok/modo/ModoRootScreenCacheTest.kt +++ b/modo-compose/src/test/java/com/github/terrakok/modo/ModoRootScreenCacheTest.kt @@ -179,7 +179,8 @@ class ModoRootScreenCacheTest { // endregion private fun mockBundle(rootScreen: RootScreen<*>, counter: Int): Bundle = mockk { - every { getParcelable<RootScreen<*>>("MODO_GRAPH") } returns rootScreen + every { getParcelable<RootScreen<*>>("MODO_GRAPH", RootScreen::class.java) } returns rootScreen + every { @Suppress("DEPRECATION") getParcelable<RootScreen<*>>("MODO_GRAPH") } returns rootScreen every { getInt("MODO_SCREEN_COUNTER_KEY") } returns counter } } diff --git a/modo-compose/src/test/java/com/github/terrakok/modo/list/ListNavigationActionAddScreensTest.kt b/modo-compose/src/test/java/com/github/terrakok/modo/list/ListReducerAddScreensTest.kt similarity index 85% rename from modo-compose/src/test/java/com/github/terrakok/modo/list/ListNavigationActionAddScreensTest.kt rename to modo-compose/src/test/java/com/github/terrakok/modo/list/ListReducerAddScreensTest.kt index 24b9ed33..51262c31 100644 --- a/modo-compose/src/test/java/com/github/terrakok/modo/list/ListNavigationActionAddScreensTest.kt +++ b/modo-compose/src/test/java/com/github/terrakok/modo/list/ListReducerAddScreensTest.kt @@ -5,13 +5,13 @@ import com.github.terrakok.modo.ScreenKey import kotlin.test.Test import kotlin.test.assertEquals -class ListNavigationActionAddScreensTest { +class ListReducerAddScreensTest { @Test fun `When add screen to empty list - Then screen is added`() { val screen = MockScreen(ScreenKey("1")) val oldState = ListNavigationState(emptyList()) - val action = ListNavigationAction.AddScreens(screen) + val action = ListReducer.AddScreens(screen) val newState = action.reduce(oldState) @@ -25,7 +25,7 @@ class ListNavigationActionAddScreensTest { fun `When add screen to empty list by pos - Then screen is added`() { val screen = MockScreen(ScreenKey("1")) val oldState = ListNavigationState(emptyList()) - val action = ListNavigationAction.AddScreens(pos = 0, screen) + val action = ListReducer.AddScreens(pos = 0, screen) val newState = action.reduce(oldState) @@ -40,7 +40,7 @@ class ListNavigationActionAddScreensTest { val screen1 = MockScreen(ScreenKey("1")) val screen2 = MockScreen(ScreenKey("2")) val oldState = ListNavigationState(listOf(screen1)) - val action = ListNavigationAction.AddScreens(pos = 1, screen2) + val action = ListReducer.AddScreens(pos = 1, screen2) val newState = action.reduce(oldState) @@ -56,7 +56,7 @@ class ListNavigationActionAddScreensTest { val screen2 = MockScreen(ScreenKey("2")) val screen3 = MockScreen(ScreenKey("3")) val oldState = ListNavigationState(emptyList()) - val action = ListNavigationAction.AddScreens(screen1, screen2, screen3) + val action = ListReducer.AddScreens(screen1, screen2, screen3) val newState = action.reduce(oldState) @@ -74,7 +74,7 @@ class ListNavigationActionAddScreensTest { val screen4 = MockScreen(ScreenKey("4")) val screen5 = MockScreen(ScreenKey("5")) val oldState = ListNavigationState(listOf(screen1, screen5)) - val action = ListNavigationAction.AddScreens(pos = 1, screen2, screen3, screen4) + val action = ListReducer.AddScreens(pos = 1, screen2, screen3, screen4) val newState = action.reduce(oldState) @@ -90,7 +90,7 @@ class ListNavigationActionAddScreensTest { val screen2 = MockScreen(ScreenKey("2")) val screen3 = MockScreen(ScreenKey("3")) val oldState = ListNavigationState(emptyList()) - val action = ListNavigationAction.AddScreens(pos = 0, screen1, screen2, screen3) + val action = ListReducer.AddScreens(pos = 0, screen1, screen2, screen3) val newState = action.reduce(oldState) @@ -106,7 +106,7 @@ class ListNavigationActionAddScreensTest { val screen2 = MockScreen(ScreenKey("2")) val screen3 = MockScreen(ScreenKey("3")) val oldState = ListNavigationState(listOf()) - val action = ListNavigationAction.AddScreens(screen1, screen2, screen3, addToEnd = true) + val action = ListReducer.AddScreens(screen1, screen2, screen3, addToEnd = true) val newState = action.reduce(oldState) @@ -122,7 +122,7 @@ class ListNavigationActionAddScreensTest { val screen2 = MockScreen(ScreenKey("2")) val screen3 = MockScreen(ScreenKey("3")) val oldState = ListNavigationState(listOf()) - val action = ListNavigationAction.AddScreens(screen1, screen2, screen3) + val action = ListReducer.AddScreens(screen1, screen2, screen3) val newState = action.reduce(oldState) @@ -138,7 +138,7 @@ class ListNavigationActionAddScreensTest { val screen2 = MockScreen(ScreenKey("2")) val screen3 = MockScreen(ScreenKey("3")) val oldState = ListNavigationState(listOf(screen1)) - val action = ListNavigationAction.AddScreens(screen2, screen3, addToEnd = true) + val action = ListReducer.AddScreens(screen2, screen3, addToEnd = true) val newState = action.reduce(oldState) @@ -154,7 +154,7 @@ class ListNavigationActionAddScreensTest { val screen2 = MockScreen(ScreenKey("2")) val screen3 = MockScreen(ScreenKey("3")) val oldState = ListNavigationState(listOf(screen3)) - val action = ListNavigationAction.AddScreens(screen1, screen2) + val action = ListReducer.AddScreens(screen1, screen2) val newState = action.reduce(oldState) diff --git a/modo-compose/src/test/java/com/github/terrakok/modo/list/ListNavigationActionRemoveScreensTest.kt b/modo-compose/src/test/java/com/github/terrakok/modo/list/ListReducerRemoveScreensTest.kt similarity index 84% rename from modo-compose/src/test/java/com/github/terrakok/modo/list/ListNavigationActionRemoveScreensTest.kt rename to modo-compose/src/test/java/com/github/terrakok/modo/list/ListReducerRemoveScreensTest.kt index c068c134..ead27ab3 100644 --- a/modo-compose/src/test/java/com/github/terrakok/modo/list/ListNavigationActionRemoveScreensTest.kt +++ b/modo-compose/src/test/java/com/github/terrakok/modo/list/ListReducerRemoveScreensTest.kt @@ -5,13 +5,13 @@ import com.github.terrakok.modo.ScreenKey import kotlin.test.Test import kotlin.test.assertContentEquals -class ListNavigationActionRemoveScreensTest { +class ListReducerRemoveScreensTest { @Test fun `When remove screen by key - Then screen is removed`() { val screen = MockScreen(ScreenKey("2")) val oldState = ListNavigationState(listOf(MockScreen(ScreenKey("1")), screen)) - val action = ListNavigationAction.RemoveScreens(ScreenKey("1")) + val action = ListReducer.RemoveScreens(ScreenKey("1")) val newState = action.reduce(oldState) @@ -29,7 +29,7 @@ class ListNavigationActionRemoveScreensTest { val screen4 = MockScreen(ScreenKey("3")) val screen5 = MockScreen(ScreenKey("3")) val oldState = ListNavigationState(listOf(screen1, screen2, screen3, screen4)) - val action = ListNavigationAction.RemoveScreens(screen1, screen3, screen5) + val action = ListReducer.RemoveScreens(screen1, screen3, screen5) val newState = action.reduce(oldState) @@ -45,7 +45,7 @@ class ListNavigationActionRemoveScreensTest { val screen2 = MockScreen(ScreenKey("2")) val screen3 = MockScreen(ScreenKey("3")) val oldState = ListNavigationState(listOf(screen1, screen2, screen3)) - val action = ListNavigationAction.RemoveScreens { _, screen -> screen.screenKey.value == "2" } + val action = ListReducer.RemoveScreens { _, screen -> screen.screenKey.value == "2" } val newState = action.reduce(oldState) @@ -61,7 +61,7 @@ class ListNavigationActionRemoveScreensTest { val screen2 = MockScreen(ScreenKey("2")) val screen3 = MockScreen(ScreenKey("3")) val oldState = ListNavigationState(listOf(screen1, screen2, screen3)) - val action = ListNavigationAction.RemoveScreens(setOf(ScreenKey("1"), ScreenKey("3"))) + val action = ListReducer.RemoveScreens(setOf(ScreenKey("1"), ScreenKey("3"))) val newState = action.reduce(oldState) @@ -77,7 +77,7 @@ class ListNavigationActionRemoveScreensTest { val screen2 = MockScreen(ScreenKey("2")) val screen3 = MockScreen(ScreenKey("3")) val oldState = ListNavigationState(listOf(screen1, screen2, screen3)) - val action = ListNavigationAction.RemoveScreens<MockScreen>() + val action = ListReducer.RemoveScreens<MockScreen>() val newState = action.reduce(oldState) diff --git a/modo-compose/src/test/java/com/github/terrakok/modo/list/ListNavigationActionSetTest.kt b/modo-compose/src/test/java/com/github/terrakok/modo/list/ListReducerSetTest.kt similarity index 86% rename from modo-compose/src/test/java/com/github/terrakok/modo/list/ListNavigationActionSetTest.kt rename to modo-compose/src/test/java/com/github/terrakok/modo/list/ListReducerSetTest.kt index e6316dd8..82a8a8c1 100644 --- a/modo-compose/src/test/java/com/github/terrakok/modo/list/ListNavigationActionSetTest.kt +++ b/modo-compose/src/test/java/com/github/terrakok/modo/list/ListReducerSetTest.kt @@ -5,7 +5,7 @@ import com.github.terrakok.modo.ScreenKey import kotlin.test.Test import kotlin.test.assertEquals -class ListNavigationActionSetTest { +class ListReducerSetTest { @Test fun `When set screens - Then screens are set`() { @@ -13,7 +13,7 @@ class ListNavigationActionSetTest { val screen2 = MockScreen(ScreenKey("2")) val screen3 = MockScreen(ScreenKey("3")) val oldState = ListNavigationState(listOf(screen1, screen2)) - val action = ListNavigationAction.SetScreens(screen3) + val action = ListReducer.SetScreens(screen3) val newState = action.reduce(oldState) @@ -29,7 +29,7 @@ class ListNavigationActionSetTest { val screen2 = MockScreen(ScreenKey("2")) val screen3 = MockScreen(ScreenKey("3")) val oldState = ListNavigationState(listOf(screen1, screen2)) - val action = ListNavigationAction.SetScreens(listOf(screen3)) + val action = ListReducer.SetScreens(listOf(screen3)) val newState = action.reduce(oldState) diff --git a/sample/src/main/java/com/github/terrakok/modo/sample/screens/containers/AddTab.kt b/sample/src/main/java/com/github/terrakok/modo/sample/screens/containers/AddTab.kt index 1456e6eb..49f84caa 100644 --- a/sample/src/main/java/com/github/terrakok/modo/sample/screens/containers/AddTab.kt +++ b/sample/src/main/java/com/github/terrakok/modo/sample/screens/containers/AddTab.kt @@ -1,17 +1,16 @@ package com.github.terrakok.modo.sample.screens.containers +import com.github.terrakok.modo.NavigationContainer import com.github.terrakok.modo.Screen -import com.github.terrakok.modo.multiscreen.MultiScreenReducerAction +import com.github.terrakok.modo.NavigationReducer import com.github.terrakok.modo.multiscreen.MultiScreenState -class AddTab( - val id: String, - val rootScreen: Screen -) : MultiScreenReducerAction { - override fun reduce(oldState: MultiScreenState): MultiScreenState { - return MultiScreenState( - oldState.screens + SampleStack(rootScreen), - oldState.selected - ) - } +fun NavigationContainer<MultiScreenState>.addTab( + id: String, + rootScreen: Screen +) = dispatch { oldState -> + MultiScreenState( + oldState.screens + SampleStack(rootScreen), + oldState.selected + ) } \ No newline at end of file diff --git a/sample/src/main/java/com/github/terrakok/modo/sample/screens/containers/HorizontalPagerScreen.kt b/sample/src/main/java/com/github/terrakok/modo/sample/screens/containers/HorizontalPagerScreen.kt index 5a40cd7b..02bd0baa 100644 --- a/sample/src/main/java/com/github/terrakok/modo/sample/screens/containers/HorizontalPagerScreen.kt +++ b/sample/src/main/java/com/github/terrakok/modo/sample/screens/containers/HorizontalPagerScreen.kt @@ -1,5 +1,6 @@ package com.github.terrakok.modo.sample.screens.containers +import android.os.Parcelable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.WindowInsets @@ -26,17 +27,18 @@ import androidx.compose.ui.graphics.vector.rememberVectorPainter import androidx.compose.ui.unit.dp import com.github.terrakok.modo.ContainerScreen import com.github.terrakok.modo.NavModel -import com.github.terrakok.modo.list.ListNavigationAction +import com.github.terrakok.modo.NavigationReducer import com.github.terrakok.modo.list.ListNavigationState import com.github.terrakok.modo.list.removeScreens import com.github.terrakok.modo.sample.components.CancelButton import com.github.terrakok.modo.sample.screens.MainScreen import kotlinx.coroutines.launch +import kotlinx.parcelize.IgnoredOnParcel import kotlinx.parcelize.Parcelize @Parcelize class HorizontalPagerScreen( - private val navModel: NavModel<ListNavigationState, ListNavigationAction> = NavModel( + private val navModel: NavModel<ListNavigationState> = NavModel( ListNavigationState( listOf( SampleStack(MainScreen(0)), @@ -45,7 +47,7 @@ class HorizontalPagerScreen( ) ) ) -) : ContainerScreen<ListNavigationState, ListNavigationAction>(navModel) { +) : ContainerScreen<ListNavigationState>(navModel), Parcelable { @Composable override fun Content(modifier: Modifier) { @@ -75,7 +77,13 @@ class HorizontalPagerScreen( ) } IconButton( - onClick = { dispatch(AddStack) }, + onClick = { + dispatch { oldState -> + ListNavigationState( + oldState.screens + SampleStack(MainScreen(0)) + ) + } + }, modifier = Modifier.windowInsetsPadding(WindowInsets.statusBars), ) { Icon(painter = rememberVectorPainter(image = Icons.Default.Add), contentDescription = "Add") @@ -100,9 +108,4 @@ class HorizontalPagerScreen( } } - object AddStack : ListNavigationAction { - override fun reduce(oldState: ListNavigationState): ListNavigationState = ListNavigationState( - oldState.screens + SampleStack(MainScreen(0)) - ) - } } \ No newline at end of file diff --git a/sample/src/main/java/com/github/terrakok/modo/sample/screens/containers/RemoveTabAction.kt b/sample/src/main/java/com/github/terrakok/modo/sample/screens/containers/RemoveTabAction.kt deleted file mode 100644 index e0244fb0..00000000 --- a/sample/src/main/java/com/github/terrakok/modo/sample/screens/containers/RemoveTabAction.kt +++ /dev/null @@ -1,8 +0,0 @@ -package com.github.terrakok.modo.sample.screens.containers - -import com.github.terrakok.modo.multiscreen.MultiScreenAction - -/** - * The sample of the action that is handled by reducer - */ -internal class RemoveTabAction(val pos: Int) : MultiScreenAction \ No newline at end of file diff --git a/sample/src/main/java/com/github/terrakok/modo/sample/screens/containers/RemoveTabReducer.kt b/sample/src/main/java/com/github/terrakok/modo/sample/screens/containers/RemoveTabReducer.kt new file mode 100644 index 00000000..1c6e5404 --- /dev/null +++ b/sample/src/main/java/com/github/terrakok/modo/sample/screens/containers/RemoveTabReducer.kt @@ -0,0 +1,16 @@ +package com.github.terrakok.modo.sample.screens.containers + +import com.github.terrakok.modo.NavigationReducer +import com.github.terrakok.modo.multiscreen.MultiScreenState + +/** + * The sample of the action that is handled by reducer + */ +class RemoveTabReducer(private val pos: Int) : NavigationReducer<MultiScreenState> { + + override fun reduce(oldState: MultiScreenState): MultiScreenState = oldState.copy( + screens = oldState.screens.filterIndexed { index, _ -> index != pos }, + selected = if (oldState.selected == pos) 0 else oldState.selected + ) + +} \ No newline at end of file diff --git a/sample/src/main/java/com/github/terrakok/modo/sample/screens/containers/SampleMultiScreen.kt b/sample/src/main/java/com/github/terrakok/modo/sample/screens/containers/SampleMultiScreen.kt index 8ad42b0b..a0ac765e 100644 --- a/sample/src/main/java/com/github/terrakok/modo/sample/screens/containers/SampleMultiScreen.kt +++ b/sample/src/main/java/com/github/terrakok/modo/sample/screens/containers/SampleMultiScreen.kt @@ -24,15 +24,11 @@ import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import com.github.terrakok.modo.NavigationReducer import com.github.terrakok.modo.multiscreen.MultiScreen -import com.github.terrakok.modo.multiscreen.MultiScreenAction import com.github.terrakok.modo.multiscreen.MultiScreenNavModel -import com.github.terrakok.modo.multiscreen.MultiScreenState -import com.github.terrakok.modo.multiscreen.selectContainer +import com.github.terrakok.modo.multiscreen.selectScreen import com.github.terrakok.modo.sample.components.CancelButton import com.github.terrakok.modo.sample.screens.MainScreen -import kotlinx.parcelize.IgnoredOnParcel import kotlinx.parcelize.Parcelize @Suppress("MagicNumber") @@ -48,18 +44,6 @@ internal class SampleMultiScreen( ) ) : MultiScreen(navModel) { - @IgnoredOnParcel - override val reducer: NavigationReducer<MultiScreenState, MultiScreenAction> = NavigationReducer { action, state -> - if (action is RemoveTabAction && action.pos in state.screens.indices) { - state.copy( - screens = state.screens.filterIndexed { index, _ -> index != action.pos }, - selected = if (state.selected == action.pos) 0 else state.selected - ) - } else { - null - } - } - @Composable override fun Content(modifier: Modifier) { var showAllStacks by rememberSaveable { @@ -70,7 +54,7 @@ internal class SampleMultiScreen( TopContent(showAllStacks) if (navigationState.screens.size > 1) { CancelButton( - onClick = { dispatch(RemoveTabAction(navigationState.selected)) }, + onClick = { dispatch(RemoveTabReducer(navigationState.selected)) }, contentDescription = "Cansel screen", modifier = Modifier .align(Alignment.TopEnd) @@ -91,12 +75,12 @@ internal class SampleMultiScreen( modifier = Modifier.weight(1f), isSelected = navigationState.selected == tabPos, tabPos = tabPos, - onTabClick = { selectContainer(tabPos) } + onTabClick = { selectScreen(tabPos) } ) } Text( modifier = Modifier - .clickable { dispatch(AddTab(navigationState.screens.size.toString(), MainScreen(1))) } + .clickable { addTab(navigationState.screens.size.toString(), MainScreen(1)) } .padding(16.dp), textAlign = TextAlign.Center, text = "[+]" diff --git a/sample/src/main/java/com/github/terrakok/modo/sample/screens/containers/SampleStack.kt b/sample/src/main/java/com/github/terrakok/modo/sample/screens/containers/SampleStack.kt index 2b48e428..73e5f8e2 100644 --- a/sample/src/main/java/com/github/terrakok/modo/sample/screens/containers/SampleStack.kt +++ b/sample/src/main/java/com/github/terrakok/modo/sample/screens/containers/SampleStack.kt @@ -25,29 +25,25 @@ import com.github.terrakok.modo.sample.screens.base.LogLifecycle import com.github.terrakok.modo.sample.screens.dialogs.SampleBottomSheet import com.github.terrakok.modo.sample.screens.dialogs.SampleBottomSheetStack import com.github.terrakok.modo.stack.DialogPlaceHolder +import com.github.terrakok.modo.NavigationReducer import com.github.terrakok.modo.stack.StackNavModel -import com.github.terrakok.modo.stack.StackReducerAction import com.github.terrakok.modo.stack.StackScreen import com.github.terrakok.modo.stack.StackState import com.github.terrakok.modo.stack.back import kotlinx.parcelize.Parcelize -class OpenActivityAction( - private val context: Context, - private val clazz: Class<*> -) : StackReducerAction { - override fun reduce(oldState: StackState): StackState { - context.startActivity( - Intent(context, clazz) - ) - return oldState - } - - companion object { - inline operator fun <reified T : Activity> invoke(context: Context) = OpenActivityAction(context, T::class.java) - } +fun OpenActivityAction( + context: Context, + clazz: Class<*> +) = NavigationReducer<StackState> { oldState -> + context.startActivity( + Intent(context, clazz) + ) + oldState } +inline fun <reified T : Activity> OpenActivityAction(context: Context) = OpenActivityAction(context, T::class.java) + @Parcelize open class SampleStack( private val stackNavModel: StackNavModel diff --git a/sample/src/main/java/com/github/terrakok/modo/sample/screens/containers/StackInLazyColumnScreen.kt b/sample/src/main/java/com/github/terrakok/modo/sample/screens/containers/StackInLazyColumnScreen.kt index 5135d294..2553a740 100644 --- a/sample/src/main/java/com/github/terrakok/modo/sample/screens/containers/StackInLazyColumnScreen.kt +++ b/sample/src/main/java/com/github/terrakok/modo/sample/screens/containers/StackInLazyColumnScreen.kt @@ -31,10 +31,11 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.vector.rememberVectorPainter import androidx.compose.ui.unit.dp import com.github.terrakok.modo.ContainerScreen +import android.os.Parcelable import com.github.terrakok.modo.lazylist.screenItems import com.github.terrakok.modo.list.ListNavModel -import com.github.terrakok.modo.list.ListNavigationAction import com.github.terrakok.modo.list.ListNavigationState +import com.github.terrakok.modo.list.addScreens import com.github.terrakok.modo.list.removeScreens import com.github.terrakok.modo.sample.components.CancelButton import com.github.terrakok.modo.sample.screens.MainScreen @@ -53,9 +54,8 @@ class StackInLazyColumnScreen( } } ) -) : ContainerScreen<ListNavigationState, ListNavigationAction>( - navModel -) { +) : ContainerScreen<ListNavigationState>(navModel) { + @OptIn(ExperimentalFoundationApi::class) @Suppress("LongMethod") @Composable @@ -63,7 +63,7 @@ class StackInLazyColumnScreen( val lazyColumnState = rememberLazyListState() Scaffold( floatingActionButton = { - FloatingActionButton(onClick = { dispatch(ListNavigationAction.AddScreens(SampleStack(MainScreen(0)))) }) { + FloatingActionButton(onClick = { addScreens(SampleStack(MainScreen(0)), addToEnd = true) }) { Icon(painter = rememberVectorPainter(image = Icons.Default.Add), contentDescription = "Add screen") } }, @@ -90,7 +90,7 @@ class StackInLazyColumnScreen( .padding(horizontal = 16.dp) .fillMaxWidth(), onClick = { - dispatch(ListNavigationAction.AddScreens(pos = 0, SampleStack(MainScreen(0)))) + addScreens(pos = 0, SampleStack(MainScreen(0))) } ) { Text(text = "Add item", modifier = Modifier.align(Alignment.CenterVertically)) @@ -124,7 +124,7 @@ class StackInLazyColumnScreen( .fillMaxWidth() .windowInsetsPadding(WindowInsets.navigationBars), onClick = { - dispatch(ListNavigationAction.AddScreens(SampleStack(MainScreen(0)))) + addScreens(SampleStack(MainScreen(0)), addToEnd = true) } ) { Text(text = "Add item", modifier = Modifier.align(Alignment.CenterVertically)) diff --git a/sample/src/main/java/com/github/terrakok/modo/sample/screens/containers/custom/RemovableItemContainerScreen.kt b/sample/src/main/java/com/github/terrakok/modo/sample/screens/containers/custom/RemovableItemContainerScreen.kt index 09ed0e60..83cbc814 100644 --- a/sample/src/main/java/com/github/terrakok/modo/sample/screens/containers/custom/RemovableItemContainerScreen.kt +++ b/sample/src/main/java/com/github/terrakok/modo/sample/screens/containers/custom/RemovableItemContainerScreen.kt @@ -15,11 +15,12 @@ import com.github.terrakok.modo.LocalContainerScreen import com.github.terrakok.modo.NavModel import com.github.terrakok.modo.NavigationReducer import com.github.terrakok.modo.NavigationState -import com.github.terrakok.modo.ReducerAction import com.github.terrakok.modo.Screen import com.github.terrakok.modo.ScreenKey import com.github.terrakok.modo.generateScreenKey import com.github.terrakok.modo.lazylist.screenItem +import android.os.Parcelable +import kotlinx.parcelize.IgnoredOnParcel import kotlinx.parcelize.Parcelize @Parcelize @@ -32,22 +33,22 @@ data class RemovableItemContainerState( override fun getChildScreens(): List<Screen> = listOfNotNull(screen1, screen2, screen3, screen4) } -internal sealed interface RemovableItemContainerAction : ReducerAction<RemovableItemContainerState> { - data object Remove : RemovableItemContainerAction { - override fun reduce(oldState: RemovableItemContainerState): RemovableItemContainerState = - oldState.copy(screen3 = null) +internal fun interface RemovableItemContainerReducer : NavigationReducer<RemovableItemContainerState> + +internal object RemovableItemContainerReducers { + val Remove = RemovableItemContainerReducer { oldState -> + oldState.copy(screen3 = null) } - data object CreateScreen : RemovableItemContainerAction { - override fun reduce(oldState: RemovableItemContainerState): RemovableItemContainerState = - oldState.copy(screen3 = NestedScreen(canBeRemoved = true)) + val CreateScreen = RemovableItemContainerReducer { oldState -> + oldState.copy(screen3 = NestedScreen(canBeRemoved = true)) } } @Parcelize internal class RemovableItemContainerScreen( private val useCustomReducer: Boolean = false, - private val navModel: NavModel<RemovableItemContainerState, RemovableItemContainerAction> = NavModel( + private val navModel: NavModel<RemovableItemContainerState> = NavModel( RemovableItemContainerState( NestedScreen(canBeRemoved = false), NestedScreen(canBeRemoved = false), @@ -55,23 +56,7 @@ internal class RemovableItemContainerScreen( NestedScreen(canBeRemoved = false), ) ) -) : ContainerScreen<RemovableItemContainerState, RemovableItemContainerAction>(navModel) { - - override val reducer: NavigationReducer<RemovableItemContainerState, RemovableItemContainerAction>? - get() = if (useCustomReducer) { - NavigationReducer<RemovableItemContainerState, RemovableItemContainerAction> { action, state -> - when (action) { - is RemovableItemContainerAction.Remove -> { - state.copy(screen3 = null) - } - is RemovableItemContainerAction.CreateScreen -> { - state.copy(screen3 = NestedScreen(canBeRemoved = true)) - } - } - } - } else { - null - } +) : ContainerScreen<RemovableItemContainerState>(navModel) { @Composable override fun Content(modifier: Modifier) { @@ -95,7 +80,7 @@ internal class RemovableItemContainerScreen( Column { Button( modifier = Modifier.fillMaxWidth(), - onClick = { dispatch(RemovableItemContainerAction.CreateScreen) } + onClick = { dispatch(RemovableItemContainerReducers.CreateScreen) } ) { Text(text = "Create screen") } @@ -115,7 +100,7 @@ internal class NestedScreen( val parent = LocalContainerScreen.current as RemovableItemContainerScreen InnerContent( title = screenKey.value, - onRemoveClick = takeIf { canBeRemoved }?.let { { parent.dispatch(RemovableItemContainerAction.Remove) } }, + onRemoveClick = takeIf { canBeRemoved }?.let { { parent.dispatch(RemovableItemContainerReducers.Remove) } }, modifier = Modifier .fillMaxWidth() .height(400.dp) diff --git a/sample/src/main/java/com/github/terrakok/modo/sample/screens/containers/custom/SampleCustomContainerScreen.kt b/sample/src/main/java/com/github/terrakok/modo/sample/screens/containers/custom/SampleCustomContainerScreen.kt index b7aff128..461e77ab 100644 --- a/sample/src/main/java/com/github/terrakok/modo/sample/screens/containers/custom/SampleCustomContainerScreen.kt +++ b/sample/src/main/java/com/github/terrakok/modo/sample/screens/containers/custom/SampleCustomContainerScreen.kt @@ -29,9 +29,8 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import com.github.terrakok.modo.ContainerScreen import com.github.terrakok.modo.NavModel -import com.github.terrakok.modo.NavigationAction +import com.github.terrakok.modo.NavigationReducer import com.github.terrakok.modo.NavigationState -import com.github.terrakok.modo.ReducerAction import com.github.terrakok.modo.Screen import com.github.terrakok.modo.ScreenKey import kotlinx.parcelize.Parcelize @@ -45,10 +44,9 @@ internal data class CustomContainerState( override fun getChildScreens(): List<Screen> = screens } -internal interface CustomContainerAction : NavigationAction<CustomContainerState> -internal fun interface CustomContainerReducerAction : CustomContainerAction, ReducerAction<CustomContainerState> +internal fun interface CustomContainerReducer : NavigationReducer<CustomContainerState> -internal class RemoveScreen(val screenKey: ScreenKey) : CustomContainerReducerAction { +internal class RemoveScreen(val screenKey: ScreenKey) : CustomContainerReducer { override fun reduce(oldState: CustomContainerState): CustomContainerState = CustomContainerState( oldState.screens.filter { it.screenKey != screenKey } ) @@ -61,8 +59,8 @@ internal val LocalSampleCustomNavigation = compositionLocalOf<SampleCustomContai @Parcelize internal class SampleCustomContainerScreen( - private val navModel: NavModel<CustomContainerState, CustomContainerAction> = NavModel(CustomContainerState(listOf(InnerScreen()))) -) : ContainerScreen<CustomContainerState, CustomContainerAction>(navModel) { + private val navModel: NavModel<CustomContainerState> = NavModel(CustomContainerState(listOf(InnerScreen()))) +) : ContainerScreen<CustomContainerState>(navModel) { override fun provideCompositionLocals(): Array<ProvidedValue<*>> = arrayOf(LocalSampleCustomNavigation provides this) @@ -103,11 +101,9 @@ internal class SampleCustomContainerScreen( Column { Button( onClick = { - navModel.dispatch( - CustomContainerReducerAction { state -> - CustomContainerState(listOf(InnerScreen()) + state.screens) - } - ) + navModel.dispatch { state -> + CustomContainerState(listOf(InnerScreen()) + state.screens) + } }, modifier = Modifier.fillMaxWidth() ) { @@ -115,11 +111,9 @@ internal class SampleCustomContainerScreen( } Button( onClick = { - navModel.dispatch( - CustomContainerReducerAction { state -> - CustomContainerState(state.screens.reversed()) - } - ) + navModel.dispatch { state -> + CustomContainerState(state.screens.reversed()) + } }, modifier = Modifier.fillMaxWidth() ) { diff --git a/sample/src/main/java/com/github/terrakok/modo/sample/screens/stack/StackActionsScreen.kt b/sample/src/main/java/com/github/terrakok/modo/sample/screens/stack/StackActionsScreen.kt index 0d8ff21e..64798904 100644 --- a/sample/src/main/java/com/github/terrakok/modo/sample/screens/stack/StackActionsScreen.kt +++ b/sample/src/main/java/com/github/terrakok/modo/sample/screens/stack/StackActionsScreen.kt @@ -8,6 +8,7 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier import com.github.terrakok.modo.Screen import com.github.terrakok.modo.ScreenKey +import com.github.terrakok.modo.dispatch import com.github.terrakok.modo.generateScreenKey import com.github.terrakok.modo.sample.screens.ButtonsState import com.github.terrakok.modo.sample.screens.GroupedButtonsState diff --git a/workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/screens/profile/EnhancedProfileScreen.kt b/workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/screens/profile/EnhancedProfileScreen.kt index 03cb0c74..cf28a83a 100644 --- a/workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/screens/profile/EnhancedProfileScreen.kt +++ b/workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/screens/profile/EnhancedProfileScreen.kt @@ -20,9 +20,10 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.github.terrakok.modo.ContainerScreen import com.github.terrakok.modo.NavModel -import com.github.terrakok.modo.NavigationAction +import com.github.terrakok.modo.NavigationReducer import com.github.terrakok.modo.NavigationState import com.github.terrakok.modo.Screen +import android.os.Parcelable import com.github.terrakok.modo.stack.LocalStackNavigation import com.github.terrakok.modo.stack.forward import io.github.ikarenkov.workshop.domain.ClimbingType @@ -34,7 +35,7 @@ import org.koin.androidx.compose.koinViewModel @Parcelize class EnhancedProfileScreen( - private val navModel: NavModel<EnhancedProfileNavigationState, EnhancedProfileNavigationActionNoOp> = NavModel( + private val navModel: NavModel<EnhancedProfileNavigationState> = NavModel( // TODO: Workshop 6.2.4 - set initial state EnhancedProfileNavigationState( ClimberPersonalInfoScreen(), @@ -43,9 +44,9 @@ class EnhancedProfileScreen( ) ) // TODO: Workshop 6.2.1 - inherit from ContainerScreen -) : ContainerScreen<EnhancedProfileNavigationState, EnhancedProfileNavigationActionNoOp>( +) : ContainerScreen<EnhancedProfileNavigationState>( navModel -) { +), Parcelable { @Composable override fun Content(modifier: Modifier) { @@ -146,7 +147,8 @@ data class EnhancedProfileNavigationState( } // TODO: Workshop 6.2.3 - define navigation action -class EnhancedProfileNavigationActionNoOp : NavigationAction<EnhancedProfileNavigationState> +@Deprecated("Use NavigationReducer directly.") +fun interface EnhancedProfileNavigationAction : NavigationReducer<EnhancedProfileNavigationState> @Preview @Composable diff --git a/workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/screens/profile/EnhancedProfileScreenFinal.kt b/workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/screens/profile/EnhancedProfileScreenFinal.kt index c678bfa0..08ffa592 100644 --- a/workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/screens/profile/EnhancedProfileScreenFinal.kt +++ b/workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/screens/profile/EnhancedProfileScreenFinal.kt @@ -8,6 +8,7 @@ import androidx.compose.ui.Modifier import com.github.terrakok.modo.ContainerScreen import com.github.terrakok.modo.NavModel import com.github.terrakok.modo.lazylist.screenItem +import android.os.Parcelable import com.github.terrakok.modo.stack.LocalStackNavigation import com.github.terrakok.modo.stack.forward import io.github.ikarenkov.workshop.screens.TrainingRecommendationsDialogScreenFinal @@ -18,10 +19,10 @@ import org.koin.core.parameter.parametersOf @Parcelize class EnhancedProfileScreenFinal( - private val navModel: NavModel<EnhancedProfileNavigationState, EnhancedProfileNavigationAction> = NavModel(EnhancedProfileNavigationState()) -) : ContainerScreen<EnhancedProfileNavigationState, EnhancedProfileNavigationAction>( + private val navModel: NavModel<EnhancedProfileNavigationState> = NavModel(EnhancedProfileNavigationState()) +) : ContainerScreen<EnhancedProfileNavigationState>( navModel -) { +), Parcelable { @Composable override fun Content(modifier: Modifier) { diff --git a/workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/screens/profile/EnhancedProfileViewModelFinal.kt b/workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/screens/profile/EnhancedProfileViewModelFinal.kt index 2bac713f..ffc76b56 100644 --- a/workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/screens/profile/EnhancedProfileViewModelFinal.kt +++ b/workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/screens/profile/EnhancedProfileViewModelFinal.kt @@ -2,7 +2,7 @@ package io.github.ikarenkov.workshop.screens.profile import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.github.terrakok.modo.ReducerAction +import com.github.terrakok.modo.NavigationReducer import io.github.ikarenkov.workshop.core.mapStateFlow import io.github.ikarenkov.workshop.data.ClimberProfileRepository import io.github.ikarenkov.workshop.domain.ClimberProfile @@ -26,7 +26,7 @@ class EnhancedProfileViewModelFinal( viewModelScope.launch { climberProfileRepository.climberProfile.collect { profile -> enhancedProfileScreenFinal.dispatch( - EnhancedProfileNavigationAction( + EnhancedProfileNavigationReducer( showClimberProfile = profile.dateOfBirth != null, showBoulderLever = profile.boulderLevel.hasAllGrades(), showLeadLevel = profile.sportLevel.hasAllGrades() @@ -49,11 +49,11 @@ class EnhancedProfileViewModelFinal( ) } -class EnhancedProfileNavigationAction( +class EnhancedProfileNavigationReducer( private val showClimberProfile: Boolean, private val showLeadLevel: Boolean, private val showBoulderLever: Boolean, -) : ReducerAction<EnhancedProfileNavigationState> { +) : NavigationReducer<EnhancedProfileNavigationState> { override fun reduce(oldState: EnhancedProfileNavigationState): EnhancedProfileNavigationState = oldState.copy( climbingProfileScreen = if (showClimberProfile) { oldState.climbingProfileScreen ?: ClimberPersonalInfoScreen() @@ -71,5 +71,4 @@ class EnhancedProfileNavigationAction( null } ) - } \ No newline at end of file From 211c7a02cf80342c4cbea0eb785c04b1163d58d7 Mon Sep 17 00:00:00 2001 From: Karenkov Igor <karenkovigor@gmail.com> Date: Tue, 28 Apr 2026 18:58:15 +0700 Subject: [PATCH 04/26] Rename `navigationStateFlow` to `stateFlow` and implement `subtreeStateFlow` for deep tree observation. - Renamed `NavigationContainer.navigationStateFlow` to `stateFlow` for brevity and consistency across the library. - Introduced `subtreeStateFlow()` extension to support observing navigation state changes across an entire hierarchy of containers. - Added a deprecated `navigationStateFlow()` extension with `DeprecationLevel.ERROR` to provide a migration path to the new API. - Added a `NavigationTreeStrip` component to the sample app to visualize the live navigation tree using the new subtree observation logic. - Updated `ComposeRenderer`, tests, and sample screens to reflect the API changes. --- .../com/github/terrakok/modo/ComposeRender.kt | 6 +- .../github/terrakok/modo/ContainerScreen.kt | 4 +- .../com/github/terrakok/modo/ModoModels.kt | 58 ++++++++++++- .../modo/ComposeRendererDisposalTest.kt | 2 +- .../modo/sample/ModoSampleActivity.kt | 20 +++++ .../modo/sample/components/NavigationTree.kt | 82 +++++++++++++++++++ .../sample/screens/containers/SampleStack.kt | 46 ++++++++--- .../screens/stack/StackActionsScreen.kt | 3 +- .../ProfileSetupFlowViewModelFinal.kt | 2 +- 9 files changed, 203 insertions(+), 20 deletions(-) create mode 100644 sample/src/main/java/com/github/terrakok/modo/sample/components/NavigationTree.kt diff --git a/modo-compose/src/main/java/com/github/terrakok/modo/ComposeRender.kt b/modo-compose/src/main/java/com/github/terrakok/modo/ComposeRender.kt index bda74337..65def919 100644 --- a/modo-compose/src/main/java/com/github/terrakok/modo/ComposeRender.kt +++ b/modo-compose/src/main/java/com/github/terrakok/modo/ComposeRender.kt @@ -158,12 +158,12 @@ class ComposeRendererScope<State : NavigationState>( */ internal class ComposeRenderer<State : NavigationState>( private val containerScreen: ContainerScreen<State>, - navigationStateFlow: StateFlow<State>, + stateFlow: StateFlow<State>, ) { internal val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) private var lastState: State? = null - var state: State by mutableStateOf(navigationStateFlow.value, neverEqualPolicy()) + var state: State by mutableStateOf(stateFlow.value, neverEqualPolicy()) private set // TODO: share removed screen for whole structure? @@ -171,7 +171,7 @@ internal class ComposeRenderer<State : NavigationState>( init { scope.launch { - navigationStateFlow.drop(1).collect { newState -> + stateFlow.drop(1).collect { newState -> removedScreens.addAll(calculateRemovedScreens(state, newState)) lastState = state state = newState diff --git a/modo-compose/src/main/java/com/github/terrakok/modo/ContainerScreen.kt b/modo-compose/src/main/java/com/github/terrakok/modo/ContainerScreen.kt index ac0d83e1..b205c0f8 100644 --- a/modo-compose/src/main/java/com/github/terrakok/modo/ContainerScreen.kt +++ b/modo-compose/src/main/java/com/github/terrakok/modo/ContainerScreen.kt @@ -36,7 +36,7 @@ abstract class ContainerScreen<State : NavigationState>( ) open val reducer: NavigationReducer<State>? = null - internal val renderer: ComposeRenderer<State> = ComposeRenderer(this, navModel.navigationStateFlow) + internal val renderer: ComposeRenderer<State> = ComposeRenderer(this, navModel.stateFlow) final override val screenKey: ScreenKey = navModel.screenKey @@ -89,7 +89,7 @@ class NavModel<State : NavigationState>( ) : NavigationContainer<State>, Parcelable { private val _navigationState = MutableStateFlow(initialState) - override val navigationStateFlow: StateFlow<State> = _navigationState.asStateFlow() + override val stateFlow: StateFlow<State> = _navigationState.asStateFlow() override fun dispatch(reducer: NavigationReducer<State>) { _navigationState.value = reducer.reduce(_navigationState.value) diff --git a/modo-compose/src/main/java/com/github/terrakok/modo/ModoModels.kt b/modo-compose/src/main/java/com/github/terrakok/modo/ModoModels.kt index 1f1ad054..bec96361 100644 --- a/modo-compose/src/main/java/com/github/terrakok/modo/ModoModels.kt +++ b/modo-compose/src/main/java/com/github/terrakok/modo/ModoModels.kt @@ -2,7 +2,13 @@ package com.github.terrakok.modo import android.os.Parcelable import androidx.compose.runtime.Stable +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.merge /** * State of navigation used in [NavigationContainer]. Can be any type. @@ -37,7 +43,7 @@ fun interface NavigationReducer<State : NavigationState> { */ @Stable interface NavigationContainer<State : NavigationState> { - val navigationStateFlow: StateFlow<State> + val stateFlow: StateFlow<State> fun dispatch(reducer: NavigationReducer<State>) } @@ -61,3 +67,53 @@ fun <State : NavigationState> NavigationContainer<State>.dispatch( state } } + +/** + * Observes navigation state changes across the entire subtree rooted at this container. + * + * Emits the current state on subscription, and re-emits whenever this container or *any* descendant + * [NavigationContainer] dispatches. Observers are expected to re-walk via [NavigationState.getChildScreens] + * to inspect the updated tree — emissions carry the root state, not nested states. + * + * Resubscription semantics: [flatMapLatest] cancels and rebuilds the inner subscription tree + * whenever this container's own state changes, so subscriptions to removed children are + * abandoned and newly added children are picked up automatically. + * + * To turn this into a hot [StateFlow], wrap the result with `stateIn(scope, started, initial)` + * at the call site — the sharing policy is a consumer concern. + */ +@OptIn(ExperimentalCoroutinesApi::class) +fun NavigationContainer<*>.subtreeStateFlow(): Flow<NavigationState> = + stateFlow.flatMapLatest { state -> + flow { + emit(state) + // Children are re-read on every parent emission: when the parent dispatches to add + // (or replace) children, flatMapLatest cancels this inner flow and a fresh one re-walks + // getChildScreens(). So an "empty now, populated later" transition is handled by the + // outer flatMapLatest, not by branching here. + state.getChildScreens() + .filterIsInstance<NavigationContainer<*>>() + .map { it.subtreeStateFlow().drop(1) } + .merge() + .collect { emit(state) } + } + } + +/** + * Migration shim for the dev-branch `navigationStateFlow()` extension that produced a + * `snapshotFlow { navigationState }` flow. The new API splits that into two operations, + * so this shim makes silent migration impossible. + * + * - For per-container observation: use the [NavigationContainer.stateFlow] property. + * - For whole-subtree observation (the previous behavior when consumers walked `getChildScreens()`): + * use [subtreeStateFlow]. + */ +@Deprecated( + message = "Replaced by the `stateFlow` property (per-container) and " + + "`subtreeStateFlow()` (whole subtree). The previous snapshotFlow-based extension is gone.", + replaceWith = ReplaceWith("subtreeStateFlow()"), + level = DeprecationLevel.ERROR, +) +@Suppress("UNCHECKED_CAST", "unused") +fun <State : NavigationState> NavigationContainer<State>.navigationStateFlow(): Flow<State> = + subtreeStateFlow() as Flow<State> diff --git a/modo-compose/src/test/java/com/github/terrakok/modo/ComposeRendererDisposalTest.kt b/modo-compose/src/test/java/com/github/terrakok/modo/ComposeRendererDisposalTest.kt index da7e47ba..f8e993cf 100644 --- a/modo-compose/src/test/java/com/github/terrakok/modo/ComposeRendererDisposalTest.kt +++ b/modo-compose/src/test/java/com/github/terrakok/modo/ComposeRendererDisposalTest.kt @@ -71,7 +71,7 @@ class ComposeRendererDisposalTest { override fun writeToParcel(parcel: Parcel, flags: Int) {} } - return ComposeRenderer(containerScreen, navModel.navigationStateFlow) + return ComposeRenderer(containerScreen, navModel.stateFlow) } @Parcelize diff --git a/sample/src/main/java/com/github/terrakok/modo/sample/ModoSampleActivity.kt b/sample/src/main/java/com/github/terrakok/modo/sample/ModoSampleActivity.kt index 22760099..af3df9ae 100644 --- a/sample/src/main/java/com/github/terrakok/modo/sample/ModoSampleActivity.kt +++ b/sample/src/main/java/com/github/terrakok/modo/sample/ModoSampleActivity.kt @@ -3,12 +3,32 @@ package com.github.terrakok.modo.sample import android.os.Bundle import androidx.activity.compose.setContent import androidx.appcompat.app.AppCompatActivity +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.produceState import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import androidx.core.view.WindowCompat +import com.github.terrakok.modo.ContainerScreen import com.github.terrakok.modo.Modo.rememberRootScreen +import com.github.terrakok.modo.NavigationContainer +import com.github.terrakok.modo.NavigationState +import com.github.terrakok.modo.Screen +import com.github.terrakok.modo.multiscreen.MultiScreenState import com.github.terrakok.modo.sample.screens.MainScreen import com.github.terrakok.modo.sample.screens.containers.SampleStack +import com.github.terrakok.modo.stack.StackState +import com.github.terrakok.modo.subtreeStateFlow class ModoSampleActivity : AppCompatActivity() { diff --git a/sample/src/main/java/com/github/terrakok/modo/sample/components/NavigationTree.kt b/sample/src/main/java/com/github/terrakok/modo/sample/components/NavigationTree.kt new file mode 100644 index 00000000..c39fdea1 --- /dev/null +++ b/sample/src/main/java/com/github/terrakok/modo/sample/components/NavigationTree.kt @@ -0,0 +1,82 @@ +package com.github.terrakok.modo.sample.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.padding +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.produceState +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.github.terrakok.modo.ContainerScreen +import com.github.terrakok.modo.NavigationContainer +import com.github.terrakok.modo.NavigationState +import com.github.terrakok.modo.Screen +import com.github.terrakok.modo.multiscreen.MultiScreenState +import com.github.terrakok.modo.stack.StackState +import com.github.terrakok.modo.subtreeStateFlow + +/** + * Demo: observes the navigation tree via [NavigationContainer.subtreeStateFlow] and renders a + * compact textual snapshot. Re-walks the tree on every emission — emissions are notifications, + * not values, since the deep flow re-emits the root state on any descendant change. + */ +@Composable +fun NavigationTreeStrip( + container: NavigationContainer<*>, + modifier: Modifier = Modifier, +) { + val state by produceState<NavigationState?>(initialValue = null, container) { + container.subtreeStateFlow().collect { value = it } + } + Text( + text = state?.compactRender().orEmpty(), + color = Color.White, + fontSize = 10.sp, + fontFamily = FontFamily.Monospace, + modifier = modifier + .background(Color.Black.copy(alpha = 0.7f)) + .padding(horizontal = 12.dp, vertical = 6.dp), + ) +} + +private const val STACK_VISIBLE = 2 + +private fun NavigationState.compactRender(): String = + buildString { appendNode(prefix = "", state = this@compactRender) }.trimEnd() + +private fun StringBuilder.appendNode(prefix: String, state: NavigationState) { + when (state) { + is StackState -> { + val stack = state.stack + val hidden = (stack.size - STACK_VISIBLE).coerceAtLeast(0) + if (hidden > 0) append(prefix).append("…").append(hidden).append(" more\n") + val visible = stack.takeLast(STACK_VISIBLE) + visible.forEachIndexed { idx, screen -> + val isTop = idx == visible.lastIndex + appendScreen(prefix, screen, isTop) + } + } + is MultiScreenState -> { + val selected = state.screens.getOrNull(state.selected) ?: return + append(prefix).append("multi #").append(state.selected).append('\n') + appendScreen("$prefix ", selected, isTop = true) + } + else -> state.getChildScreens().forEach { appendScreen(prefix, it, isTop = false) } + } +} + +private fun StringBuilder.appendScreen(prefix: String, screen: Screen, isTop: Boolean) { + append(prefix) + append(screen.label()) + if (isTop && screen !is ContainerScreen<*>) append(" ◀") + append('\n') + if (screen is ContainerScreen<*>) { + appendNode("$prefix ", screen.navigationState) + } +} + +private fun Screen.label(): String = this::class.simpleName ?: "Screen" diff --git a/sample/src/main/java/com/github/terrakok/modo/sample/screens/containers/SampleStack.kt b/sample/src/main/java/com/github/terrakok/modo/sample/screens/containers/SampleStack.kt index 73e5f8e2..df69b049 100644 --- a/sample/src/main/java/com/github/terrakok/modo/sample/screens/containers/SampleStack.kt +++ b/sample/src/main/java/com/github/terrakok/modo/sample/screens/containers/SampleStack.kt @@ -8,7 +8,15 @@ import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.systemBars +import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.remember @@ -26,6 +34,7 @@ import com.github.terrakok.modo.sample.screens.dialogs.SampleBottomSheet import com.github.terrakok.modo.sample.screens.dialogs.SampleBottomSheetStack import com.github.terrakok.modo.stack.DialogPlaceHolder import com.github.terrakok.modo.NavigationReducer +import com.github.terrakok.modo.sample.components.NavigationTreeStrip import com.github.terrakok.modo.stack.StackNavModel import com.github.terrakok.modo.stack.StackScreen import com.github.terrakok.modo.stack.StackState @@ -54,18 +63,35 @@ open class SampleStack( @Composable override fun Content(modifier: Modifier) { LogLifecycle() - Box(modifier.fillMaxSize()) { - TopScreenContent( - modifier = Modifier.fillMaxSize(), - dialogModifier = Modifier.fillMaxSize() - ) { contentModifier -> - SlideTransition(contentModifier) + Column { + // The strip below physically sits at the bottom of the window and pads the bottom + // system bar. Tell descendants of this Box to treat that inset as already handled, + // otherwise ButtonsScreenContent.windowInsetsPadding(WindowInsets.systemBars) doubles + // the bottom padding. consumeWindowInsets affects descendants only; the strip is a + // sibling, so it still sees and pads the full bottom inset. + Box( + modifier + .weight(1f) + .consumeWindowInsets(WindowInsets.systemBars.only(WindowInsetsSides.Bottom)) + ) { + TopScreenContent( + modifier = Modifier.fillMaxSize(), + dialogModifier = Modifier.fillMaxSize() + ) { contentModifier -> + SlideTransition(contentModifier) + } + LifecycleEventsHistory( + fontSize = 8.sp, + modifier = Modifier + .background(Color.White.copy(alpha = 0.5f)) + .align(Alignment.TopEnd) + ) } - LifecycleEventsHistory( - fontSize = 8.sp, + NavigationTreeStrip( + this@SampleStack, modifier = Modifier - .background(Color.White.copy(alpha = 0.5f)) - .align(Alignment.TopEnd) + .fillMaxWidth() + .windowInsetsPadding(WindowInsets.systemBars.only(WindowInsetsSides.Bottom)) ) } diff --git a/sample/src/main/java/com/github/terrakok/modo/sample/screens/stack/StackActionsScreen.kt b/sample/src/main/java/com/github/terrakok/modo/sample/screens/stack/StackActionsScreen.kt index 64798904..9d49b070 100644 --- a/sample/src/main/java/com/github/terrakok/modo/sample/screens/stack/StackActionsScreen.kt +++ b/sample/src/main/java/com/github/terrakok/modo/sample/screens/stack/StackActionsScreen.kt @@ -27,7 +27,6 @@ import com.github.terrakok.modo.stack.StackScreen import com.github.terrakok.modo.stack.StackState import com.github.terrakok.modo.stack.back import com.github.terrakok.modo.stack.backTo -import com.github.terrakok.modo.stack.dispatch import com.github.terrakok.modo.stack.forward import com.github.terrakok.modo.stack.removeScreens import com.github.terrakok.modo.stack.replace @@ -101,7 +100,7 @@ private fun rememberButtons( } }, ModoButtonSpec("Remove previous") { - val prevScreenIndex = navigation.navigationStateFlow.value.stack.lastIndex - 1 + val prevScreenIndex = navigation.stateFlow.value.stack.lastIndex - 1 navigation.removeScreens { pos, screen -> pos == prevScreenIndex } }, ModoButtonSpec("Back to '3'") { diff --git a/workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/screens/profile_setup/ProfileSetupFlowViewModelFinal.kt b/workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/screens/profile_setup/ProfileSetupFlowViewModelFinal.kt index fff837b2..5a0bdc44 100644 --- a/workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/screens/profile_setup/ProfileSetupFlowViewModelFinal.kt +++ b/workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/screens/profile_setup/ProfileSetupFlowViewModelFinal.kt @@ -29,7 +29,7 @@ class ProfileSetupFlowViewModelFinal( // Workshop 5.3 - define state using navigationStateFlow and climberProfileRepository.climberProfile val state: StateFlow<ProfileSetupContainerUiState> = combineStateFlow( - profileSetupFlowScreen.navigationStateFlow, + profileSetupFlowScreen.stateFlow, climberProfileRepository.climberProfile, viewModelScope, ) { navigationState, profile -> From 7280eb6ec1ec48d3115c41e8702513f2ae7677ba Mon Sep 17 00:00:00 2001 From: Karenkov Igor <karenkovigor@gmail.com> Date: Tue, 5 May 2026 15:56:19 +0700 Subject: [PATCH 05/26] Implemented persistent app settings using DataStore. Added a SettingsDialog to configure navigation tree visibility and display limits. Refactored NavigationTreeStrip to support AnimatedContent and dynamic visibility, and moved its placement to the root activity. Added maxLines support to LifecycleEventsHistory. --- gradle/libs.versions.toml | 2 + sample/build.gradle.kts | 1 + .../modo/sample/ModoSampleActivity.kt | 62 ++++++--- .../modo/sample/ModoSampleApplication.kt | 9 +- .../terrakok/modo/sample/SampleAppSettings.kt | 25 ++++ .../modo/sample/components/NavigationTree.kt | 47 ++++--- .../screens/base/LifecycleEventsHistory.kt | 5 +- .../sample/screens/containers/SampleStack.kt | 10 +- .../sample/screens/dialogs/SettingsDialog.kt | 124 ++++++++++++++++++ .../modo/sample/settings/AppSetting.kt | 45 +++++++ 10 files changed, 286 insertions(+), 44 deletions(-) create mode 100644 sample/src/main/java/com/github/terrakok/modo/sample/SampleAppSettings.kt create mode 100644 sample/src/main/java/com/github/terrakok/modo/sample/screens/dialogs/SettingsDialog.kt create mode 100644 sample/src/main/java/com/github/terrakok/modo/sample/settings/AppSetting.kt diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 556618f9..ac1feda9 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -23,6 +23,7 @@ kotlinCompilerExtension = "1.5.12" minSdk = "21" compileSdk = "36" koin = "4.0.0" +datastorePreferences = "1.1.1" [libraries] androidx-compose-bom-modo = { group = "androidx.compose", name = "compose-bom", version.ref = "androidxComposeBomModo" } @@ -67,6 +68,7 @@ compose-compile-gradlePlugin = { group = "org.jetbrains.kotlin.plugin.compose", koin-android = { group = "io.insert-koin", name = "koin-android", version.ref = "koin" } koin-compose = { group = "io.insert-koin", name = "koin-androidx-compose", version.ref = "koin" } +datastore-preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "datastorePreferences" } [plugins] modo-detekt = { id = "modo-detekt" } modo-android-library = { id = "modo-android-library" } diff --git a/sample/build.gradle.kts b/sample/build.gradle.kts index e84e0cfc..2fb0253d 100644 --- a/sample/build.gradle.kts +++ b/sample/build.gradle.kts @@ -37,6 +37,7 @@ dependencies { implementation(libs.debug.logcat) implementation(libs.kotlinx.coroutines.android) + implementation(libs.datastore.preferences) debugImplementation(libs.leakcanary.android) } \ No newline at end of file diff --git a/sample/src/main/java/com/github/terrakok/modo/sample/ModoSampleActivity.kt b/sample/src/main/java/com/github/terrakok/modo/sample/ModoSampleActivity.kt index af3df9ae..4482a76d 100644 --- a/sample/src/main/java/com/github/terrakok/modo/sample/ModoSampleActivity.kt +++ b/sample/src/main/java/com/github/terrakok/modo/sample/ModoSampleActivity.kt @@ -3,45 +3,77 @@ package com.github.terrakok.modo.sample import android.os.Bundle import androidx.activity.compose.setContent import androidx.appcompat.app.AppCompatActivity -import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.only import androidx.compose.foundation.layout.padding -import androidx.compose.material.Text -import androidx.compose.runtime.Composable +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.layout.systemBars +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.material.Icon +import androidx.compose.material.IconButton +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Settings +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.produceState +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color -import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp import androidx.core.view.WindowCompat -import com.github.terrakok.modo.ContainerScreen +import com.github.terrakok.modo.ExperimentalModoApi import com.github.terrakok.modo.Modo.rememberRootScreen -import com.github.terrakok.modo.NavigationContainer -import com.github.terrakok.modo.NavigationState -import com.github.terrakok.modo.Screen -import com.github.terrakok.modo.multiscreen.MultiScreenState +import com.github.terrakok.modo.sample.components.NavigationTreeStrip import com.github.terrakok.modo.sample.screens.MainScreen import com.github.terrakok.modo.sample.screens.containers.SampleStack -import com.github.terrakok.modo.stack.StackState -import com.github.terrakok.modo.subtreeStateFlow +import com.github.terrakok.modo.sample.screens.dialogs.SettingsDialog +import com.github.terrakok.modo.stack.forward class ModoSampleActivity : AppCompatActivity() { + @OptIn(ExperimentalModoApi::class) override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) WindowCompat.setDecorFitsSystemWindows(window, false) setContent { ActivityContent { - // Remember root screen using rememberSeaveable under the hood. val rootScreen = rememberRootScreen { SampleStack(MainScreen(1)) } - rootScreen.Content(modifier = Modifier.fillMaxSize()) + val stackScreen = rootScreen.screen + val showNavigationTree by SampleAppSettings.instance.showNavigationTree.stateFlow.collectAsState() + val navTreeVisibleScreens by SampleAppSettings.instance.navTreeVisibleScreens.stateFlow.collectAsState() + Column(modifier = Modifier.fillMaxSize()) { + Box(modifier = Modifier.weight(1f)) { + rootScreen.Content(modifier = Modifier.fillMaxSize()) + IconButton( + onClick = { stackScreen.forward(SettingsDialog()) }, + modifier = Modifier + .align(Alignment.TopEnd) + .statusBarsPadding() + .padding(end = 4.dp) + ) { + Icon( + imageVector = Icons.Default.Settings, + contentDescription = "App settings", + tint = Color.White + ) + } + } + if (showNavigationTree) { + NavigationTreeStrip( + container = stackScreen, + visibleScreens = navTreeVisibleScreens, + modifier = Modifier + .fillMaxWidth() + .windowInsetsPadding(WindowInsets.systemBars.only(WindowInsetsSides.Bottom)) + ) + } + } } } } diff --git a/sample/src/main/java/com/github/terrakok/modo/sample/ModoSampleApplication.kt b/sample/src/main/java/com/github/terrakok/modo/sample/ModoSampleApplication.kt index a79bce02..743f8379 100644 --- a/sample/src/main/java/com/github/terrakok/modo/sample/ModoSampleApplication.kt +++ b/sample/src/main/java/com/github/terrakok/modo/sample/ModoSampleApplication.kt @@ -1,15 +1,22 @@ package com.github.terrakok.modo.sample import android.app.Application +import androidx.datastore.preferences.preferencesDataStore import com.github.terrakok.modo.ModoDevOptions import com.github.terrakok.modo.sample.logs.logcat +import kotlinx.coroutines.MainScope import logcat.AndroidLogcatLogger import logcat.LogPriority +private val Application.dataStore by preferencesDataStore(name = "sample_settings") + class ModoSampleApplication : Application() { + private val applicationScope = MainScope() + override fun onCreate() { super.onCreate() + SampleAppSettings.init(dataStore, applicationScope) AndroidLogcatLogger.installOnDebuggableApp(this, minPriority = LogPriority.VERBOSE) ModoDevOptions.onIllegalScreenModelStoreAccess = ModoDevOptions.ValidationFailedStrategy { throwable -> throw throwable @@ -27,4 +34,4 @@ class ModoSampleApplication : Application() { it.logcat { "Screen preDisposed" } } } -} \ No newline at end of file +} diff --git a/sample/src/main/java/com/github/terrakok/modo/sample/SampleAppSettings.kt b/sample/src/main/java/com/github/terrakok/modo/sample/SampleAppSettings.kt new file mode 100644 index 00000000..7abd88cc --- /dev/null +++ b/sample/src/main/java/com/github/terrakok/modo/sample/SampleAppSettings.kt @@ -0,0 +1,25 @@ +package com.github.terrakok.modo.sample + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import com.github.terrakok.modo.sample.settings.AppSetting +import kotlinx.coroutines.CoroutineScope + +class SampleAppSettings private constructor( + dataStore: DataStore<Preferences>, + scope: CoroutineScope +) { + private val factory = AppSetting.Factory(dataStore, scope) + + val showNavigationTree = factory.boolean("show_navigation_tree", default = true) + val navTreeVisibleScreens = factory.int("nav_tree_visible_screens", default = 2) + + companion object { + lateinit var instance: SampleAppSettings + private set + + internal fun init(dataStore: DataStore<Preferences>, scope: CoroutineScope) { + instance = SampleAppSettings(dataStore, scope) + } + } +} diff --git a/sample/src/main/java/com/github/terrakok/modo/sample/components/NavigationTree.kt b/sample/src/main/java/com/github/terrakok/modo/sample/components/NavigationTree.kt index c39fdea1..56adbcb3 100644 --- a/sample/src/main/java/com/github/terrakok/modo/sample/components/NavigationTree.kt +++ b/sample/src/main/java/com/github/terrakok/modo/sample/components/NavigationTree.kt @@ -1,5 +1,10 @@ package com.github.terrakok.modo.sample.components +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.SizeTransform +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.togetherWith import androidx.compose.foundation.background import androidx.compose.foundation.layout.padding import androidx.compose.material.Text @@ -28,54 +33,60 @@ import com.github.terrakok.modo.subtreeStateFlow fun NavigationTreeStrip( container: NavigationContainer<*>, modifier: Modifier = Modifier, + visibleScreens: Int = 2, ) { val state by produceState<NavigationState?>(initialValue = null, container) { container.subtreeStateFlow().collect { value = it } } - Text( - text = state?.compactRender().orEmpty(), - color = Color.White, - fontSize = 10.sp, - fontFamily = FontFamily.Monospace, + val text = state?.compactRender(visibleScreens).orEmpty() + AnimatedContent( + targetState = text, + transitionSpec = { fadeIn() togetherWith fadeOut() using SizeTransform(clip = true) }, modifier = modifier .background(Color.Black.copy(alpha = 0.7f)) .padding(horizontal = 12.dp, vertical = 6.dp), - ) + label = "NavTreeStrip", + ) { currentText -> + Text( + text = currentText, + color = Color.White, + fontSize = 10.sp, + fontFamily = FontFamily.Monospace, + ) + } } -private const val STACK_VISIBLE = 2 - -private fun NavigationState.compactRender(): String = - buildString { appendNode(prefix = "", state = this@compactRender) }.trimEnd() +private fun NavigationState.compactRender(visibleScreens: Int): String = + buildString { appendNode(prefix = "", state = this@compactRender, visibleScreens = visibleScreens) }.trimEnd() -private fun StringBuilder.appendNode(prefix: String, state: NavigationState) { +private fun StringBuilder.appendNode(prefix: String, state: NavigationState, visibleScreens: Int) { when (state) { is StackState -> { val stack = state.stack - val hidden = (stack.size - STACK_VISIBLE).coerceAtLeast(0) + val hidden = (stack.size - visibleScreens).coerceAtLeast(0) if (hidden > 0) append(prefix).append("…").append(hidden).append(" more\n") - val visible = stack.takeLast(STACK_VISIBLE) + val visible = stack.takeLast(visibleScreens) visible.forEachIndexed { idx, screen -> val isTop = idx == visible.lastIndex - appendScreen(prefix, screen, isTop) + appendScreen(prefix, screen, isTop, visibleScreens) } } is MultiScreenState -> { val selected = state.screens.getOrNull(state.selected) ?: return append(prefix).append("multi #").append(state.selected).append('\n') - appendScreen("$prefix ", selected, isTop = true) + appendScreen("$prefix ", selected, isTop = true, visibleScreens) } - else -> state.getChildScreens().forEach { appendScreen(prefix, it, isTop = false) } + else -> state.getChildScreens().forEach { appendScreen(prefix, it, isTop = false, visibleScreens) } } } -private fun StringBuilder.appendScreen(prefix: String, screen: Screen, isTop: Boolean) { +private fun StringBuilder.appendScreen(prefix: String, screen: Screen, isTop: Boolean, visibleScreens: Int) { append(prefix) append(screen.label()) if (isTop && screen !is ContainerScreen<*>) append(" ◀") append('\n') if (screen is ContainerScreen<*>) { - appendNode("$prefix ", screen.navigationState) + appendNode("$prefix ", screen.navigationState, visibleScreens) } } diff --git a/sample/src/main/java/com/github/terrakok/modo/sample/screens/base/LifecycleEventsHistory.kt b/sample/src/main/java/com/github/terrakok/modo/sample/screens/base/LifecycleEventsHistory.kt index 044bd97c..6b3411af 100644 --- a/sample/src/main/java/com/github/terrakok/modo/sample/screens/base/LifecycleEventsHistory.kt +++ b/sample/src/main/java/com/github/terrakok/modo/sample/screens/base/LifecycleEventsHistory.kt @@ -42,6 +42,7 @@ fun LifecycleEventsHistory( enabled: Boolean = SampleAppConfig.displayLifecycleEvents, lifecycleEventsHistory: SnapshotStateList<Lifecycle.Event>? = null, fontSize: TextUnit = 16.sp, + maxLines: Int = Int.MAX_VALUE, ) { if (enabled && !LocalInspectionMode.current) { val lifecycleEventsHistory = lifecycleEventsHistory ?: viewModel<LifecycleEventsViewModel>(key = key).lifecycleEventsHistory @@ -72,7 +73,7 @@ fun LifecycleEventsHistory( ) } ) { - for (item in lifecycleEventsHistory) { + for (item in lifecycleEventsHistory.takeLast(maxLines)) { Text(text = item.name, fontSize = fontSize) if (item == Lifecycle.Event.ON_STOP) { Divider( @@ -89,8 +90,10 @@ fun LifecycleEventsHistory( fun BoxScope.LifecycleEventsHistory( modifier: Modifier = Modifier, alignment: Alignment = Alignment.TopEnd, + maxLines: Int = Int.MAX_VALUE, ) = LifecycleEventsHistory( fontSize = 8.sp, + maxLines = maxLines, modifier = modifier .background(Color.White.copy(alpha = 0.5f)) .align(alignment) diff --git a/sample/src/main/java/com/github/terrakok/modo/sample/screens/containers/SampleStack.kt b/sample/src/main/java/com/github/terrakok/modo/sample/screens/containers/SampleStack.kt index df69b049..67119bc2 100644 --- a/sample/src/main/java/com/github/terrakok/modo/sample/screens/containers/SampleStack.kt +++ b/sample/src/main/java/com/github/terrakok/modo/sample/screens/containers/SampleStack.kt @@ -18,7 +18,6 @@ import androidx.compose.foundation.layout.only import androidx.compose.foundation.layout.systemBars import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -33,8 +32,8 @@ import com.github.terrakok.modo.sample.screens.base.LogLifecycle import com.github.terrakok.modo.sample.screens.dialogs.SampleBottomSheet import com.github.terrakok.modo.sample.screens.dialogs.SampleBottomSheetStack import com.github.terrakok.modo.stack.DialogPlaceHolder +import androidx.compose.runtime.getValue import com.github.terrakok.modo.NavigationReducer -import com.github.terrakok.modo.sample.components.NavigationTreeStrip import com.github.terrakok.modo.stack.StackNavModel import com.github.terrakok.modo.stack.StackScreen import com.github.terrakok.modo.stack.StackState @@ -87,14 +86,7 @@ open class SampleStack( .align(Alignment.TopEnd) ) } - NavigationTreeStrip( - this@SampleStack, - modifier = Modifier - .fillMaxWidth() - .windowInsetsPadding(WindowInsets.systemBars.only(WindowInsetsSides.Bottom)) - ) } - } @OptIn(ExperimentalModoApi::class) diff --git a/sample/src/main/java/com/github/terrakok/modo/sample/screens/dialogs/SettingsDialog.kt b/sample/src/main/java/com/github/terrakok/modo/sample/screens/dialogs/SettingsDialog.kt new file mode 100644 index 00000000..46dfc098 --- /dev/null +++ b/sample/src/main/java/com/github/terrakok/modo/sample/screens/dialogs/SettingsDialog.kt @@ -0,0 +1,124 @@ +package com.github.terrakok.modo.sample.screens.dialogs + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.Card +import androidx.compose.material.Divider +import androidx.compose.material.Icon +import androidx.compose.material.IconButton +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Slider +import androidx.compose.material.Switch +import androidx.compose.material.Text +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import kotlin.math.roundToInt +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import kotlinx.coroutines.launch +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.github.terrakok.modo.DialogScreen +import com.github.terrakok.modo.ExperimentalModoApi +import com.github.terrakok.modo.ScreenKey +import com.github.terrakok.modo.generateScreenKey +import com.github.terrakok.modo.sample.SampleAppSettings +import com.github.terrakok.modo.stack.LocalStackNavigation +import com.github.terrakok.modo.stack.back +import kotlinx.parcelize.Parcelize + +@OptIn(ExperimentalModoApi::class) +@Parcelize +class SettingsDialog( + override val screenKey: ScreenKey = generateScreenKey() +) : DialogScreen { + + override fun provideDialogConfig(): DialogScreen.DialogConfig = DialogScreen.DialogConfig.Custom + + @Composable + override fun Content(modifier: Modifier) { + val navigation = LocalStackNavigation.current + androidx.compose.foundation.layout.Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Card( + shape = RoundedCornerShape(16.dp), + elevation = 8.dp, + modifier = Modifier + .fillMaxWidth(0.85f) + .clickable( + enabled = false, + interactionSource = remember { MutableInteractionSource() }, + indication = null + ) {} + ) { + Column(modifier = Modifier.padding(24.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text(text = "Settings", style = MaterialTheme.typography.h6) + IconButton(onClick = { navigation.back() }) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = "Close settings" + ) + } + } + Spacer(modifier = Modifier.height(8.dp)) + Divider() + Spacer(modifier = Modifier.height(16.dp)) + val scope = rememberCoroutineScope() + val showNavigationTree by SampleAppSettings.instance.showNavigationTree.stateFlow.collectAsState() + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "Show navigation tree", + style = MaterialTheme.typography.body1 + ) + Switch( + checked = showNavigationTree, + onCheckedChange = { scope.launch { SampleAppSettings.instance.showNavigationTree.update(it) } } + ) + } + Spacer(modifier = Modifier.height(8.dp)) + val navTreeVisibleScreens by SampleAppSettings.instance.navTreeVisibleScreens.stateFlow.collectAsState() + Column(modifier = Modifier.fillMaxWidth()) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text(text = "Nav tree visible screens", style = MaterialTheme.typography.body1) + Text(text = "$navTreeVisibleScreens", style = MaterialTheme.typography.body1) + } + Slider( + value = navTreeVisibleScreens.toFloat(), + onValueChange = { scope.launch { SampleAppSettings.instance.navTreeVisibleScreens.update(it.roundToInt()) } }, + valueRange = 1f..10f, + steps = 8, + modifier = Modifier.fillMaxWidth() + ) + } + } + } + } + } +} diff --git a/sample/src/main/java/com/github/terrakok/modo/sample/settings/AppSetting.kt b/sample/src/main/java/com/github/terrakok/modo/sample/settings/AppSetting.kt new file mode 100644 index 00000000..e24342fa --- /dev/null +++ b/sample/src/main/java/com/github/terrakok/modo/sample/settings/AppSetting.kt @@ -0,0 +1,45 @@ +package com.github.terrakok.modo.sample.settings + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.floatPreferencesKey +import androidx.datastore.preferences.core.intPreferencesKey +import androidx.datastore.preferences.core.longPreferencesKey +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.core.stringSetPreferencesKey +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn + +class AppSetting<T> internal constructor( + private val dataStore: DataStore<Preferences>, + private val key: Preferences.Key<T>, + private val defaultValue: T, + scope: CoroutineScope +) { + val stateFlow: StateFlow<T> = dataStore.data + .map { it[key] ?: defaultValue } + .stateIn(scope, SharingStarted.Eagerly, defaultValue) + + val value: T get() = stateFlow.value + + suspend fun update(value: T) { + dataStore.edit { it[key] = value } + } + + class Factory( + private val dataStore: DataStore<Preferences>, + private val scope: CoroutineScope + ) { + fun boolean(key: String, default: Boolean) = AppSetting(dataStore, booleanPreferencesKey(key), default, scope) + fun int(key: String, default: Int) = AppSetting(dataStore, intPreferencesKey(key), default, scope) + fun string(key: String, default: String) = AppSetting(dataStore, stringPreferencesKey(key), default, scope) + fun float(key: String, default: Float) = AppSetting(dataStore, floatPreferencesKey(key), default, scope) + fun long(key: String, default: Long) = AppSetting(dataStore, longPreferencesKey(key), default, scope) + fun stringSet(key: String, default: Set<String>) = AppSetting(dataStore, stringSetPreferencesKey(key), default, scope) + } +} From df62ffc5239d38a6ac1c488d93c0714563ecd07d Mon Sep 17 00:00:00 2001 From: Karenkov Igor <karenkovigor@gmail.com> Date: Wed, 20 May 2026 00:39:17 +0700 Subject: [PATCH 06/26] Refactor `NavigationContainer` state APIs: added `subtreeFlow`, introduced hot `subtreeStateFlow` with deep tree observation. Updated `NavigationTree` logic and tests accordingly. --- .../com/github/terrakok/modo/ModoModels.kt | 53 ++++- .../modo/DeepNavigationStateFlowTest.kt | 206 ++++++++++++++++++ .../modo/sample/components/NavigationTree.kt | 12 +- 3 files changed, 255 insertions(+), 16 deletions(-) create mode 100644 modo-compose/src/test/java/com/github/terrakok/modo/DeepNavigationStateFlowTest.kt diff --git a/modo-compose/src/main/java/com/github/terrakok/modo/ModoModels.kt b/modo-compose/src/main/java/com/github/terrakok/modo/ModoModels.kt index bec96361..49de3867 100644 --- a/modo-compose/src/main/java/com/github/terrakok/modo/ModoModels.kt +++ b/modo-compose/src/main/java/com/github/terrakok/modo/ModoModels.kt @@ -2,13 +2,18 @@ package com.github.terrakok.modo import android.os.Parcelable import androidx.compose.runtime.Stable +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.FlowCollector +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.drop import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.merge +import kotlinx.coroutines.flow.shareIn /** * State of navigation used in [NavigationContainer]. Can be any type. @@ -69,21 +74,17 @@ fun <State : NavigationState> NavigationContainer<State>.dispatch( } /** - * Observes navigation state changes across the entire subtree rooted at this container. + * Cold [Flow] that observes navigation state changes across the entire subtree rooted at this container. * - * Emits the current state on subscription, and re-emits whenever this container or *any* descendant + * Emits the current state on collection, and re-emits whenever this container or *any* descendant * [NavigationContainer] dispatches. Observers are expected to re-walk via [NavigationState.getChildScreens] * to inspect the updated tree — emissions carry the root state, not nested states. * - * Resubscription semantics: [flatMapLatest] cancels and rebuilds the inner subscription tree - * whenever this container's own state changes, so subscriptions to removed children are - * abandoned and newly added children are picked up automatically. - * - * To turn this into a hot [StateFlow], wrap the result with `stateIn(scope, started, initial)` - * at the call site — the sharing policy is a consumer concern. + * Because this is a cold flow, no [CoroutineScope] is needed at the call site. Use [subtreeStateFlow] + * for a hot [StateFlow] with a synchronously accessible current value. */ @OptIn(ExperimentalCoroutinesApi::class) -fun NavigationContainer<*>.subtreeStateFlow(): Flow<NavigationState> = +fun NavigationContainer<*>.subtreeFlow(): Flow<NavigationState> = stateFlow.flatMapLatest { state -> flow { emit(state) @@ -93,12 +94,42 @@ fun NavigationContainer<*>.subtreeStateFlow(): Flow<NavigationState> = // outer flatMapLatest, not by branching here. state.getChildScreens() .filterIsInstance<NavigationContainer<*>>() - .map { it.subtreeStateFlow().drop(1) } + .map { it.subtreeFlow().drop(1) } .merge() .collect { emit(state) } } } +/** + * Hot [StateFlow] that observes navigation state changes across the entire subtree rooted at this container. + * + * Emits the current root state on collection, and re-emits whenever this container or *any* descendant + * [NavigationContainer] dispatches. Observers are expected to re-walk via [NavigationState.getChildScreens] + * to inspect the updated tree — emissions carry the root state, not nested states. + * + * Note: unlike a typical [StateFlow], same-value re-emissions are NOT deduplicated — a nested dispatch + * does not change the root state object but must still trigger observers. + * + * @param scope the [CoroutineScope] that keeps the returned [StateFlow] active. + * @param started controls when upstream collection starts and stops; defaults to [SharingStarted.Eagerly]. + */ +fun NavigationContainer<*>.subtreeStateFlow( + scope: CoroutineScope, + started: SharingStarted = SharingStarted.Eagerly +): StateFlow<NavigationState> { + // SharedFlow(replay=1) preserves all emissions without equals-based deduplication, + // which is required because nested dispatches re-emit the unchanged root state as a signal. + val shared: SharedFlow<NavigationState> = subtreeFlow().shareIn(scope, started, replay = 1) + return object : StateFlow<NavigationState> { + override val value: NavigationState + get() = shared.replayCache.firstOrNull() ?: stateFlow.value + override val replayCache: List<NavigationState> + get() = shared.replayCache.ifEmpty { listOf(stateFlow.value) } + override suspend fun collect(collector: FlowCollector<NavigationState>): Nothing = + shared.collect(collector) + } +} + /** * Migration shim for the dev-branch `navigationStateFlow()` extension that produced a * `snapshotFlow { navigationState }` flow. The new API splits that into two operations, @@ -116,4 +147,4 @@ fun NavigationContainer<*>.subtreeStateFlow(): Flow<NavigationState> = ) @Suppress("UNCHECKED_CAST", "unused") fun <State : NavigationState> NavigationContainer<State>.navigationStateFlow(): Flow<State> = - subtreeStateFlow() as Flow<State> + subtreeFlow() as Flow<State> diff --git a/modo-compose/src/test/java/com/github/terrakok/modo/DeepNavigationStateFlowTest.kt b/modo-compose/src/test/java/com/github/terrakok/modo/DeepNavigationStateFlowTest.kt new file mode 100644 index 00000000..a739a447 --- /dev/null +++ b/modo-compose/src/test/java/com/github/terrakok/modo/DeepNavigationStateFlowTest.kt @@ -0,0 +1,206 @@ +package com.github.terrakok.modo + +import android.os.Parcel +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import kotlinx.parcelize.Parcelize +import org.junit.jupiter.api.Test +import kotlin.test.assertEquals + +class DeepNavigationStateFlowTest { + + @Test + fun `subtreeStateFlow emits initial state on subscription`() = runTest { + val container = FakeContainer(NestedState()) + + val first = container.subtreeStateFlow(backgroundScope).first() + + assertEquals(NestedState(), first) + } + + @Test + fun `direct dispatch on root - emits new state`() = runTest { + val container = FakeContainer(NestedState()) + val emissions = collectInBackground(container) + + val newScreen = MockScreen() + container.dispatch { it.copy(children = it.children + newScreen) } + runCurrent() + + val expected: List<NavigationState> = listOf(NestedState(), NestedState(listOf(newScreen))) + assertEquals(expected, emissions) + } + + @Test + fun `nested container dispatch - root re-emits its state`() = runTest { + val nested = FakeContainer(NestedState()) + val rootInitial = NestedState(listOf(nested)) + val root = FakeContainer(rootInitial) + val emissions = collectInBackground(root) + + val deepScreen = MockScreen() + nested.dispatch { it.copy(children = it.children + deepScreen) } + runCurrent() + + // Root emits twice: initial + re-emit on nested change. Both equal rootInitial + // because the root state itself didn't change — observers re-walk getChildScreens(). + assertEquals(2, emissions.size) + assertEquals(rootInitial, emissions[0]) + assertEquals(rootInitial, emissions[1]) + // And the nested container itself reflects the change + assertEquals( + NestedState(listOf(deepScreen)), + nested.stateFlow.value + ) + } + + @Test + fun `two-level nested dispatch - root re-emits`() = runTest { + val grandchild = FakeContainer(NestedState()) + val child = FakeContainer(NestedState(listOf(grandchild))) + val root = FakeContainer(NestedState(listOf(child))) + val emissions = collectInBackground(root) + + val initialSize = emissions.size + + grandchild.dispatch { it.copy(children = it.children + MockScreen()) } + runCurrent() + + assertEquals(initialSize + 1, emissions.size) + } + + @Test + fun `removed child container - dispatching on orphan does not emit on root`() = runTest { + val nested = FakeContainer(NestedState()) + val root = FakeContainer(NestedState(listOf(nested))) + val emissions = collectInBackground(root) + + // Remove the nested container from the root. + root.dispatch { NestedState(emptyList()) } + runCurrent() + val sizeAfterRemoval = emissions.size + + // Now dispatch on the orphaned container. The root must NOT emit, because + // flatMapLatest dropped the inner subscription when root's state changed. + nested.dispatch { it.copy(children = it.children + MockScreen()) } + runCurrent() + + assertEquals(sizeAfterRemoval, emissions.size) + } + + @Test + fun `newly added child container - dispatch on it emits on root`() = runTest { + val root = FakeContainer(NestedState()) + val emissions = collectInBackground(root) + + // Add a fresh nested container after subscription started. + val nested = FakeContainer(NestedState()) + root.dispatch { it.copy(children = listOf(nested)) } + runCurrent() + val sizeAfterAdd = emissions.size + + // Dispatch on the new child — must propagate. + nested.dispatch { it.copy(children = it.children + MockScreen()) } + runCurrent() + + assertEquals(sizeAfterAdd + 1, emissions.size) + } + + @Test + fun `empty children then populated later - deep observation works through transition`() = runTest { + // Root starts with zero children. The inner flow inside flatMapLatest emits the initial + // state and then completes (empty merge). flatMapLatest is still subscribed to the + // outer StateFlow, so when children appear later, a fresh inner flow walks the tree. + val root = FakeContainer(NestedState()) + val emissions = collectInBackground(root) + assertEquals(1, emissions.size, "initial emission only") + + // Add a nested container. + val nested = FakeContainer(NestedState()) + root.dispatch { it.copy(children = listOf(nested)) } + runCurrent() + assertEquals(2, emissions.size, "root re-emits when children grow from empty to one") + + // Add a grandchild under the freshly-attached nested. Deep change must propagate + // because the outer flatMapLatest restart wired up `nested`'s flow. + val grandchild = FakeContainer(NestedState()) + nested.dispatch { it.copy(children = listOf(grandchild)) } + runCurrent() + assertEquals(3, emissions.size, "nested change propagates to root") + + // Dispatch at the deepest level — must propagate through both hops. + grandchild.dispatch { it.copy(children = it.children + MockScreen()) } + runCurrent() + assertEquals(4, emissions.size, "grandchild change propagates two levels up") + } + + @Test + fun `swap one child for another - only the live child propagates`() = runTest { + val oldChild = FakeContainer(NestedState()) + val newChild = FakeContainer(NestedState()) + val root = FakeContainer(NestedState(listOf(oldChild))) + val emissions = collectInBackground(root) + + // Replace the child. + root.dispatch { it.copy(children = listOf(newChild)) } + runCurrent() + val sizeAfterSwap = emissions.size + + // Dispatch on the old (orphaned) child — no propagation. + oldChild.dispatch { it.copy(children = it.children + MockScreen()) } + runCurrent() + assertEquals(sizeAfterSwap, emissions.size) + + // Dispatch on the new child — propagates. + newChild.dispatch { it.copy(children = it.children + MockScreen()) } + runCurrent() + assertEquals(sizeAfterSwap + 1, emissions.size) + } + +} + +private fun TestScope.collectInBackground(container: FakeContainer): MutableList<NavigationState> { + val emissions = mutableListOf<NavigationState>() + backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { + container.subtreeStateFlow(backgroundScope).collect { emissions += it } + } + runCurrent() + return emissions +} + +@Parcelize +private data class NestedState( + val children: List<Screen> = emptyList() +) : NavigationState { + override fun getChildScreens(): List<Screen> = children +} + +/** + * Test double: both a [Screen] (so it can live inside another container's state) and a + * [NavigationContainer]. Bypasses ContainerScreen/ComposeRenderer so we don't need a Main dispatcher. + */ +private class FakeContainer( + initialState: NestedState, + override val screenKey: ScreenKey = generateScreenKey() +) : Screen, NavigationContainer<NestedState> { + + private val navModel = NavModel(initialState, screenKey) + + override val stateFlow: StateFlow<NestedState> = navModel.stateFlow + + override fun dispatch(reducer: NavigationReducer<NestedState>) = navModel.dispatch(reducer) + + @Composable + override fun Content(modifier: Modifier) = Unit + + override fun describeContents(): Int = 0 + + override fun writeToParcel(parcel: Parcel, flags: Int) = Unit +} diff --git a/sample/src/main/java/com/github/terrakok/modo/sample/components/NavigationTree.kt b/sample/src/main/java/com/github/terrakok/modo/sample/components/NavigationTree.kt index 56adbcb3..a3431ce1 100644 --- a/sample/src/main/java/com/github/terrakok/modo/sample/components/NavigationTree.kt +++ b/sample/src/main/java/com/github/terrakok/modo/sample/components/NavigationTree.kt @@ -9,8 +9,10 @@ import androidx.compose.foundation.background import androidx.compose.foundation.layout.padding import androidx.compose.material.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.produceState +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontFamily @@ -35,10 +37,10 @@ fun NavigationTreeStrip( modifier: Modifier = Modifier, visibleScreens: Int = 2, ) { - val state by produceState<NavigationState?>(initialValue = null, container) { - container.subtreeStateFlow().collect { value = it } - } - val text = state?.compactRender(visibleScreens).orEmpty() + val scope = rememberCoroutineScope() + val stateFlow = remember(container) { container.subtreeStateFlow(scope) } + val state by stateFlow.collectAsState() + val text = state.compactRender(visibleScreens) AnimatedContent( targetState = text, transitionSpec = { fadeIn() togetherWith fadeOut() using SizeTransform(clip = true) }, From aec9d6eb06ffcb0ef1f9a55f0889e0ee1972de17 Mon Sep 17 00:00:00 2001 From: Karenkov Igor <karenkovigor@gmail.com> Date: Wed, 20 May 2026 13:02:34 +0700 Subject: [PATCH 07/26] Drop redundant @Stable from NavModel --- .../src/main/java/com/github/terrakok/modo/ContainerScreen.kt | 1 - 1 file changed, 1 deletion(-) diff --git a/modo-compose/src/main/java/com/github/terrakok/modo/ContainerScreen.kt b/modo-compose/src/main/java/com/github/terrakok/modo/ContainerScreen.kt index b205c0f8..c23d4a17 100644 --- a/modo-compose/src/main/java/com/github/terrakok/modo/ContainerScreen.kt +++ b/modo-compose/src/main/java/com/github/terrakok/modo/ContainerScreen.kt @@ -82,7 +82,6 @@ abstract class ContainerScreen<State : NavigationState>( * exclusively through [dispatch]. Parcelable so it survives process death. * Intended to be owned by a [ContainerScreen], which delegates [NavigationContainer] to it. */ -@Stable class NavModel<State : NavigationState>( initialState: State, val screenKey: ScreenKey = generateScreenKey() From 1516ae1b2d1ef007aa841893c8b5e4101d466bb2 Mon Sep 17 00:00:00 2001 From: Karenkov Igor <karenkovigor@gmail.com> Date: Wed, 20 May 2026 21:19:16 +0700 Subject: [PATCH 08/26] Update docs for the reducer-based navigation API --- README.md | 7 ++-- Writerside/codeSnippets/SampleAction.kt | 43 ++++--------------------- Writerside/topics/Core-concepts.md | 40 ++++++++++++++--------- Writerside/topics/ModoOverview.md | 8 ++--- Writerside/topics/StackScreen.md | 8 ++--- Writerside/topics/snippets.topic | 2 +- 6 files changed, 40 insertions(+), 68 deletions(-) diff --git a/README.md b/README.md index a758569e..ee056ee1 100644 --- a/README.md +++ b/README.md @@ -27,21 +27,20 @@ Each integration of Modo is a * Each node is a <code>Screen</code> or <code>ContainerScreen</code>. * Leaf nodes are <code>Screen</code>s. * Inner nodes are <code>ContainerScreen</code>s. They can contain other <code>Screen</code>s or <code>ContainerScreen</code>s in their <code> - navigationState</code>. + NavigationState</code>. * The root node is a <code>RootScreen</code>. You can have multiple roots in your app. See <a href="https://ikarenkov.github.io/Modo/how-to-integrate-modo-to-your-app.html">How to integrate Modo</a> for details. ## State Defines UI * `NavigationState` defines the UI: - * The initial state is defined in the constructor of `ContainerScreen` by `navModel: NavModel<State, Action>`. - * To update the state, use `dispatch(action: Action)` on `NavigationContainer`, or use the built-in extension functions + * The initial state is defined in the constructor of `ContainerScreen` by `navModel: NavModel<State>`. + * To update the state, dispatch a lambda that calculates the new state from the old one, or use the built-in extension functions for [StackScreen](modo-compose/src/main/java/com/github/terrakok/modo/stack/StackActions.kt) and [MultiScreen](modo-compose/src/main/java/com/github/terrakok/modo/multiscreen/MultiScreenActions.kt). * There are `Screen` and `ContainerScreen`: * `ContainerScreen` can contain and render child screens. * There are some built-in implementations of `ContainerScreen` like `StackScreen` and `MultiScreen`. -* You can easily create custom `Action` by extending `Action` or `ReducerAction`. # For Maintainers diff --git a/Writerside/codeSnippets/SampleAction.kt b/Writerside/codeSnippets/SampleAction.kt index 9ba3d3d4..ba2ce534 100644 --- a/Writerside/codeSnippets/SampleAction.kt +++ b/Writerside/codeSnippets/SampleAction.kt @@ -1,42 +1,11 @@ -fun interface SampleAction : ReducerAction<SampleState> { - class Remove : SampleAction { - override fun reduce(oldState: SampleState): SampleState = - oldState.copy(screen3 = null) - } +fun interface SampleReducer : NavigationReducer<SampleState> - class CreateScreen : SampleAction { - override fun reduce(oldState: SampleState): SampleState = - oldState.copy(screen3 = NestedScreen(canBeRemoved = true)) +object SampleReducers { + val Remove = SampleReducer { oldState -> + oldState.copy(screen3 = null) } -} - -sealed interface SampleAction : NavigationAction<SampleState> { - class Remove : SampleAction - class CreateScreen : SampleAction -} - -@Parcelize -internal class RemovableItemContainerScreen( - private val navModel: NavModel<RemovableItemContainerState, RemovableItemContainerAction> = NavModel( - RemovableItemContainerState( - NestedScreen(canBeRemoved = false), - NestedScreen(canBeRemoved = false), - NestedScreen(canBeRemoved = true), - NestedScreen(canBeRemoved = false), - ) - ) -) : ContainerScreen<RemovableItemContainerState, RemovableItemContainerAction>(navModel) { - - override val reducer: NavigationReducer<RemovableItemContainerState, RemovableItemContainerAction> = NavigationReducer<RemovableItemContainerState, RemovableItemContainerAction> { action, state -> - when (action) { - is RemovableItemContainerAction.Remove -> { - state.copy(screen3 = null) - } - is RemovableItemContainerAction.CreateScreen -> { - state.copy(screen3 = NestedScreen(canBeRemoved = true)) - } - } + val CreateScreen = SampleReducer { oldState -> + oldState.copy(screen3 = NestedScreen(canBeRemoved = true)) } - } diff --git a/Writerside/topics/Core-concepts.md b/Writerside/topics/Core-concepts.md index 1bafa57d..a52b7b48 100644 --- a/Writerside/topics/Core-concepts.md +++ b/Writerside/topics/Core-concepts.md @@ -48,13 +48,13 @@ structures. [`StackScreen`](StackScreen.md) and `MultiScreen` are built-in imple ![diagram_2.png](diagram_2.png){ height = 300 } -Each ContainerScreen is defined by two typed parameters: State and Action. +Each ContainerScreen is parameterized by its `State` type. ```kotlin @Stable -abstract class ContainerScreen<State : NavigationState, Action : NavigationAction<State>>( - private val navModel: NavModel<State, Action> -) : Screen, NavigationContainer<State, Action> by navModel +abstract class ContainerScreen<State : NavigationState>( + private val navModel: NavModel<State> +) : Screen, NavigationContainer<State> by navModel ``` { collapsible="true" default-state="collapsed" collapsed-title="ContainerScreen"} @@ -62,7 +62,7 @@ abstract class ContainerScreen<State : NavigationState, Action : NavigationActio <procedure> <title>State

-NavigationState - a class that can contain nested screens and other additional information. The state can be updated by calling dispatch(action). +NavigationState - a class that can contain nested screens and other additional information. The state can be updated by calling dispatch(reducer).

@Parcelize @@ -81,10 +81,9 @@ Read the State Update section for more details. -Action +Reducer

-NavigationAction - a marker interface to distinguish actions for this container on a specific State. You can also use -ReducerAction to define actions with an in-place update function: +NavigationReducer - a pure state transformer that takes the old state and returns the new one. Dispatch it via dispatch(reducer) to update the container's state:

@@ -106,18 +105,27 @@ The built-in `StackScreen` and `MultiScreen` use `InternalContent` under the hoo ## State Update -To update the state of a `ContainerScreen`, use `dispatch(action: Action)`. -There are two ways to define your action: +The simplest way to update a `ContainerScreen`'s state is to dispatch a lambda that calculates the new state from the old one. For example, to push two new screens onto a `StackScreen`: -### ReducerAction (Recommended) +```kotlin +stackContainer.dispatch { oldState -> + StackState(oldState.stack + listOf(NextScreen(), AnotherScreen())) +} +``` -ReducerAction allows defining the update function in-place. - +The built-in containers already expose convenience extension functions for the most common operations, so the same change can be written as: + +```kotlin +stackContainer.forward(NextScreen(), AnotherScreen()) +``` + +Explore the available commands in [`StackActions.kt`](%github_code_url%modo-compose/src/main/java/com/github/terrakok/modo/stack/StackActions.kt) (`forward`, `back`, `replace`, …) and [`MultiScreenActions.kt`](%github_code_url%modo-compose/src/main/java/com/github/terrakok/modo/multiscreen/MultiScreenActions.kt). -### Custom Reducer + Action +If you need reusable or parameterized state changes, define your own `NavigationReducer`. Pick whichever shape fits your code: -You can provide a reducer in your ContainerScreen implementation. - +- named instances on an object (the `SampleReducer` / `SampleReducers` example above) +- a class with constructor parameters — see [`RemoveTabReducer`](%github_code_url%sample/src/main/java/com/github/terrakok/modo/sample/screens/containers/RemoveTabReducer.kt) in the sample app +- your own extension functions on a typed `NavigationContainer` for ergonomic call sites ## Root Screen diff --git a/Writerside/topics/ModoOverview.md b/Writerside/topics/ModoOverview.md index 53614537..7b944205 100644 --- a/Writerside/topics/ModoOverview.md +++ b/Writerside/topics/ModoOverview.md @@ -39,15 +39,11 @@ Modo is an easy-to-use library. Here are some of the most-used features of Modo val onForwardClick = { stackNavigation.forward(SampleScreen()) } ``` -* You can easily change `NavigationState` as needed by calling `dispatch(action: (StackState) -> StackState)` on `NavigationContainer`: +* For arbitrary state changes the built-in commands don't cover, pass a lambda that calculates the new state from the old one. For example, to remove every `LoginScreen` from the stack: ```kotlin navigation.dispatch { oldState -> - StackState( - oldState.stack.filterIndexed { index, screen -> - index % 2 == 0 && screen != oldState.stack.last() - } - ) + StackState(oldState.stack.filter { it !is LoginScreen }) } ``` diff --git a/Writerside/topics/StackScreen.md b/Writerside/topics/StackScreen.md index 867bba70..ff52809c 100644 --- a/Writerside/topics/StackScreen.md +++ b/Writerside/topics/StackScreen.md @@ -18,14 +18,14 @@ val stackScreen = DefaultStackScreen( ) ``` -You can change the stack by calling `dispatch(Action)` on `NavigationContainer`. +You can change the stack by dispatching a `StackReducer` on `StackNavContainer` (an alias for `NavigationContainer`). -For a convenient way to update the state, there is a function `dispatch(action: (StackState) -> StackState)` that allows you to change the state +For a convenient way to update the state, there is a function `dispatch(reducer: (StackState) -> StackState)` that allows you to change the state according to your needs. There is also a list of built-in commands. -## Built-in Navigation Actions +## Built-in Stack Commands -Modo provides a list of built-in actions for stack navigation. You can explore the available +Modo provides a list of built-in commands (extension functions on `StackNavContainer`) for stack navigation. You can explore the available commands [here](%github_code_url%modo-compose/src/main/java/com/github/terrakok/modo/stack/StackActions.kt). Some of them include: * `forward(screen: Screen, vararg screens: Screen)` - Adds the given screens to the top of the stack. diff --git a/Writerside/topics/snippets.topic b/Writerside/topics/snippets.topic index d2c33244..993e5b94 100644 --- a/Writerside/topics/snippets.topic +++ b/Writerside/topics/snippets.topic @@ -20,7 +20,7 @@ Each node is a Screen or ContainerScreen. Leaf nodes are Screens. Inner nodes are ContainerScreens. They can contain other Screens or ContainerScreens in their - navigationState. + NavigationState. The root node is a RootScreen. You can have multiple roots in your app. See How to integrate Modo for details. From 9971ca151fb355526a7aa5875773514ceda993cf Mon Sep 17 00:00:00 2001 From: "i.karenkov" Date: Mon, 25 May 2026 00:28:44 +0700 Subject: [PATCH 09/26] Added deprecation annotations and migration shims for legacy `navigationState` and `navigationStateFlow` properties --- .../com/github/terrakok/modo/ModoModels.kt | 40 +++++++++++++++---- 1 file changed, 32 insertions(+), 8 deletions(-) diff --git a/modo-compose/src/main/java/com/github/terrakok/modo/ModoModels.kt b/modo-compose/src/main/java/com/github/terrakok/modo/ModoModels.kt index 49de3867..acd942de 100644 --- a/modo-compose/src/main/java/com/github/terrakok/modo/ModoModels.kt +++ b/modo-compose/src/main/java/com/github/terrakok/modo/ModoModels.kt @@ -131,18 +131,42 @@ fun NavigationContainer<*>.subtreeStateFlow( } /** - * Migration shim for the dev-branch `navigationStateFlow()` extension that produced a - * `snapshotFlow { navigationState }` flow. The new API splits that into two operations, - * so this shim makes silent migration impossible. + * Migration shim for the previous `navigationState` property on [NavigationContainer]. Pick the + * replacement that matches the consumer's actual intent: * - * - For per-container observation: use the [NavigationContainer.stateFlow] property. - * - For whole-subtree observation (the previous behavior when consumers walked `getChildScreens()`): - * use [subtreeStateFlow]. + * - [NavigationContainer.stateFlow].value — one-shot read of this container's state. + * - `stateFlow.collectAsState()` — Compose-reactive read of this container's state. + * - [subtreeStateFlow] — hot StateFlow observing this container AND every nested container. + * - [subtreeFlow] — cold Flow equivalent of [subtreeStateFlow], no scope required. + * - Or change the declared type to the concrete ContainerScreen subtype (StackScreen, + * MultiScreen, ...) which still exposes Compose-reactive `navigationState`. + */ +@Deprecated( + message = "navigationState was removed from NavigationContainer. Pick a migration:\n" + + " - stateFlow.value — one-shot read of this container's state\n" + + " - stateFlow.collectAsState() — Compose-reactive read of this container's state\n" + + " - subtreeStateFlow(scope) — hot StateFlow observing this container AND every nested container (whole subtree)\n" + + " - subtreeFlow() — cold Flow equivalent of subtreeStateFlow, no scope required\n" + + " - or change the declared type to the concrete ContainerScreen subtype (StackScreen, MultiScreen, ...) " + + "which still exposes Compose-reactive navigationState.", + replaceWith = ReplaceWith("stateFlow.value"), + level = DeprecationLevel.ERROR, +) +@Suppress("unused") +val NavigationContainer.navigationState: State + get() = stateFlow.value + +/** + * Migration shim for the dev-branch `navigationStateFlow()` extension that produced a + * `snapshotFlow { navigationState }` flow. The closest replacement is the per-container + * [NavigationContainer.stateFlow] property; for whole-subtree observation use [subtreeFlow] + * (cold) or [subtreeStateFlow] (hot). */ @Deprecated( message = "Replaced by the `stateFlow` property (per-container) and " + - "`subtreeStateFlow()` (whole subtree). The previous snapshotFlow-based extension is gone.", - replaceWith = ReplaceWith("subtreeStateFlow()"), + "`subtreeFlow( сс ии )` / `subtreeStateFlow(scope)` (whole subtree). " + + "The previous snapshotFlow-based extension is gone.", + replaceWith = ReplaceWith("stateFlow"), level = DeprecationLevel.ERROR, ) @Suppress("UNCHECKED_CAST", "unused") From 5a5b5a9d8b8eea2d163d60c25651b047581dcaa5 Mon Sep 17 00:00:00 2001 From: "i.karenkov" Date: Mon, 25 May 2026 00:31:48 +0700 Subject: [PATCH 10/26] Bump version to 0.12.0-rc1 --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index ac1feda9..32ed394b 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,7 +1,7 @@ [versions] composeWheelPicker = "1.0.0-beta05" leakcanaryAndroid = "2.14" -modo = "0.11.0" +modo = "0.12.0-rc1" #noinspection AndroidGradlePluginVersion androidGradlePlugin = "8.13.2" nexusPublish = "2.0.0" From 3b650c5da7047fe0b3e4b8d1d91c0629bfd98137 Mon Sep 17 00:00:00 2001 From: "i.karenkov" Date: Mon, 25 May 2026 10:26:56 +0700 Subject: [PATCH 11/26] Add 0.12.0 changelog --- changelogs/0.12.0.md | 102 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 102 insertions(+) create mode 100644 changelogs/0.12.0.md diff --git a/changelogs/0.12.0.md b/changelogs/0.12.0.md new file mode 100644 index 00000000..c3b0be4d --- /dev/null +++ b/changelogs/0.12.0.md @@ -0,0 +1,102 @@ +## Architecture refactor + +Before this release the navigation layers were tightly coupled: `ContainerScreen` carried both a model and a `NavigationRenderer`, the renderer interface was public, and state updates flowed through an action-dispatch protocol where `NavigationAction` and `ReducerAction` were separate concepts (action = command; reducer = `(action, state) -> State?`). Together they forced model and renderer to share an `Action` vocabulary — hence the two-generic types. + +This refactor separates the concerns cleanly: + +- **`NavModel` is a pure-Kotlin model.** `MutableStateFlow` plus `dispatch(reducer)`. No `androidx.compose.runtime` references at the declaration level (stability is inherited from `NavigationContainer`). `Parcelable`, so it survives process death on its own. +- **`ComposeRenderer` is an internal Compose adapter.** It subscribes to a `StateFlow` via a managed `CoroutineScope` and mirrors the value into Compose state. It does not own state and does not know what produced it. The public `NavigationRenderer` interface is gone. +- **`ContainerScreen` is just `Screen, NavigationContainer by navModel`.** A screen with a model glued in via delegation, plus an internal renderer. No reducer plumbing or renderer indirection. +- **One reducer concept, not two.** `NavigationReducer` is now the single primitive: `fun interface NavigationReducer { fun reduce(oldState: State): State }`. It replaces `NavigationAction` (the command-style marker) entirely — the dispatched reducer *is* the command. The old `ReducerAction` survives only as a deprecated typealias to `NavigationReducer`. Collapsing the two concepts is what lets the `Action` generic disappear. + +Everything else in this release — single-generic types, `subtreeStateFlow`, the deprecations — falls out of those new boundaries. + +--- + +## API changes + +**Generics collapsed to a single type parameter.** `NavigationContainer`, `ContainerScreen`, and `NavModel` no longer have an `Action` generic. Call sites must drop the second type parameter: + +- `ContainerScreen` → `ContainerScreen` +- `NavigationContainer` → `NavigationContainer` +- `NavModel` → `NavModel` + +**Action-dispatch model replaced with pure reducers.** `NavigationReducer` is now a single-method `fun interface`: + +```kotlin +fun interface NavigationReducer { + fun reduce(oldState: State): State +} +``` + +`dispatch(...)` accepts a reducer directly; state updates are pure functions of the previous state with no action argument. + +**`navigationState` removed; state exposed as a `StateFlow`.** `NavModel` holds state in a `MutableStateFlow`, so observers and synchronous readers go through `stateFlow.value`. + +**Deep tree observation.** Two new APIs let callers observe changes anywhere in the navigation subtree under a container: + +- `subtreeFlow(): Flow` — cold flow that emits the root state on collection and re-emits whenever any descendant container dispatches. +- `subtreeStateFlow(scope, started = Eagerly): StateFlow` — hot variant with synchronously accessible `.value`. **Intentionally does not deduplicate same-value emissions**, so nested dispatches that produce an unchanged root reference still notify observers to re-walk the tree. + +**`NavigationRenderer` interface removed.** `ComposeRenderer` is internal and now owns a `CoroutineScope` (created in `init`, cancelled on `dispose`) that collects state from the underlying flow. + +**`NavModel` is `Parcelable`.** State now survives process death via the state-flow holder; no separate render layer is required. + +--- + +## Deprecations + +The previous action-based / hot-flow API is retained as deprecated bindings so existing code keeps compiling under transitional builds, but most are at **`ERROR`** level — they will not run. + +- `NavigationAction` and its subtypes (`StackAction`, `MultiScreenAction`, `ListNavigationAction`, etc.) — **deprecated**. Replace with reducer factories (`StackActions`, `MultiScreenActions`, `ListReducer`, the new `RemoveTabReducer`, …). +- `NavigationContainer.dispatch(action: Action)` — **deprecated**. Use `dispatch(reducer: NavigationReducer)`. +- `NavigationContainer.navigationState` property — **removed**. Read `stateFlow.value`. +- `NavigationContainer.navigationStateFlow()` extension — **deprecated at `ERROR` level (no runtime fallback)**. Migration path: + - per-container, use `stateFlow` + - subtree-wide, use `subtreeFlow()` or `subtreeStateFlow(scope)` +- `NavigationRenderer` interface — **removed**. The renderer is internal to `ComposeRenderer`; downstream code should not depend on it. +- `NavigationReducer` (two-generic form) — **removed**. Use the single-generic `fun interface NavigationReducer`. + +### Migration cheat sheet + +1. Drop the `Action` generic from `ContainerScreen` / `NavigationContainer` / `NavModel` declarations and references. +2. Convert custom actions to reducers: `NavigationReducer { oldState -> /* compute new state */ }`. +3. `container.navigationState` → `container.stateFlow.value`. +4. `container.navigationStateFlow()` → `container.stateFlow` (per-container) or `container.subtreeStateFlow(scope)` (deep observation). +5. Replace any references to the removed `NavigationRenderer` interface with the hot/cold `StateFlow` APIs above. + +--- + +## Sample app changes + +- **`SampleAppSettings`** — DataStore-backed settings holder. Persists `showNavigationTree` (Boolean) and `navTreeVisibleScreens` (Int, default `2`). +- **`SettingsDialog`** — new dialog to toggle tree visibility and adjust depth via sliders. +- **`NavigationTree`** — new composable that replaces the previous `NavigationTreeStrip`. Driven by `subtreeStateFlow()`, mounted at the root activity, wrapped in `AnimatedContent` with dynamic visibility tied to settings. +- **`LifecycleEventsHistory`** — added `maxLines` support to bound the displayed event log. +- **`RemoveTabReducer`** — extracted reducer that replaces the previous `RemoveTabAction` in the multi-screen sample. + +--- + +## Test changes + +- **New** `ComposeRendererDisposalTest` — verifies the renderer's `CoroutineScope` lifecycle (created on init, cancelled on dispose). +- **New** `DeepNavigationStateFlowTest` — covers `subtreeStateFlow` across nested containers, including the deliberate no-op re-emission when a descendant dispatches without changing the root reference. +- **Renamed** (content updated to reducer form): + - `ListNavigationActionAddScreensTest` → `ListReducerAddScreensTest` + - `ListNavigationActionRemoveScreensTest` → `ListReducerRemoveScreensTest` + - `ListNavigationActionSetTest` → `ListReducerSetTest` + +--- + +## Repo meta + +- Add `AGENTS.md` and `CLAUDE.md` for AI-agent orientation. +- Add `.claude/skills/task-workflow/SKILL.md` for the task-folder workflow. + +--- + +## What's Changed + +* Architecture refactor: decouple NavModel, renderer, and reducer pipeline by @ikarenkov in https://github.com/ikarenkov/Modo/pull/78 + +**Full Changelog**: https://github.com/ikarenkov/Modo/compare/v0.11.0...v0.12.0 From 32825a91f244a7b64f8ef3e66ef66b9c9e543451 Mon Sep 17 00:00:00 2001 From: "i.karenkov" Date: Mon, 25 May 2026 10:41:35 +0700 Subject: [PATCH 12/26] Update settings for idea and claude --- .claude/settings.json | 28 ++++++++++++++++++++++++++++ .idea/codeStyles/Project.xml | 25 ------------------------- 2 files changed, 28 insertions(+), 25 deletions(-) create mode 100644 .claude/settings.json diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 00000000..a4ca5e02 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,28 @@ +{ + "permissions": { + "allow": [ + "Bash(git show:*)", + "Bash(git log:*)", + "Bash(git diff:*)", + "Bash(git status)", + "Bash(git blame:*)", + "Bash(sed -n:*)", + "Bash(head:*)", + "Bash(tail:*)", + "Bash(cat:*)", + "Bash(wc:*)", + "Bash(xxd:*)", + "Bash(file:*)", + "Bash(ls:*)", + "Bash(rg:*)", + "Bash(grep:*)", + "Bash(jq:*)", + "Bash(diff:*)", + "Bash(pwd)" + ], + "deny": [ + "Read(./local.properties)", + "Read(**/local.properties)" + ] + } +} \ No newline at end of file diff --git a/.idea/codeStyles/Project.xml b/.idea/codeStyles/Project.xml index 5d238a15..bc3c083e 100644 --- a/.idea/codeStyles/Project.xml +++ b/.idea/codeStyles/Project.xml @@ -4,31 +4,6 @@ -