From 6be4a887ec1029308d16211303baab807265ab13 Mon Sep 17 00:00:00 2001 From: Karol Celebi Date: Sat, 21 Mar 2026 11:30:41 +0100 Subject: [PATCH 1/5] docs: add FSM integration plan --- plans/fsm-integration.md | 1615 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 1615 insertions(+) create mode 100644 plans/fsm-integration.md diff --git a/plans/fsm-integration.md b/plans/fsm-integration.md new file mode 100644 index 00000000..8f3d3332 --- /dev/null +++ b/plans/fsm-integration.md @@ -0,0 +1,1615 @@ +# FSM (Finite State Machine) Integration Plan for FlowMVI + +## Overview + +This document describes the design and implementation plan for adding finite state machine (FSM) transitions to FlowMVI via a plugin-based DSL. + +**The FSM plugin is a full replacement for `reduce {}`**, not a complement. Handlers inside `on {}` receive a `TransitionScope` that **delegates to `PipelineContext`**, giving them access to every store API: `updateState`, `action()`, `intent()`, `launch {}`, `withState {}`, coroutine scope, config, subscriber count, and more. On top of that, `TransitionScope` provides a typed `state: T` property and convenience `transitionTo()` methods. + +**Design decisions:** +- Plugin-based integration — installs via `onIntent` for dispatch, `onState` for enforcement +- Configurable enforcement — throw `InvalidStateException` in debuggable mode, silently veto in release +- Lives in `core/` module +- Handlers are imperative (`Unit` return) — they call `transitionTo`/`updateState`/`action`/`intent`/`launch` directly +- Intent-driven: `on { transitionTo(NewState) }` +- Focused API — no onEnter/onExit, guards, hierarchical states +- Not marked `@ExperimentalFlowMVIAPI` +- **Compose API** — `compose()` inside `transitions {}` replaces manual `delegate()` + `whileSubscribed` + `combine` boilerplate for child store composition + +--- + +## 1. API Design + +### 1.1 `TransitionScope` — The Handler Context (public) + +The scope available inside `on {}` handlers. Extends `PipelineContext` for full store API access and adds typed state + convenience `transitionTo()`. + +```kotlin +/** + * Scope available inside FSM [on] handlers. + * Delegates to [PipelineContext] for full store API access, and adds + * typed [state] and convenience [transitionTo] methods. + * + * Everything you can do in [reduce], you can do here — plus you get the current + * state guaranteed to be of type [T], and a shorthand for state transitions. + */ +@FlowMVIDSL +@OptIn(ExperimentalSubclassOptIn::class) +@SubclassOptInRequired(NotIntendedForInheritance::class) +public interface TransitionScope : + PipelineContext { + + /** + * The current state, guaranteed to be of type [T] at the time this handler was invoked. + * + * **Note**: If [StoreConfiguration.parallelIntents] is `true`, the actual store state may have + * changed by the time you read this. Use [updateState] for atomic operations. + * For most use cases this value is stable because the default is sequential intent processing. + */ + public val state: T + + /** + * Transition to [target] state. Shorthand for `updateState { target }`. + * The transition will be validated by the FSM's enforcement rules via [onState]. + */ + @FlowMVIDSL + public suspend fun transitionTo(target: S) + + /** + * Transition to [target] state and emit [sideEffect] action. + * Shorthand for `updateState { target }; action(sideEffect)`. + */ + @FlowMVIDSL + public suspend fun transitionTo(target: S, sideEffect: A) +} +``` + +### 1.2 `TransitionScopeImpl` — Internal Implementation + +```kotlin +@PublishedApi +internal class TransitionScopeImpl( + private val pipeline: PipelineContext, + override val state: T, +) : TransitionScope, PipelineContext by pipeline { + + override suspend fun transitionTo(target: S) { + updateState { target } + } + + override suspend fun transitionTo(target: S, sideEffect: A) { + updateState { target } + action(sideEffect) + } +} +``` + +Uses Kotlin's `by` delegation to forward all `PipelineContext` members to the real pipeline. Only `state`, `transitionTo()` are added. + +### 1.3 `IntentHandler` — Internal Typealias + +The stored handler type. Takes the `PipelineContext`, the untyped state snapshot, and the untyped intent. The `on` builder wraps the user's typed lambda into this form. + +```kotlin +/** + * Internal handler function. Takes PipelineContext + current state + intent. + * The PipelineContext is the receiver so handlers can call updateState/action/intent/launch. + */ +internal typealias IntentHandler = suspend PipelineContext.(state: S, intent: I) -> Unit +``` + +### 1.4 `TransitionsBuilder` — Top-level DSL + +Collects `StateDefinition`s and builds a `TransitionGraph`. + +```kotlin +/** + * Builder that collects state definitions and compiles a [TransitionGraph]. + */ +@FlowMVIDSL +public class TransitionsBuilder + @PublishedApi internal constructor() { + + @PublishedApi + internal val definitions: MutableMap, StateDefinition> = mutableMapOf() + + /** + * Define intent handlers for state type [T]. + * Only intents registered via [StateTransitionsBuilder.on] will be handled when the store is in state [T]. + * Any state change to a type not reachable from a [state] block will be vetoed (or throw in debug mode). + * + * @throws IllegalArgumentException if [T] is already defined in this transitions block. + */ + @FlowMVIDSL + public inline fun state( + @BuilderInference block: StateTransitionsBuilder.() -> Unit, + ) { + val stateClass = T::class + require(stateClass !in definitions) { + "State ${stateClass.simpleName} is already defined in this transitions block" + } + val builder = StateTransitionsBuilder(stateClass).apply(block) + definitions[stateClass] = builder.build() + } + + @PublishedApi + internal fun build(): TransitionGraph = TransitionGraph( + definitions = definitions.toMap(), + ) +} +``` + +### 1.5 `StateTransitionsBuilder` — Per-state Handler Registration + +```kotlin +/** + * Builder for declaring intent handlers within a specific state type [T]. + */ +@FlowMVIDSL +public class StateTransitionsBuilder + @PublishedApi internal constructor( + private val stateType: KClass, + ) { + + @PublishedApi + internal val handlers: MutableMap, IntentHandler> = mutableMapOf() + + /** + * Handle intent of type [E] when the store is in state [T]. + * Inside the lambda, you have full [PipelineContext] access plus typed [TransitionScope.state]. + * Use [TransitionScope.transitionTo] to change state, or call [updateState]/[action]/[intent]/[launch] directly. + * + * @throws IllegalArgumentException if [E] is already handled for state [T]. + */ + @FlowMVIDSL + public inline fun on( + noinline block: suspend TransitionScope.(E) -> Unit, + ) { + val intentClass = E::class + require(intentClass !in handlers) { + "Intent ${intentClass.simpleName} is already handled for state ${stateType.simpleName}" + } + handlers[intentClass] = handler@{ state, intent -> + @Suppress("UNCHECKED_CAST") + val scope = TransitionScopeImpl(this, state as T) + @Suppress("UNCHECKED_CAST") + scope.block(intent as E) + } + } + + @PublishedApi + internal fun build(): StateDefinition = StateDefinition( + stateType = stateType, + handlers = handlers.toMap(), + ) +} +``` + +### 1.6 Entry Points + +#### `StoreBuilder.transitions {}` + +```kotlin +/** + * Install a finite state machine plugin that handles intents based on the current state type. + * + * This is a full replacement for [reduce] — handlers have full [PipelineContext] access. + * Intents not matched by any FSM handler pass through to downstream plugins. + * + * Name is hardcoded because usually multiple FSM plugins are not used. + * Provide your own name if you want to have multiple FSM plugins. + */ +@IgnorableReturnValue +@FlowMVIDSL +public inline fun StoreBuilder.transitions( + name: String = TransitionsPluginName, + @BuilderInference block: TransitionsBuilder.() -> Unit, +): Unit = install(transitionsPlugin(name, block)) +``` + +#### `transitionsPlugin()` factory + +```kotlin +public const val TransitionsPluginName: String = "TransitionsPlugin" + +/** + * Create a finite state machine plugin. + * + * @see transitions + */ +@FlowMVIDSL +public inline fun transitionsPlugin( + name: String = TransitionsPluginName, + @BuilderInference block: TransitionsBuilder.() -> Unit, +): StorePlugin { + val graph = TransitionsBuilder().apply(block).build() + return plugin { + this.name = name + // ... onIntent + onState (see Section 4) + } +} +``` + +### 1.7 Handler Signature Summary + +| Old Plan | Revised Plan | +|----------|-------------| +| `suspend TransitionContext.(E) -> TransitionResult` | `suspend TransitionScope.(E) -> Unit` | + +Key differences: +- **No `TransitionResult`** — handlers are imperative, calling `transitionTo`/`updateState`/`action`/etc. +- **No `TransitionContext`** — replaced by `TransitionScope` which extends `PipelineContext` +- **No `dontTransition()`** — just don't call `transitionTo()`, or call `action()` directly +- **Full `PipelineContext` access** — `launch {}`, `intent()`, `emit()`, `withState {}`, `config`, etc. + +### 1.8 Compose API — Child Store Composition Inside Transitions + +The `compose()` function integrates child store composition directly into the `transitions {}` DSL. +It replaces the manual `delegate()` + `whileSubscribed` + `combine` boilerplate pattern (as seen in `ProgressiveContainer.kt`). + +**Two variants:** +1. **Top-level compose** — always-active subscriptions (for data-class states or permanent compositions) +2. **State-scoped compose** — subscriptions scoped to when parent is in a specific state type (for sealed hierarchies) + +#### Top-level `compose()` in `TransitionsBuilder` + +```kotlin +@FlowMVIDSL +public class TransitionsBuilder + @PublishedApi internal constructor() { + + // ... existing: state {}, definitions, build() + + @PublishedApi + internal val topLevelCompositions: MutableList> = mutableListOf() + + /** + * Compose a child store into this store's state. The child's state changes are merged into the + * parent state at all times while the parent store is active. + * + * The child store is automatically started as a lifecycle child of the parent store. + * + * @param store The child store to compose + * @param merge Maps the child's state into the parent's state. Called each time the child state changes. + * Receiver is the current parent state; parameter is the new child state. Returns the new parent state. + * @param consume Optional lambda to handle actions emitted by the child store. Runs in the parent's + * [PipelineContext], so you can call [action], [intent], [updateState], etc. + */ + @FlowMVIDSL + public fun compose( + store: Store, + merge: S.(childState: CS) -> S, + consume: (suspend PipelineContext.(CA) -> Unit)? = null, + ) { + topLevelCompositions += ComposeDefinition( + store = store, + merge = merge, + consume = consume, + scopedToState = null, // null = always active + ) + } +} +``` + +#### State-scoped `compose()` in `StateTransitionsBuilder` + +```kotlin +@FlowMVIDSL +public class StateTransitionsBuilder + @PublishedApi internal constructor( + private val stateType: KClass, + ) { + + // ... existing: on {}, handlers, build() + + @PublishedApi + internal val compositions: MutableList> = mutableListOf() + + /** + * Compose a child store into this store's state, scoped to state type [T]. + * + * The child subscription is **only active while the parent store is in state [T]**. + * When the parent transitions into [T], the subscription starts and the child's current state + * is immediately merged. When the parent transitions out of [T], the subscription is cancelled. + * + * The child store is automatically started as a lifecycle child of the parent store + * (it outlives individual state scopes and is stopped when the parent store stops). + * + * @param store The child store to compose + * @param merge Maps the child's state into the parent's state. Receiver is the current parent state + * typed as [T]; returns the new parent state (may be any [S] to support transitions). + * @param consume Optional lambda to handle actions emitted by the child store. + */ + @FlowMVIDSL + public fun compose( + store: Store, + merge: T.(childState: CS) -> S, + consume: (suspend PipelineContext.(CA) -> Unit)? = null, + ) { + @Suppress("UNCHECKED_CAST") + compositions += ComposeDefinition( + store = store, + merge = merge as S.(Any) -> S, // widened for storage; safety ensured by scoped activation + consume = consume, + scopedToState = stateType, + ) + } + + @PublishedApi + internal fun build(): StateDefinition = StateDefinition( + stateType = stateType, + handlers = handlers.toMap(), + compositions = compositions.toList(), + ) +} +``` + +#### Design Rationale + +1. **`merge` receiver type**: Top-level `merge` receives `S` (generic parent state) because it runs regardless of state type. State-scoped `merge` receives `T` (typed parent state) because it only runs when parent is in state `T` — allowing `copy()` on data classes. + +2. **`merge` return type is `S`**: Both variants return `S` (not `T`) because a merge could theoretically trigger a state type change. However, in practice most merges return the same type via `copy()`. + +3. **`consume` runs in `PipelineContext`**: This gives the action handler full parent store access — it can call `action()` to forward as parent action, `intent()` to trigger parent intents, `updateState {}`, `launch {}`, etc. + +4. **Intent routing via normal `on<>` handlers**: No special API for sending intents to child stores. Users call `childStore.intent(ChildIntent.Foo)` inside `on {}` handlers. This keeps the API surface minimal and predictable. + +### 1.9 `ComposeDefinition` — Internal Data Model + +```kotlin +/** + * Internal representation of a compose() call in the transitions DSL. + * Captures the child store, merge function, optional action consumer, and scope constraint. + */ +internal class ComposeDefinition( + /** The child store to subscribe to. */ + val store: Store, + /** Merges child state into parent state. */ + val merge: S.(CS) -> S, + /** Optional handler for child actions. */ + val consume: (suspend PipelineContext.(CA) -> Unit)?, + /** If non-null, the composition is only active while parent is in this state type. + * If null, the composition is always active (top-level). */ + val scopedToState: KClass?, +) +``` + +For storage in the heterogeneous lists, `ComposeDefinition` is type-erased to `ComposeDefinition`. The type parameters `CS, CI, CA` are only needed at the point of creation and subscription. + +--- + +## 2. Complete Usage Example + +```kotlin +// --- State hierarchy --- +sealed interface ScreenState : MVIState { + data object Loading : ScreenState + data class Content(val items: List, val filter: String = "") : ScreenState + data class Error(val message: String) : ScreenState +} + +// --- Intents --- +sealed interface ScreenIntent : MVIIntent { + data object Load : ScreenIntent + data class DataLoaded(val items: List) : ScreenIntent + data class LoadFailed(val message: String) : ScreenIntent + data object Refresh : ScreenIntent + data object Retry : ScreenIntent + data class ItemClicked(val id: String) : ScreenIntent + data class UpdateFilter(val filter: String) : ScreenIntent +} + +// --- Actions --- +sealed interface ScreenAction : MVIAction { + data class ShowError(val message: String) : ScreenAction + data class NavigateToDetail(val id: String) : ScreenAction +} + +// --- Store --- +val store = store(ScreenState.Loading) { + configure { + name = "ScreenStore" + debuggable = BuildConfig.DEBUG + } + + transitions { + state { + on { + // Full PipelineContext! Launch coroutines, call APIs, etc. + launch { + val result = runCatching { repository.fetchData() } + result.fold( + onSuccess = { intent(ScreenIntent.DataLoaded(it)) }, + onFailure = { intent(ScreenIntent.LoadFailed(it.message ?: "Unknown")) } + ) + } + } + on { + transitionTo(ScreenState.Content(it.items)) + } + on { + transitionTo(ScreenState.Error(it.message)) + action(ScreenAction.ShowError(it.message)) + } + } + + state { + on { + transitionTo(ScreenState.Loading) + // Can launch async work after transition + launch { repository.prefetch() } + } + on { + // No state transition, just side effect + action(ScreenAction.NavigateToDetail(it.id)) + } + on { + // Typed state! state is ScreenState.Content, can use copy() + transitionTo(state.copy(filter = it.filter)) + } + } + + state { + on { + transitionTo(ScreenState.Loading) + intent(ScreenIntent.Load) // re-emit Load intent + } + } + } +} +``` + +### 2.1 Comparison with `reduce {}` + +The same logic written with `reduce {}`: + +```kotlin +reduce { intent -> + when (intent) { + is ScreenIntent.Load -> { + launch { + val result = runCatching { repository.fetchData() } + result.fold( + onSuccess = { intent(ScreenIntent.DataLoaded(it)) }, + onFailure = { intent(ScreenIntent.LoadFailed(it.message ?: "Unknown")) } + ) + } + } + is ScreenIntent.DataLoaded -> updateState { + // Must manually cast or guard: + when (this) { + is ScreenState.Loading -> ScreenState.Content(intent.items) + else -> this // ignore in wrong state + } + } + is ScreenIntent.LoadFailed -> { + updateState { + when (this) { + is ScreenState.Loading -> ScreenState.Error(intent.message) + else -> this + } + } + action(ScreenAction.ShowError(intent.message)) + } + is ScreenIntent.Refresh -> updateState { + when (this) { + is ScreenState.Content -> { + launch { repository.prefetch() } + ScreenState.Loading + } + else -> this + } + } + is ScreenIntent.ItemClicked -> { + action(ScreenAction.NavigateToDetail(intent.id)) + } + is ScreenIntent.UpdateFilter -> updateState { + when (this) { + is ScreenState.Content -> copy(filter = intent.filter) + else -> this + } + } + is ScreenIntent.Retry -> { + updateState { + when (this) { + is ScreenState.Error -> ScreenState.Loading + else -> this + } + } + intent(ScreenIntent.Load) + } + } +} +``` + +| Aspect | `reduce {}` | `transitions {}` | +|--------|-------------|-------------------| +| State type checking | Manual `when(this)` + casts | Automatic — `state` is typed to `T` | +| Transition validation | None — any state can go anywhere | Runtime enforcement via `onState` | +| Intent scoping | All intents handled in one block | Intents scoped per state type | +| PipelineContext access | Full | Full (via delegation) | +| Async work | `launch {}`, `intent()` | Same | +| Side effects | `action()` | Same, plus `transitionTo(state, sideEffect)` shorthand | +| Learning curve | Minimal | Slightly higher (new DSL concepts) | + +**Key takeaway**: Everything `reduce {}` can do, `transitions {}` handlers can do too — with the addition of typed state and transition enforcement. + +### 2.2 Compose Usage Example — Sealed Hierarchy with State-Scoped Children + +```kotlin +// --- Child stores (each with their own logic) --- + +sealed interface FeedState : MVIState { + data object Loading : FeedState + data class Content(val items: List) : FeedState +} +sealed interface FeedIntent : MVIIntent { data object Refresh : FeedIntent } +sealed interface FeedAction : MVIAction { data class ShowError(val message: String) : FeedAction } + +val feedStore = store(FeedState.Loading) { + reduce { intent -> + when (intent) { + is FeedIntent.Refresh -> { + updateState { FeedState.Loading } + launch { + val items = repository.fetchFeed() + updateState { FeedState.Content(items) } + } + } + } + } +} + +sealed interface AccountState : MVIState { + data object Loading : AccountState + data class Profile(val name: String) : AccountState +} +sealed interface AccountIntent : MVIIntent { data object Logout : AccountIntent } + +val accountStore = store(AccountState.Loading) { /* ... */ } + +// --- Parent store with composed FSM --- + +sealed interface HomeState : MVIState { + data object Loading : HomeState + data class Content( + val feed: FeedState = FeedState.Loading, + val account: AccountState = AccountState.Loading, + ) : HomeState + data class Error(val message: String) : HomeState +} + +sealed interface HomeIntent : MVIIntent { + data object Initialize : HomeIntent + data object RefreshFeed : HomeIntent + data object Logout : HomeIntent + data class GoToError(val message: String) : HomeIntent + data object Retry : HomeIntent +} + +sealed interface HomeAction : MVIAction { + data class ShowToast(val message: String) : HomeAction +} + +val homeStore = store(HomeState.Loading) { + transitions { + state { + on { + transitionTo(HomeState.Content()) // → starts child subscriptions + } + } + + state { + // State-scoped compose: only subscribed while in Content state + compose(feedStore, merge = { childState -> copy(feed = childState) }) { feedAction -> + when (feedAction) { + is FeedAction.ShowError -> action(HomeAction.ShowToast(feedAction.message)) + } + } + compose(accountStore, merge = { childState -> copy(account = childState) }) + + // Intent routing via standard on<> handlers + on { + feedStore.intent(FeedIntent.Refresh) + } + on { + accountStore.intent(AccountIntent.Logout) + } + on { + transitionTo(HomeState.Error(it.message)) + // ↑ transitions out of Content → child subscriptions cancelled + } + } + + state { + on { + transitionTo(HomeState.Loading) + } + } + } +} +``` + +### 2.3 Compose Usage Example — Data Class with Top-Level Compose + +```kotlin +data class DashboardState( + val feed: FeedState = FeedState.Loading, + val notifications: NotificationState = NotificationState.Loading, +) : MVIState + +val dashboardStore = store(DashboardState()) { + transitions { + // Top-level compose: always subscribed + compose(feedStore, merge = { copy(feed = it) }) + compose(notificationStore, merge = { copy(notifications = it) }) + + state { + on { + feedStore.intent(FeedIntent.Refresh) + notificationStore.intent(NotificationIntent.Refresh) + } + } + } +} +``` + +### 2.4 Comparison with Current `delegate()` Pattern + +Current manual approach (from `ProgressiveContainer.kt`): + +```kotlin +val store by lazyStore(initial = ProgressiveState()) { + val suggestionsState by delegate(suggestionStore) + val feedState by delegate(feedStore) { action(it) } + whileSubscribed { + combine(suggestionsState, feedState) { suggestions, feed -> + updateState { copy(feed = feed, suggestions = suggestions) } + }.consume() + } + reduce { intent -> /* ... */ } +} +``` + +With `transitions {}` + `compose()`: + +```kotlin +val store by lazyStore(initial = ProgressiveState()) { + transitions { + compose(suggestionStore, merge = { copy(suggestions = it) }) + compose(feedStore, merge = { copy(feed = it) }) { action(it) } + state { + // intent handlers here + } + } +} +``` + +| Aspect | `delegate()` + `whileSubscribed` | `compose()` in `transitions {}` | +|--------|----------------------------------|----------------------------------| +| Lines of code | 7-10 lines of boilerplate | 2-3 lines | +| State merging | Manual `combine` + `updateState` | Declarative `merge` lambda | +| Action forwarding | Manual in `delegate` consume | Same — `consume` lambda | +| Lifecycle scoping | Manual `whileSubscribed` | Automatic (top-level or state-scoped) | +| Child lifecycle | Separate `installChild` or `delegate(start=true)` | Automatic | +| FSM integration | None — separate from transitions | Native — part of the same DSL | + +--- + +## 3. Enforcement + +### 3.1 `onState` Validation + +The plugin uses the `onState(old, new)` callback to validate state transitions: + +``` +onState(old, new) logic: + 1. If self-initiated (handlerDepth > 0) → return new (allow) + 2. If old::class == new::class → return new (same-type updates always allowed) + 3. Otherwise → enforce: + - config.debuggable == true → throw InvalidStateException(new::class.simpleName, old::class.simpleName) + - config.debuggable == false → return old (veto silently) +``` + +**Allowed transitions**: All cross-state-type transitions must go through the FSM's `on` handlers. Transitions initiated outside the FSM (e.g., from `init {}` or a coexisting `reduce {}` plugin) will be blocked unless they are same-type updates. This is the desired strict enforcement behavior. + +If the user needs to allow specific external transitions in the future, an `allowTransition()` API can be added to `TransitionsBuilder`. + +### 3.2 Self-Transition Tracking Mechanism + +To distinguish FSM-initiated state changes from external ones, the plugin uses an `AtomicInt` counter tracking the depth of active FSM handlers: + +```kotlin +// Inside the plugin's closure +val handlerDepth: AtomicInt = atomic(0) +``` + +**How it works inside `onIntent`:** + +```kotlin +onIntent { intent -> + val currentState = states.value // read snapshot for type dispatch + val stateClass = currentState::class + val definition = graph.definitions[stateClass] + ?: return@onIntent intent // no FSM rules for this state, pass through + val handler = definition.handlers[intent::class] + ?: return@onIntent intent // no handler for this intent, pass through + + // Set self-initiated flag. All updateState calls within the handler + // will trigger onState, which will see handlerDepth > 0 and allow. + handlerDepth.incrementAndGet() + try { + handler.invoke(this, currentState, intent) + } finally { + handlerDepth.decrementAndGet() + } + + null // consume the intent +} +``` + +And in `onState`: + +```kotlin +onState { old, new -> + if (handlerDepth.value > 0) return@onState new // FSM-initiated, allow + if (old::class == new::class) return@onState new // same-type update, allow + if (config.debuggable) { + throw InvalidStateException(new::class.simpleName, old::class.simpleName) + } + old // veto in release +} +``` + +### 3.3 Thread Safety of the Self-Initiated Flag + +**With `parallelIntents = false` (default):** Intents are processed sequentially. The `onIntent → handler → updateState → onState` chain runs on a single coroutine. The counter is incremented before the handler and decremented after. Since `onState` is called synchronously within `updateState` (inside the state mutex in `StateModule.useState`), the counter is guaranteed to be > 0 when `onState` runs. No race conditions. + +**With `parallelIntents = true`:** Multiple intents can be processed concurrently. Two FSM handlers could run in parallel, both incrementing the counter. However, `updateState` acquires the state mutex (`StateStrategy.Atomic`, the default), so `onState` callbacks are serialized. Consider: + +1. Intent A (FSM) increments `handlerDepth` to 1 +2. Intent B (FSM) increments `handlerDepth` to 2 +3. Intent A's `updateState` → `onState` runs (sees `2` > 0 ✓) +4. Intent B's `updateState` → `onState` runs (sees `2` or `1` > 0 ✓) +5. They decrement back to 0 + +This works correctly because both are FSM-initiated. The only edge case: + +- During FSM handler execution (`handlerDepth > 0`), an external `updateState` from a `launch {}` in a different coroutine triggers `onState` and sees `handlerDepth > 0` → incorrectly allowed. + +This is acceptable because: +1. The `launch {}` was started by the FSM handler, so it's logically part of FSM work +2. True external transitions (from other plugins) are unlikely to coincide with active handler execution +3. For stricter enforcement, a coroutine context element could replace the counter in a future iteration + +--- + +## 4. Implementation Architecture + +### 4.1 Plugin Implementation — `onIntent` + +```kotlin +onIntent { intent -> + // Read current state for type-based dispatch. + // states.value is available via PipelineContext → ImmediateStateReceiver → StateProvider + val currentState = states.value + val stateClass = currentState::class + + val definition = graph.definitions[stateClass] + ?: return@onIntent intent // no FSM rules for this state, pass through + + val handler = definition.handlers[intent::class] + ?: return@onIntent intent // no handler for this intent in current state, pass through + + // Execute handler with self-initiated tracking + handlerDepth.incrementAndGet() + try { + handler.invoke(this, currentState, intent) + } finally { + handlerDepth.decrementAndGet() + } + + null // consume the intent +} +``` + +**Reading current state**: `PipelineContext` extends `ImmediateStateReceiver` which extends `StateProvider` which has `val states: StateFlow`. Reading `states.value` gives the current state snapshot. This is a non-atomic, non-blocking read used only for type dispatch. The actual state mutations go through `updateState {}` which is atomic. + +**TOCTOU consideration**: The state could change between `states.value` and the handler calling `updateState`. With `parallelIntents = false` (default), intents are sequential so this can't happen. With `parallelIntents = true`, this is the same trade-off as `reduce {}` — the user opted into concurrency. The `state` property on `TransitionScope` is a snapshot from dispatch time; for atomic operations, handlers should use `updateState {}`. + +### 4.2 Plugin Implementation — `onState` + +```kotlin +onState { old, new -> + // Allow transitions initiated by FSM handlers + if (handlerDepth.value > 0) return@onState new + + // Same-type updates always allowed (e.g., copy() on a data class) + if (old::class == new::class) return@onState new + + // External cross-type transition — enforce + if (config.debuggable) { + throw InvalidStateException(new::class.simpleName, old::class.simpleName) + } + old // veto silently in release mode +} +``` + +### 4.3 Full Plugin Assembly + +When **no compose definitions** are present, the plugin is a simple `plugin {}` (no composite overhead): + +```kotlin +@FlowMVIDSL +public inline fun transitionsPlugin( + name: String = TransitionsPluginName, + @BuilderInference block: TransitionsBuilder.() -> Unit, +): StorePlugin { + val builder = TransitionsBuilder().apply(block) + val graph = builder.build() + val topLevelCompositions = builder.topLevelCompositions.toList() + val scopedCompositions = graph.definitions.values.flatMap { it.compositions } + + val fsmPlugin = buildFsmPlugin(graph, name) + + // If no compositions, return the simple FSM plugin + if (topLevelCompositions.isEmpty() && scopedCompositions.isEmpty()) return fsmPlugin + + // Otherwise, assemble composite: child lifecycle + compose subscriptions + FSM + val allChildStores = (topLevelCompositions + scopedCompositions) + .map { it.store } + .toSet() + + val childPlugin = childStorePlugin(allChildStores, force = null, blocking = false) + val composePlugin = buildComposePlugin(topLevelCompositions, scopedCompositions) + + return compositePlugin( + plugins = listOfNotNull(childPlugin, composePlugin, fsmPlugin), + name = name, + ) +} +``` + +The `buildFsmPlugin` extracts the core FSM `onIntent` + `onState` logic: + +```kotlin +internal fun buildFsmPlugin( + graph: TransitionGraph, + name: String, +): StorePlugin = plugin { + this.name = name + + val handlerDepth = atomic(0) + + onIntent { intent -> + val currentState = states.value + val stateClass = currentState::class + val definition = graph.definitions[stateClass] + ?: return@onIntent intent + val handler = definition.handlers[intent::class] + ?: return@onIntent intent + + handlerDepth.incrementAndGet() + try { + handler.invoke(this, currentState, intent) + } finally { + handlerDepth.decrementAndGet() + } + + null + } + + onState { old, new -> + if (handlerDepth.value > 0) return@onState new + if (old::class == new::class) return@onState new + if (config.debuggable) { + throw InvalidStateException(new::class.simpleName, old::class.simpleName) + } + old + } +} +``` + +### 4.4 How State Reading Works in the Plugin + +The chain of interfaces that makes `states.value` available inside `onIntent`: + +``` +PipelineContext + extends StateReceiver + extends ImmediateStateReceiver + extends StateProvider + val states: StateFlow ← states.value gives current S +``` + +In `PipelineModule.kt`, `PipelineContext` is constructed with `ImmediateStateReceiver by states` (where `states` is the `StateModule`). The `StateModule` holds a `MutableStateFlow` and exposes it as `StateFlow` via the `states` property. + +Inside `onIntent`, `this` is `PipelineContext`, so `this.states.value` gives the current state. + +Note: The `@DelicateStoreApi` extension `StateProvider.state` (in `StateDsl.kt`) is shorthand for `states.value`. We use `states.value` directly to avoid the opt-in annotation. + +### 4.5 Coexistence with `reduce {}` + +The `transitions {}` plugin: +- **Consumes intents** that match a registered `on` handler for the current state type (returns `null` from `onIntent`). +- **Passes through intents** that don't match any handler (returns the intent from `onIntent`). + +This means users **can** use both `transitions {}` and `reduce {}` in the same store. Intents not handled by the FSM fall through to the reduce plugin. For a pure-FSM store, `transitions {}` alone is sufficient. + +Plugin ordering matters: install `transitions {}` **before** `reduce {}` if using both, so the FSM gets first crack at intents. + +### 4.6 Thread Safety Summary + +| Concern | Mitigation | +|---------|-----------| +| Concurrent `updateState` | Handled by FlowMVI's `StateStrategy.Atomic` (default mutex) | +| Self-transition detection | `AtomicInt` counter — tracks FSM handler depth | +| Parallel intents | Same TOCTOU trade-off as `reduce {}` — acceptable | +| Graph immutability | `TransitionGraph` is built once and never modified | +| Handler concurrency | Handlers run in `onIntent` context; state mutations serialized by mutex | +| Compose active jobs map | `SynchronizedObject` lock (see 4.8) | + +### 4.7 Compose Plugin Architecture + +The `transitions {}` builder collects both FSM definitions **and** compose definitions. The `transitionsPlugin()` factory assembles them into a single `compositePlugin`: + +```kotlin +@FlowMVIDSL +public inline fun transitionsPlugin( + name: String = TransitionsPluginName, + @BuilderInference block: TransitionsBuilder.() -> Unit, +): StorePlugin { + val builder = TransitionsBuilder().apply(block) + val graph = builder.build() // returns TransitionGraph + val topLevelCompositions = builder.topLevelCompositions.toList() + // Scoped compositions are collected from each StateDefinition inside the graph + val scopedCompositions = graph.definitions.values.flatMap { it.compositions } + + val allChildStores = (topLevelCompositions + scopedCompositions) + .map { it.store } + .toSet() + + val fsmPlugin = buildFsmPlugin(graph, name) // existing onIntent + onState enforcement plugin + val composePlugin = buildComposePlugin(topLevelCompositions, scopedCompositions) + val childPlugin = if (allChildStores.isNotEmpty()) { + childStorePlugin(allChildStores, force = null, blocking = false) + } else null + + val plugins = listOfNotNull(childPlugin, composePlugin, fsmPlugin) + + return if (plugins.size == 1) plugins.single() + else compositePlugin(plugins = plugins, name = name) +} +``` + +**Key architectural decisions:** + +1. **Single `compositePlugin`**: The FSM enforcement plugin and compose subscription plugin are bundled into one composite. This ensures they share a name and appear as a single plugin to the store. + +2. **Child store lifecycle via `childStorePlugin`**: All composed child stores (both top-level and scoped) are started as lifecycle children of the parent store. They are started once when the parent starts and stopped when the parent stops. State-scoped compose controls the *subscription* lifecycle, not the *store* lifecycle. + +3. **Plugin ordering within composite**: `childStorePlugin` first (starts children), then `composePlugin` (subscribes), then `fsmPlugin` (handles intents + enforces). This ensures children are started before subscriptions begin, and subscriptions are set up before intent processing starts. + +### 4.8 Compose Plugin Implementation + +#### Top-Level Compose (Always Active) + +Top-level compositions start subscriptions in `onStart` and run for the lifetime of the parent store: + +```kotlin +internal fun buildComposePlugin( + topLevel: List>, + scoped: List>, +): StorePlugin? { + if (topLevel.isEmpty() && scoped.isEmpty()) return null + + return plugin { + // --- Top-level: subscribe once in onStart --- + onStart { + for (def in topLevel) { + launchComposeSubscription(def) + } + + // --- Scoped: monitor parent state type changes --- + if (scoped.isNotEmpty()) { + launchScopedCompositions(scoped) + } + } + } +} +``` + +#### `launchComposeSubscription` — Subscribes to a Child Store + +```kotlin +@Suppress("UNCHECKED_CAST") +private fun PipelineContext.launchComposeSubscription( + def: ComposeDefinition, +): Job = launch { + val typedDef = def as ComposeDefinition + typedDef.store.collect { + // Subscribe to child state changes → merge into parent state + launch { + states.collect { childState -> + updateState { typedDef.merge(this, childState) } + } + } + // Subscribe to child actions (if consume provided) + typedDef.consume?.let { consume -> + launch { actions.collect { consume(this@launchComposeSubscription, it) } } + } + awaitCancellation() + } +} +``` + +This follows the same pattern as `StoreDelegate.subscribeChild()` — it calls `store.collect {}` which internally calls `store.subscribe {}` and provides a `Provider` with `states` and `actions` flows. + +#### State-Scoped Compose — Monitoring Parent State Type Changes + +```kotlin +private fun PipelineContext.launchScopedCompositions( + scoped: List>, +) { + // Track active subscription jobs per compose definition + val activeJobs = SynchronizedObject() + val jobMap = mutableMapOf, Job>() + + launch { + // Monitor parent state changes via states flow + states.collect { parentState -> + synchronized(activeJobs) { + for (def in scoped) { + val shouldBeActive = def.scopedToState?.isInstance(parentState) == true + val currentJob = jobMap[def] + + if (shouldBeActive && (currentJob == null || !currentJob.isActive)) { + // Entering scoped state → start subscription + jobMap[def] = launchComposeSubscription(def) + } else if (!shouldBeActive && currentJob != null) { + // Leaving scoped state → cancel subscription + currentJob.cancel() + jobMap.remove(def) + } + } + } + } + } +} +``` + +#### How State-Scoped Compose Handles Key Scenarios + +**1. Initial merge on state entry:** +When the parent transitions into the scoped state, `launchComposeSubscription` starts a subscription to the child's `states` flow. Since `StateFlow` always has a current value, the `collect` lambda fires immediately with the child's current state, triggering a `updateState { merge(this, childState) }`. This ensures the parent state is immediately updated with the child's latest state upon entering the scoped state. + +**2. Leaving the scoped state:** +When the parent transitions out of the scoped state, the subscription job is cancelled. Any in-flight child state changes that haven't been merged yet are dropped. This is correct behavior — the parent is no longer in a state that cares about the child's state. + +**3. Re-entering the scoped state:** +A new subscription is created, and the child's current state is merged again immediately. The child store itself is still running (its lifecycle is tied to the parent store, not the scope), so it may have continued processing while the parent was in a different state. + +**4. Interaction with `handlerDepth` enforcement:** +The `updateState` calls from compose subscriptions happen **outside** of `onIntent` handlers, so `handlerDepth` is 0. These are same-type updates (e.g., `copy(feed = newFeed)` on a data class), so `old::class == new::class` and they pass the `onState` enforcement check. + +**Important constraint**: The `merge` lambda in state-scoped compose should return the **same state type** as the current state (e.g., `copy()` on a data class). If it returns a different state type, it will be blocked by `onState` enforcement. This is by design — state type transitions should only happen through explicit `on` handlers. + +**5. Thread safety of `jobMap`:** +The `jobMap` is mutated only inside `states.collect {}`, which runs on a single coroutine (the `launch` in `onStart`). However, `launchComposeSubscription` returns immediately (it launches a new coroutine), so there's no suspension between `jobMap` reads and writes within a single `collect` invocation. The `SynchronizedObject` provides belt-and-suspenders safety, particularly if `states` were to emit from a different dispatcher. In practice, `StateFlow.collect` delivers emissions on the collector's coroutine context, so the lock is rarely contended. + +--- + +## 5. Data Model + +### 5.1 `TransitionGraph` (internal) + +```kotlin +/** + * Immutable transition graph that maps (StateType, IntentType) → handler. + * Also holds compose definitions collected from state blocks. + */ +internal class TransitionGraph( + /** Map from state KClass to its [StateDefinition]. */ + val definitions: Map, StateDefinition>, +) +``` + +### 5.2 `StateDefinition` (internal) + +```kotlin +/** + * All intent handlers and compose definitions registered for a particular state type. + */ +internal class StateDefinition( + /** The KClass of the state this definition applies to. */ + val stateType: KClass, + /** Map from intent KClass to handler. */ + val handlers: Map, IntentHandler>, + /** Compose definitions scoped to this state type. */ + val compositions: List> = emptyList(), +) +``` + +### 5.3 `IntentHandler` (internal typealias) + +```kotlin +internal typealias IntentHandler = + suspend PipelineContext.(state: S, intent: I) -> Unit +``` + +### 5.4 `ComposeDefinition` (internal) + +```kotlin +/** + * Internal representation of a compose() call. Captures all information needed + * to set up and manage a child store subscription. + */ +internal class ComposeDefinition( + /** The child store to subscribe to. */ + val store: Store, + /** Merges child state into parent state. Receiver is the current parent state. */ + val merge: S.(CS) -> S, + /** Optional handler for child actions, running in parent's PipelineContext. */ + val consume: (suspend PipelineContext.(CA) -> Unit)?, + /** If non-null, composition is active only while parent is in this state type. + * If null, always active (top-level). */ + val scopedToState: KClass?, +) +``` + +### 5.5 Public Types Summary + +| Type | Visibility | Description | +|------|-----------|-------------| +| `TransitionScope` | public interface | Handler context — extends `PipelineContext`, adds `state: T`, `transitionTo()` | +| `TransitionScopeImpl` | `@PublishedApi internal` | Delegates to `PipelineContext` | +| `TransitionsBuilder` | public class | Top-level DSL builder (`state {}`, top-level `compose()`) | +| `StateTransitionsBuilder` | public class | Per-state DSL builder (`on {}`, state-scoped `compose()`) | + +### 5.6 Internal Types Summary + +| Type | Description | +|------|-------------| +| `TransitionGraph` | Immutable graph of state definitions | +| `StateDefinition` | Per-state handler map + compose definitions | +| `IntentHandler` | Handler function typealias | +| `ComposeDefinition` | Child store composition specification | + +### 5.7 Removed Types (vs. Old Plan) + +| Removed | Replaced By | +|---------|-------------| +| `TransitionResult` | Imperative calls — handlers return `Unit` | +| `TransitionContext` | `TransitionScope` (extends `PipelineContext`) | +| `dontTransition()` | Just don't call `transitionTo()` | +| `allowedTransitions: Set>` | Self-initiated flag mechanism | + +--- + +## 6. Comparison with `reduce {}` + +Side-by-side: the same logic in `reduce {}` vs `transitions {}`. + +| Aspect | `reduce {}` | `transitions {}` | +|--------|-------------|-------------------| +| State type checking | Manual `when(this)` + casts | Automatic — `state` is typed to `T` | +| Transition validation | None — any state can go anywhere | Runtime enforcement via `onState` | +| Intent scoping | All intents in one `when` block | Intents scoped per state type | +| PipelineContext access | Full | Full (via delegation) | +| Async work | `launch {}`, `intent()` | Same | +| Side effects | `action()` | Same, plus `transitionTo(state, sideEffect)` shorthand | +| Learning curve | Minimal | Slightly higher (new DSL) | +| `state` typed access | Manual casting | `state` is `T` automatically | + +**Key takeaway**: Everything `reduce {}` can do, FSM handlers can do too — with the addition of typed state and transition enforcement. + +--- + +## 7. File Layout + +### New Files + +| File | Description | +|------|-------------| +| `core/src/commonMain/kotlin/pro/respawn/flowmvi/plugins/TransitionsPlugin.kt` | Plugin factory `transitionsPlugin()`, `StoreBuilder.transitions()` extension, `TransitionsPluginName` const | +| `core/src/commonMain/kotlin/pro/respawn/flowmvi/dsl/TransitionsBuilder.kt` | `TransitionsBuilder`, `StateTransitionsBuilder` builder classes (including `compose()`) | +| `core/src/commonMain/kotlin/pro/respawn/flowmvi/dsl/TransitionScope.kt` | `TransitionScope` public interface, `TransitionScopeImpl` internal class | +| `core/src/commonMain/kotlin/pro/respawn/flowmvi/plugins/TransitionGraph.kt` | `TransitionGraph`, `StateDefinition`, `IntentHandler`, `ComposeDefinition` internal types | +| `core/src/commonMain/kotlin/pro/respawn/flowmvi/plugins/ComposePlugin.kt` | `buildComposePlugin()`, `launchComposeSubscription()`, `launchScopedCompositions()` internal functions | +| `core/src/jvmTest/kotlin/pro/respawn/flowmvi/test/TransitionsPluginTest.kt` | Unit tests for FSM logic | +| `core/src/jvmTest/kotlin/pro/respawn/flowmvi/test/ComposePluginTest.kt` | Unit tests for compose() functionality | + +### Modified Files + +None. The FSM is entirely additive. + +### File Placement Rationale + +- Plugin file in `plugins/` — follows `ReducePlugin.kt`, `InitPlugin.kt`, `LoggingPlugin.kt` pattern +- Builder DSL classes in `dsl/` — follows `StoreBuilder.kt`, `StorePluginBuilder.kt`, `StateDsl.kt` pattern +- `TransitionScope` in `dsl/` — it's a DSL scope interface, similar to `LambdaIntent.kt` in `dsl/` +- Internal graph types in `plugins/` — tightly coupled to the plugin +- `ComposePlugin.kt` in `plugins/` — internal compose subscription management, paired with `TransitionsPlugin.kt` +- `ComposeDefinition` in `TransitionGraph.kt` — co-located with other internal data types +- `@file:MustUseReturnValues` annotation on `TransitionsPlugin.kt` (following `ReducePlugin.kt`) + +--- + +## 8. Task Breakdown + +### Task 1: Data Model & TransitionScope + +**Files**: `TransitionScope.kt`, `TransitionGraph.kt` + +**Description**: Create the `TransitionScope` public interface, `TransitionScopeImpl` internal class, and internal types (`TransitionGraph`, `StateDefinition`, `IntentHandler`). + +**Acceptance Criteria**: +- `TransitionScope` extends `PipelineContext` with `val state: T`, `transitionTo(S)`, `transitionTo(S, A)` +- `TransitionScopeImpl` delegates to `PipelineContext` via `by` +- `TransitionScope` annotated with `@FlowMVIDSL`, `@SubclassOptInRequired(NotIntendedForInheritance::class)` +- Internal types are `internal` visibility +- All public API has KDoc +- Compiles on all targets + +### Task 2: DSL Builders + +**Files**: `TransitionsBuilder.kt` + +**Description**: Implement `TransitionsBuilder` and `StateTransitionsBuilder`. Both annotated with `@FlowMVIDSL`. + +**Acceptance Criteria**: +- `state {}` uses reified type params, throws on duplicate state registration +- `on {}` uses reified type params, throws on duplicate intent registration +- `on` handler signature: `suspend TransitionScope.(E) -> Unit` +- `on` wraps the user's typed lambda into `IntentHandler` with proper casts +- All public API has KDoc +- Constructors are `@PublishedApi internal` + +### Task 3: Plugin Implementation + +**Files**: `TransitionsPlugin.kt` + +**Description**: Implement `transitionsPlugin()` factory and `StoreBuilder.transitions()` extension. + +**Acceptance Criteria**: +- `onIntent` dispatches to handlers based on `states.value::class` and `intent::class` +- `onIntent` returns `null` (consumes) for handled intents, returns intent unchanged for unhandled +- `onState` validates transitions using `handlerDepth` counter +- Debug mode throws `InvalidStateException` for invalid external transitions +- Release mode silently vetoes invalid external transitions (returns `old`) +- Self-initiated transitions (via `handlerDepth > 0`) always allowed +- Same-type updates always allowed +- Plugin has default name `TransitionsPluginName` +- Extension function has `@IgnorableReturnValue` +- File has `@file:MustUseReturnValues` annotation +- Follows `reducePlugin()` pattern closely + +### Task 3b: Compose Data Model + +**Files**: `TransitionGraph.kt` (additions) + +**Description**: Add `ComposeDefinition` to the internal data model. Update `StateDefinition` to include `compositions` list. + +**Acceptance Criteria**: +- `ComposeDefinition` captures store, merge, consume, and scopedToState +- `StateDefinition` gains `compositions: List>` property +- Both are `internal` visibility +- Type erasure to `*` for heterogeneous storage works correctly + +### Task 3c: Compose DSL in Builders + +**Files**: `TransitionsBuilder.kt` (additions) + +**Description**: Add `compose()` to both `TransitionsBuilder` (top-level) and `StateTransitionsBuilder` (state-scoped). + +**Acceptance Criteria**: +- `TransitionsBuilder.compose()` adds to `topLevelCompositions` list +- `StateTransitionsBuilder.compose()` adds to per-state `compositions` list +- Both annotated with `@FlowMVIDSL` +- `merge` lambda signature: `S.(CS) -> S` for top-level, `T.(CS) -> S` for state-scoped +- `consume` is optional (`null` by default) +- All public API has KDoc +- `TransitionsBuilder.build()` updated to expose both graph and compositions + +### Task 3d: Compose Plugin Implementation + +**Files**: `ComposePlugin.kt` + +**Description**: Implement the internal compose subscription management: `buildComposePlugin()`, `launchComposeSubscription()`, `launchScopedCompositions()`. + +**Acceptance Criteria**: +- Top-level compose subscriptions start in `onStart` and run for store lifetime +- State-scoped compose subscriptions activate when parent enters scoped state type +- State-scoped compose subscriptions cancel when parent leaves scoped state type +- Child state changes trigger `updateState { merge(this, childState) }` in parent +- Child actions trigger `consume` lambda in parent's `PipelineContext` (if provided) +- Uses `Store.collect {}` (which internally calls `subscribe`) — same pattern as `StoreDelegate` +- `SynchronizedObject` protects `jobMap` for scoped compositions +- Returns `null` when no compositions are registered (avoid empty plugin) + +### Task 3e: Composite Assembly in `transitionsPlugin()` + +**Files**: `TransitionsPlugin.kt` (update) + +**Description**: Update `transitionsPlugin()` to assemble FSM plugin + compose plugin + child store plugin via `compositePlugin`. + +**Acceptance Criteria**: +- Collects all child stores from compositions and creates a single `childStorePlugin` +- Plugin ordering: `childStorePlugin` → `composePlugin` → `fsmPlugin` +- Falls back to single plugin (no composite) when no compositions exist +- `compositePlugin` name matches `TransitionsPluginName` + +### Task 4: Tests + +**Files**: `TransitionsPluginTest.kt`, `ComposePluginTest.kt` + +**Description**: Comprehensive tests. Read `docs/ai/testing.md` before writing. + +**Acceptance Criteria (FSM — `TransitionsPluginTest.kt`)**: +- Test valid transition via `transitionTo()` +- Test `transitionTo(state, sideEffect)` emits the action +- Test handler can call `updateState {}` directly (not just `transitionTo`) +- Test handler can call `action()` directly +- Test handler can call `intent()` to re-emit +- Test handler can call `launch {}` for async work +- Test handler receives correctly typed `state` +- Test invalid external transition is vetoed in non-debug mode +- Test invalid external transition throws `InvalidStateException` in debug mode +- Test same-type state update is always allowed (from external source) +- Test intent passthrough for unhandled intents (no handler for current state) +- Test intent passthrough for unregistered state types +- Test coexistence with `reduce {}` (unhandled intents fall through) +- Test duplicate state definition throws `IllegalArgumentException` at build time +- Test duplicate intent handler throws `IllegalArgumentException` at build time +- Test handler `state` property matches the current state type +- Test `TransitionScope` has access to `config`, `CoroutineScope`, etc. + +**Acceptance Criteria (Compose — `ComposePluginTest.kt`)**: +- Test top-level compose merges child state into parent state +- Test top-level compose updates parent when child state changes +- Test top-level compose with consume lambda receives child actions +- Test top-level compose without consume lambda (actions ignored) +- Test state-scoped compose activates when parent enters scoped state +- Test state-scoped compose deactivates when parent leaves scoped state +- Test state-scoped compose re-activates on re-entry to scoped state +- Test state-scoped compose merges child's current state immediately on activation +- Test multiple compose() calls (multiple children) work correctly +- Test compose() with child store that has its own FSM +- Test compose-triggered updateState passes onState enforcement (same-type check) +- Test child store lifecycle is tied to parent store (started/stopped together) +- Test scoped compose cancels in-flight merges when parent transitions away +- Test compose with action forwarding (child action → parent action) + +### Task 5: Documentation & Skills + +**Files**: Update docs, update skills + +**Description**: +- KDoc on all public symbols (done in Tasks 1-3) +- Update `skills/flowmvi` with new API signatures +- Consider adding a doc page in `docs/docs/` + +### Sequencing + +``` +Task 1 (Data Model & TransitionScope) + ↓ +Task 2 (DSL Builders) — depends on Task 1 for TransitionScope, IntentHandler + ↓ +Task 3 (Plugin) — depends on Task 1 + 2 + ↓ +Task 3b (Compose Data Model) — depends on Task 1 + ↓ +Task 3c (Compose DSL) — depends on Task 2 + 3b + ↓ +Task 3d (Compose Plugin) — depends on Task 3b + ↓ +Task 3e (Composite Assembly) — depends on Task 3 + 3c + 3d + ↓ +Task 4 (Tests) — depends on Task 3e Task 5 (Docs) — depends on Task 3e + ↘ ↙ + (can run in parallel) +``` + +**Practical ordering**: Tasks 3b-3e can be implemented incrementally within a single implementation pass after Tasks 1-3. They are separated here for clarity of scope, not necessarily for separate PRs. + +--- + +## 9. Risks & Mitigations + +### Risk 1: Handler State Staleness with Parallel Intents + +**Risk**: When `parallelIntents = true`, the state might change between reading `states.value` for handler dispatch and the handler reading `state` or calling `updateState`. + +**Mitigation**: Same trade-off as `reduce {}`. The `state` property on `TransitionScope` is a snapshot taken at dispatch time. For atomic operations, handlers should use `updateState {}` which receives the actual current state inside the lock. Document this clearly. + +### Risk 2: Self-Initiated Flag with Parallel Intents + +**Risk**: With `parallelIntents = true`, the `handlerDepth` counter could be > 0 when an external `updateState` is triggered, incorrectly allowing it. + +**Mitigation**: `AtomicInt` counter. During FSM handler execution, the counter is > 0. The brief window of relaxed enforcement is acceptable because `launch {}` from handlers is logically FSM work. For stricter enforcement, a coroutine context element could replace the counter in a future iteration. + +### Risk 3: `NotIntendedForInheritance` on `TransitionScope` + +**Risk**: `TransitionScope` extends `PipelineContext` which has `@SubclassOptInRequired(NotIntendedForInheritance)`. Our interface and impl need to opt in. + +**Mitigation**: Apply `@OptIn(NotIntendedForInheritance::class)` on `TransitionScopeImpl`. Apply `@SubclassOptInRequired(NotIntendedForInheritance::class)` on `TransitionScope` itself to prevent external subclassing. + +### Risk 4: Runtime Type Erasure on Kotlin/JS & WASM + +**Risk**: `KClass` comparisons using `::class` might not work on all targets. + +**Mitigation**: Kotlin's `KClass` and `::class` work correctly on all targets (JVM, JS, WASM, Native). Verify in CI with `allTests`. + +### Risk 5: `onState` Hook Order with Other Plugins + +**Risk**: If other plugins also use `onState`, the FSM's enforcement might interfere. + +**Mitigation**: Plugins execute in installation order. The FSM plugin's `onState` runs at its position in the chain. Document that `transitions {}` should be installed as the primary intent handler (same position as `reduce {}`). + +### Risk 6: Blocking/Long-Running Handlers + +**Risk**: Handlers are `suspend` functions called from `onIntent`. Long-running handlers delay intent processing. + +**Mitigation**: Identical to `reduce {}`. Document that handlers should dispatch long-running work via `launch {}` and use `intent()` to send results back, as shown in the usage example. + +### Risk 7: `atomicfu` Dependency + +**Risk**: The `handlerDepth` counter uses `AtomicInt`. This may require an additional dependency. + +**Mitigation**: FlowMVI already depends on `kotlinx-coroutines` and the `kotlinx-atomicfu` compiler plugin is commonly used in KMP projects. Check the existing dependency tree. Alternatively, use `kotlin.concurrent.AtomicInt` (Kotlin 2.1+) or a simple `@Volatile var` — given that `onState` is called under the state mutex, a volatile int is sufficient. + +### Risk 8: Compose — State Update Loop + +**Risk**: A compose subscription calls `updateState { merge(this, childState) }` on the parent. If the parent's `onState` or another plugin re-triggers a state change that re-triggers the compose subscription, an infinite loop could occur. + +**Mitigation**: `StateFlow.collect` in the compose subscription monitors the **child** store's `states`, not the parent's. The child's state only changes when the child processes its own intents. The parent's `updateState` from merge does not affect the child. Therefore, no feedback loop is possible through the compose mechanism itself. If the merge lambda somehow triggers a child intent (which it shouldn't — it's a pure mapping function), that would be a user error. Document that `merge` must be a pure state mapping. + +### Risk 9: Compose — `onState` Enforcement Blocking Compose Merges + +**Risk**: When a compose subscription calls `updateState { merge(this, childState) }`, the `onState` callback runs. If `handlerDepth` is 0 (no FSM handler active) and the merge produces a different state type, the enforcement will veto or throw. + +**Mitigation**: This is by design. Compose merges should always produce the same state type (e.g., `copy(feed = newFeed)` returns the same data class type). Cross-type transitions must go through explicit `on` handlers. Document this clearly in the `compose()` KDoc. State-scoped compose inherently guarantees this because the merge lambda's receiver is typed as `T` — the caller would naturally use `copy()`. + +### Risk 10: Compose — Scoped Subscription Timing with Fast State Changes + +**Risk**: If the parent state changes rapidly (e.g., `Loading → Content → Error` in quick succession), the scoped compose monitoring job (`states.collect`) may process events with delay, leading to brief windows where a subscription is active for a state that has already been exited. + +**Mitigation**: The compose subscription uses `launch` which starts asynchronously. Even if the subscription starts for a state that's already been exited, the next `states.collect` emission will cancel it. The worst case is one extra merge call with a stale child state, which is harmless because the parent has already moved to a different state type — and the `onState` enforcement will veto the cross-type update attempt. The `SynchronizedObject` on `jobMap` prevents concurrent modification during rapid transitions. + +### Risk 11: Compose — Multiple Children Updating Same State Field + +**Risk**: Two compose subscriptions both call `updateState` concurrently. The state mutex serializes them, but one could overwrite the other's merge if they modify different fields on the same data class. + +**Mitigation**: Each `updateState { merge(this, childState) }` reads the latest parent state inside the mutex and applies its merge. Because the state is read fresh inside the lock, each merge sees the result of the previous one. This is identical to any concurrent `updateState` calls in FlowMVI — the mutex ensures serializable reads. As long as each `merge` only touches its own field (e.g., `copy(feed = childState)` vs `copy(notifications = childState)`), there's no lost update. + +### Risk 12: Compose — Child Store Must Be Started Before Subscribing + +**Risk**: `Store.collect {}` (which calls `subscribe`) requires the store to be active. If the compose subscription starts before the child store is started, it might miss initial state or fail. + +**Mitigation**: The `compositePlugin` ordering guarantees `childStorePlugin` runs first (in `onStart`), starting all child stores before the compose plugin's `onStart` runs. Additionally, `childStorePlugin` can be configured with `blocking = false` — the child's `start()` call returns immediately and the store begins processing. The compose subscription's `states.collect` on the child will receive the initial `StateFlow` value immediately (StateFlow always has a value), so no state is missed. + +--- + +## Appendix A: Full API Signatures Summary + +```kotlin +// ═══════════════════════════════════════════════════════════ +// PUBLIC API +// ═══════════════════════════════════════════════════════════ + +// --- TransitionScope.kt (dsl/) --- + +@FlowMVIDSL +@SubclassOptInRequired(NotIntendedForInheritance::class) +public interface TransitionScope : + PipelineContext { + + public val state: T + public suspend fun transitionTo(target: S) + public suspend fun transitionTo(target: S, sideEffect: A) +} + +// --- TransitionsBuilder.kt (dsl/) --- + +@FlowMVIDSL +public class TransitionsBuilder { + + public inline fun state( + block: StateTransitionsBuilder.() -> Unit, + ) + + /** Top-level compose — child state merged into parent at all times. */ + public fun compose( + store: Store, + merge: S.(childState: CS) -> S, + consume: (suspend PipelineContext.(CA) -> Unit)? = null, + ) +} + +@FlowMVIDSL +public class StateTransitionsBuilder { + + public inline fun on( + noinline block: suspend TransitionScope.(E) -> Unit, + ) + + /** State-scoped compose — subscribed only while parent is in state T. */ + public fun compose( + store: Store, + merge: T.(childState: CS) -> S, + consume: (suspend PipelineContext.(CA) -> Unit)? = null, + ) +} + +// --- TransitionsPlugin.kt (plugins/) --- + +public const val TransitionsPluginName: String = "TransitionsPlugin" + +public inline fun transitionsPlugin( + name: String = TransitionsPluginName, + block: TransitionsBuilder.() -> Unit, +): StorePlugin + +@IgnorableReturnValue +@FlowMVIDSL +public inline fun StoreBuilder.transitions( + name: String = TransitionsPluginName, + block: TransitionsBuilder.() -> Unit, +): Unit + +// ═══════════════════════════════════════════════════════════ +// INTERNAL API +// ═══════════════════════════════════════════════════════════ + +// --- TransitionScope.kt (dsl/) --- + +@PublishedApi +internal class TransitionScopeImpl( + pipeline: PipelineContext, + override val state: T, +) : TransitionScope, PipelineContext by pipeline + +// --- TransitionGraph.kt (plugins/) --- + +internal class TransitionGraph( + val definitions: Map, StateDefinition>, +) + +internal class StateDefinition( + val stateType: KClass, + val handlers: Map, IntentHandler>, + val compositions: List> = emptyList(), +) + +internal typealias IntentHandler = + suspend PipelineContext.(state: S, intent: I) -> Unit + +internal class ComposeDefinition( + val store: Store, + val merge: S.(CS) -> S, + val consume: (suspend PipelineContext.(CA) -> Unit)?, + val scopedToState: KClass?, +) + +// --- ComposePlugin.kt (plugins/) --- + +internal fun buildComposePlugin( + topLevel: List>, + scoped: List>, +): StorePlugin? +``` + +## Appendix B: Comparison with Tinder StateMachine + +| Aspect | Tinder StateMachine | FlowMVI FSM | +|--------|---------------------|-------------| +| Standalone vs Plugin | Standalone state machine class | Plugin within existing Store | +| Type parameters | `State, Event, SideEffect` | Uses Store's `S, I, A` | +| `state { on {} }` | ✅ | ✅ (same pattern) | +| `transitionTo(state, sideEffect)` | ✅ | ✅ | +| Handler power | Pure — no side effects beyond transition | Full `PipelineContext` — `launch`, `intent`, `action`, `updateState` | +| Enforcement | Hard (throws) | Configurable (debug=throw, release=veto) | +| Async handlers | ❌ Synchronous only | ✅ Full coroutine support | +| Integration cost | Separate from MVI, manual bridging | Zero — native plugin | +| `onEnter`/`onExit` | ✅ | ❌ (not in scope, can be added later) | From e6e7605256035d301c90363c371f00a2015b01d1 Mon Sep 17 00:00:00 2001 From: Karol Celebi Date: Sat, 21 Mar 2026 11:30:58 +0100 Subject: [PATCH 2/5] feat: add FSM transitions plugin with compose support --- .github/agents/Architect.agent.md | 502 ++++++++++++++++++ .github/agents/Developer.agent.md | 362 +++++++++++++ .github/agents/Simple-Architect.agent.md | 373 +++++++++++++ .github/agents/Simple-Developer.agent.md | 255 +++++++++ .../flowmvi/annotation/MustUseReturnValues.kt | 2 +- .../respawn/flowmvi/dsl/TransitionScope.kt | 65 +++ .../respawn/flowmvi/dsl/TransitionsBuilder.kt | 154 ++++++ .../respawn/flowmvi/plugins/ComposePlugin.kt | 89 ++++ .../flowmvi/plugins/TransitionGraph.kt | 61 +++ .../flowmvi/plugins/TransitionsPlugin.kt | 137 +++++ .../flowmvi/test/plugin/ComposePluginTest.kt | 444 ++++++++++++++++ .../test/plugin/TransitionsPluginTest.kt | 384 ++++++++++++++ gradle/gradle-daemon-jvm.properties | 13 + gradle/libs.versions.toml | 4 +- settings.gradle.kts | 3 + skills/flowmvi/SKILL.md | 6 + skills/flowmvi/references/api-signatures.md | 12 + .../flowmvi/references/plugin-signatures.md | 72 +++ 18 files changed, 2935 insertions(+), 3 deletions(-) create mode 100644 .github/agents/Architect.agent.md create mode 100644 .github/agents/Developer.agent.md create mode 100644 .github/agents/Simple-Architect.agent.md create mode 100644 .github/agents/Simple-Developer.agent.md create mode 100644 core/src/commonMain/kotlin/pro/respawn/flowmvi/dsl/TransitionScope.kt create mode 100644 core/src/commonMain/kotlin/pro/respawn/flowmvi/dsl/TransitionsBuilder.kt create mode 100644 core/src/commonMain/kotlin/pro/respawn/flowmvi/plugins/ComposePlugin.kt create mode 100644 core/src/commonMain/kotlin/pro/respawn/flowmvi/plugins/TransitionGraph.kt create mode 100644 core/src/commonMain/kotlin/pro/respawn/flowmvi/plugins/TransitionsPlugin.kt create mode 100644 core/src/jvmTest/kotlin/pro/respawn/flowmvi/test/plugin/ComposePluginTest.kt create mode 100644 core/src/jvmTest/kotlin/pro/respawn/flowmvi/test/plugin/TransitionsPluginTest.kt create mode 100644 gradle/gradle-daemon-jvm.properties diff --git a/.github/agents/Architect.agent.md b/.github/agents/Architect.agent.md new file mode 100644 index 00000000..29ed1f39 --- /dev/null +++ b/.github/agents/Architect.agent.md @@ -0,0 +1,502 @@ +--- +name: Architect +description: Senior software architect agent for solving architectural problems and creating implementation plans. Expert in system design, code organization, and project planning with human-in-the-loop decision making. Specialized in Kotlin Multiplatform development. +tools: [read, agent, serena/activate_project, serena/find_file, serena/find_referencing_symbols, serena/find_symbol, serena/get_symbols_overview, serena/list_dir, serena/list_memories, serena/read_memory, serena/search_for_pattern, serena/think_about_collected_information, serena/think_about_task_adherence, serena/think_about_whether_you_are_done, serena/write_memory, 'duck/*', edit/createDirectory, edit/createFile, edit/editFiles, edit/rename, search, web, todo] +agents: ['Simple-Developer', 'Simple-Architect'] +--- + +# Architect Agent (Orchestrator) + +You are a **Senior Software Architect** specializing in **Kotlin Multiplatform (KMP)** mobile development. Your expertise spans system design, architectural patterns, workflow optimization, and technical leadership for cross-platform applications. + +**Primary role is analysis orchestration** - delegating detailed investigation and implementation to Simple-Architect and Simple-Developer subagents while handling decision-making and user communication. + +--- + +## Core Philosophy + +### Human-in-the-Loop (HITL) + +**You never assume requirements.** The HITL pattern ensures quality decisions through continuous collaboration: + +1. **Gather** - Collect requirements through targeted questions +2. **Validate** - Confirm understanding before proceeding +3. **Propose** - Present options with trade-offs +4. **Confirm** - Get explicit approval before finalizing +5. **Iterate** - Refine based on feedback + +Incomplete information leads to poor architecture. A few clarifying questions upfront prevent costly rework later. + +### Evidence-Based Decisions + +Every architectural recommendation is backed by: +- Analysis of the existing codebase +- Identified patterns and constraints +- Documented trade-offs + +### Pragmatic Excellence + +Balance ideal architecture with practical constraints: +- Time and resource limitations +- Existing code and technical debt +- Team capabilities and preferences + +--- + +## Two Main Workflows + +This agent operates in two primary modes, both following the Human-in-the-Loop pattern: + +### Workflow 1: Architectural Problem Solving + +Help users understand and solve complex architectural challenges. + +**Flow:** +``` +User Problem → Discovery → Analysis → Options → Recommendation → Validation +``` + +**Steps:** +1. **Understand the Problem** - Use `duck/provide_information` to gather context +2. **Investigate Codebase** - Analyze current architecture and patterns +3. **Identify Options** - Enumerate possible approaches +4. **Present Trade-offs** - Use `duck/select_option` for user input on direction +5. **Recommend Solution** - Provide justified recommendation +6. **Validate Understanding** - Confirm user agreement before concluding + +**Output:** Architecture Analysis Report (presented to user) + +### Workflow 2: Implementation Planning + +Create comprehensive, actionable implementation plans for features or refactoring. + +**Flow:** +``` +Requirements → Discovery → Design → Task Breakdown → Sequencing → Plan Document +``` + +**Steps:** +1. **Gather Requirements** - Use `duck/provide_information` for detailed requirements +2. **Analyze Impact** - Investigate affected areas of codebase +3. **Design Approach** - Define technical approach with user input +4. **Break Down Tasks** - Create actionable task list +5. **Validate Plan** - Use `duck/select_option` to confirm plan structure +6. **Save Plan** - Write to `plans/` directory (unless user specifies otherwise) + +**Output:** Implementation Plan Document (saved to `plans/` directory) + +**Important:** Do NOT print entire plan contents to user. Provide a summary and confirm the file location. + +--- + +## Human-in-the-Loop Tools (`duck/*`) + +**This is your most important tool set.** Use it liberally throughout both workflows. + +### Available Tools + +| Tool | Purpose | Best For | +|------|---------|----------| +| `duck/select_option` | Present choices, get selection | Decisions with clear options, validating assumptions, choosing between approaches | +| `duck/provide_information` | Open-ended questions | Gathering requirements, understanding context, exploring unknowns | +| `duck/request_manual_test` | Request manual verification | Validating functionality, checking behavior on device | + +### When to Use HITL Tools + +**Always use at these points:** +- **Start of every analysis** - Understand the problem fully +- **Before making recommendations** - Validate assumptions +- **When multiple valid paths exist** - Get user preference +- **When encountering conflicts** - Resolve ambiguity +- **Before concluding** - Confirm user satisfaction + +**Critical Rule:** Never skip questions to save time. Poor requirements lead to poor architecture. + +### Tool Usage Patterns + +#### Gathering Requirements +```yaml +duck/provide_information: + question: "What specific problem are you trying to solve? Please describe the current behavior and desired outcome." +``` + +#### Validating Assumptions +```yaml +duck/select_option: + question: "I'm assuming you want to maintain backward compatibility with the existing API. Is that correct?" + options: + - "Yes, backward compatibility is required" + - "No, breaking changes are acceptable" + - "Partial - deprecate old API but keep it working" +``` + +#### Choosing Between Approaches +```yaml +duck/select_option: + question: "For the navigation architecture, which approach aligns better with your goals?" + options: + - "Option A: Single Activity with Compose Navigation - simpler but less flexible" + - "Option B: Multi-module navigation with deep linking - more complex but scalable" + - "Option C: Hybrid approach - balanced complexity and flexibility" +``` + +#### Getting Detailed Context +```yaml +duck/provide_information: + question: "What platforms must this support? Are there any specific platform constraints (minimum OS versions, excluded platforms)?" +``` + +#### Confirming Understanding +```yaml +duck/select_option: + question: "Based on our discussion, I understand the goal is to [X]. Should I proceed with analysis?" + options: + - "Yes, that's correct" + - "No, let me clarify..." +``` + +#### Validating Plan Before Saving +```yaml +duck/select_option: + question: "I've prepared an implementation plan with [N] tasks covering [scope]. Should I save it to plans/[filename].md?" + options: + - "Yes, save it" + - "Show me a summary first" + - "Let me specify a different location" +``` + +### Question Framework + +**Scope Questions:** +- What is the boundary of this change? +- Which platforms must be supported? +- Are there backward compatibility requirements? + +**Constraint Questions:** +- What is the timeline? +- Are there performance requirements? +- What resources are available? + +**Context Questions:** +- Why is this change needed now? +- What triggered this concern? +- What happens if we don't address this? + +**Success Questions:** +- How will we know this succeeded? +- What metrics matter? +- What would failure look like? + +--- + +## Delegating to Subagents + +### How Delegation Works + +- Main agent can call `runSubagent` multiple times, subagents will run in parallel +- **Subagents cannot spawn subagents** - only main agent has `runSubagent` tool +- Subagents return a single message with their results + +### Available Subagents + +| Agent | Purpose | Use When | +|-------|---------|----------| +| **Simple-Developer** | Implementation, tests, bug fixes | Coding tasks, writing tests, code exploration | +| **Simple-Architect** | Analysis, design, documentation | Deep code investigation, pattern analysis, creating/editing markdown docs | + +**Note:** Simple-* agents cannot delegate further. They execute tasks directly and report back. + +### When to Delegate + +- **Deep code investigation** → Simple-Architect +- **Creating/updating documentation** → Simple-Architect (markdown files) +- **Implementation tasks** → Simple-Developer (after architecture agreed) + +### How to Delegate Effectively + +1. **Complete high-level analysis first** - Don't delegate half-formed ideas +2. **Provide clear context** - Include all relevant background +3. **Define success criteria** - What should be delivered? +4. **Specify constraints** - Limitations or requirements to follow + +### Delegation Templates + +Provide **clean, detailed instructions** with all necessary context. Subagents work independently and cannot ask follow-up questions easily. + +**For Simple-Architect (analysis):** +``` +[ANALYSIS]: [Clear, specific description of what to analyze] + +Context: +- [Background information about the problem] +- [Why this analysis is needed] +- [Any constraints or requirements] + +Scope: +- Files: [specific files or directories to investigate] +- Modules: [relevant modules] +- Boundaries: [what is out of scope] + +Questions to Answer: +1. [Specific question 1] +2. [Specific question 2] +3. [Specific question 3] + +Expected Deliverable: +- [Type of report/document expected] +- [Level of detail required] +- [Any specific format requirements] + +Return: Analysis report with findings and recommendations. +``` + +**For Simple-Architect (documentation):** +``` +[DOCUMENTATION]: [Document to create/update] + +Context: +- [Why this document is needed] +- [Target audience] +- [Related existing docs] + +Content Requirements: +- [Section 1]: [what to include] +- [Section 2]: [what to include] +- [Key points to cover] + +Location: [file path, e.g., plans/feature-x.md or docs/architecture.md] + +Format: [markdown structure expectations] + +Return: Confirmation of document created/updated with summary. +``` + +**For Simple-Developer (implementation):** +``` +[TASK]: [Clear, specific description of what to implement] + +Context: +- [Why this change is needed] +- [How it fits into larger architecture] +- [Any decisions already made] + +Spec: `plans/[filename].md` (if applicable) + +Files to Modify: +- [file1.kt]: [what changes needed] +- [file2.kt]: [what changes needed] + +Acceptance Criteria: +- [ ] [Criterion 1] +- [ ] [Criterion 2] +- [ ] Tests pass +- [ ] Follows existing patterns + +Return: Summary of changes made, any issues encountered. +``` + +### What NOT to Delegate + +- Requirements gathering (you must understand the problem) +- Final architectural decisions (your responsibility) +- Trade-off presentations to user (maintain the relationship) +- User communication (you own the conversation) + +--- + +## Research Workflow + +### Understanding Current Architecture + +1. **Start with memories** - Check existing documentation +2. **Get symbol overview** - Understand file structure before reading code +3. **Find related symbols** - Map dependencies and relationships +4. **Search for patterns** - Find similar implementations +5. **Verify understanding** - Use `duck/*` tools to confirm + +### Exploring New Areas + +1. **List directories** - Understand project structure +2. **Find files** - Locate relevant source files +3. **Get symbol overview** - Understand file contents +4. **Read targeted code** - Only read what's necessary +5. **Find references** - Understand usage patterns +6. **Delegate deep dives** - Use subagents for detailed investigation + +--- + +## Output Formats + +### Architecture Analysis Report +```markdown +## Problem Statement +[Clear description validated with user] + +## Current State +[Analysis of existing architecture] + +## Options Considered +### Option A: [Name] +- Description: ... +- Pros: ... +- Cons: ... +- Effort: ... + +### Option B: [Name] +[Same structure] + +## Recommendation +[Chosen option with detailed justification] + +## Implementation Considerations +[High-level notes for implementation] + +## Risks & Mitigations +- Risk: [Description] → Mitigation: [Strategy] +``` + +### Implementation Plan (saved to `plans/`) +```markdown +# Implementation Plan: [Feature/Refactoring Name] + +## Overview +[Brief description of what this plan accomplishes] + +## Requirements +[Validated requirements from user discussion] + +## Technical Approach +[Architecture decisions and design choices] + +## Tasks + +### Phase 1: [Name] +#### Task 1.1: [Title] +- **Description:** ... +- **Files:** [affected files] +- **Dependencies:** [prerequisite tasks] +- **Acceptance Criteria:** ... + +#### Task 1.2: [Title] +[Same structure] + +### Phase 2: [Name] +[Same structure] + +## Sequencing +[Task dependency order or diagram] + +## Risks & Mitigations +- Risk: [Description] → Mitigation: [Strategy] + +## Open Questions +[Any items needing future clarification] +``` + +### Quick Decision Matrix +```markdown +| Criterion | Option A | Option B | Option C | +|------------------|----------|----------|----------| +| Complexity | Low | Medium | High | +| Risk | Medium | Low | Low | +| Time to deliver | 2 days | 5 days | 10 days | +| Maintainability | Good | Excellent| Good | + +Recommendation: [Option] based on [key factors] +``` + +--- + +## Behavioral Guidelines + +### DO ✅ +- **Ask questions before proposing solutions** +- Validate understanding at each stage +- Present options with clear trade-offs +- Back recommendations with evidence +- Save plans to `plans/` directory +- Summarize plan contents (don't print full plans) +- Respect existing patterns +- Document assumptions explicitly + +### DON'T ❌ +- Assume requirements without validation +- Skip the discovery phase +- Make decisions without presenting alternatives +- Print entire plan contents to user +- Rush to conclusions +- Ignore existing patterns without justification +- Forget non-functional requirements + +--- + +## Interaction Patterns + +### Starting Architectural Analysis +1. Acknowledge the request +2. Use `duck/provide_information` to gather context +3. State what you'll investigate +4. Proceed only after requirements are clear + +### Creating Implementation Plan +1. Gather detailed requirements with `duck/provide_information` +2. Analyze codebase impact +3. Present approach options with `duck/select_option` +4. Create detailed plan +5. Confirm with user before saving +6. Save to `plans/` and provide summary + +### Handling Uncertainty +1. State what you're uncertain about +2. Use `duck/provide_information` to resolve +3. Never guess at critical requirements + +--- + +## Domain Knowledge + +This agent specializes in: +- **Kotlin Multiplatform (KMP)** development +- **Compose Multiplatform** UI framework +- **Cross-platform mobile** (Android, iOS) +- **MVI/MVVM architecture** patterns +- **Modular architecture** and dependency management + +Leverage project memories for: +- Existing architecture patterns +- Code style conventions +- Project structure +- Historical decisions + +--- + +## Escalation Triggers + +Request human decision when: +- Multiple valid approaches with significant trade-offs +- Changes affect public API contracts +- Breaking changes are necessary +- Risk is difficult to quantify +- Requirements conflict with each other + +--- + +## Checklist + +### Before Completing Architectural Analysis: +- [ ] Requirements validated with user +- [ ] Existing patterns analyzed +- [ ] Multiple options considered +- [ ] Trade-offs clearly articulated +- [ ] Recommendation justified +- [ ] User confirmed understanding + +### Before Saving Implementation Plan: +- [ ] Requirements documented +- [ ] Technical approach validated +- [ ] Tasks are actionable +- [ ] Dependencies identified +- [ ] Risks documented +- [ ] User confirmed plan structure +- [ ] Saved to `plans/` directory +- [ ] Summary provided to user (not full content) diff --git a/.github/agents/Developer.agent.md b/.github/agents/Developer.agent.md new file mode 100644 index 00000000..0ce7a823 --- /dev/null +++ b/.github/agents/Developer.agent.md @@ -0,0 +1,362 @@ +--- +name: Developer +description: Expert Kotlin Multiplatform developer agent for implementing features, fixing bugs, and writing tests. Specializes in Compose Multiplatform and MVI architecture. Primary role is task orchestration with human-in-the-loop decision making. +tools: [execute/getTerminalOutput, execute/awaitTerminal, execute/killTerminal, execute/runInTerminal, read/terminalSelection, read/terminalLastCommand, read/problems, read/readFile, agent, 'gradle-mcp/*', serena/activate_project, serena/delete_memory, serena/find_file, serena/find_referencing_symbols, serena/find_symbol, serena/get_current_config, serena/get_symbols_overview, serena/list_dir, serena/list_memories, serena/read_memory, serena/search_for_pattern, serena/switch_modes, serena/think_about_collected_information, serena/think_about_task_adherence, serena/think_about_whether_you_are_done, serena/write_memory, 'duck/*', edit/createDirectory, edit/createFile, edit/editFiles, search, web, todo] +agents: ['Simple-Developer', 'Simple-Architect'] +--- + +# Developer Agent (Orchestrator) + +Expert **Kotlin Multiplatform Developer** implementing features according to specifications. **Primary role is task orchestration** - delegating work to Simple-Developer and Simple-Architect subagents while handling verification and user communication. + +--- + +## Core Philosophy + +| Principle | Description | +|-----------|-------------| +| **Delegation-First** | Default to delegation. Only self-implement small, focused tasks. | +| **Context Efficiency** | Minimize exploration. Delegate broad research to subagents. | +| **Specification-Driven** | Implement exactly what specs/plans say. Ask when unclear. | +| **Human-in-the-Loop** | Use `duck/*` tools for critical unknowns. Never guess. | +| **Quality First** | Every change compiles, has tests, follows conventions. | + +--- + +## Responsibilities + +1. **Task Orchestration** - Break down work, delegate to subagents, integrate results +2. **Focused Implementation** - Self-implement ONLY small, well-defined changes (≤3 files) +3. **Quality Verification** - Build, test, and validate after subagent work +4. **Bug Fixing** - Diagnose with symbol tools, minimal targeted fixes +5. **Progress Communication** - Keep user informed of progress and blockers + +--- + +## Working Method + +### Phase 0: Delegation Assessment (DO FIRST - Max 3 tool calls) + +**Goal**: Determine delegation strategy BEFORE exploring details. + +1. **Read the task/spec** (1 tool call) +2. **Identify scope**: Count files, modules, platforms involved +3. **Decide** using decision tree below + +``` +DECISION TREE: +Task received + ├─ Is it a single, focused change (≤3 files)? + │ └─ YES → Self-implement (go to Phase 1) + │ └─ NO → Continue assessment + ├─ Can it be split into independent subtasks? + │ └─ YES → Delegate subtasks sequentially + ├─ Does it require exploring unfamiliar code? + │ └─ YES → Delegate exploration first + ├─ Does it span multiple modules/platforms? + │ └─ YES → Delegate to subagent + └─ Is it a complete feature or large refactoring? + └─ YES → Delegate entire task +``` + +### Phase 1: Quick Context (Max 5 tool calls for self-implementation) + +Only if self-implementing after Phase 0 decision: + +1. **Read relevant memories** (`serena/list_memories` → `serena/read_memory`): + - `architecture-patterns` - For understanding current patterns + - `code-style-conventions` - For coding standards + - `suggested-commands` - For build/test commands +2. Read spec/plan document (if referenced) +3. Get symbol overview of target file(s) +4. Identify critical unknowns → **Ask user if any** using `duck/*` tools +5. Proceed to implementation + +**STOP if you need more than 5 tool calls for context** → Delegate instead. + +### Phase 2: Implementation + +1. Use symbol-based navigation (don't read entire files) +2. Search for reusable code patterns +3. Make incremental changes +4. Verify each significant change with builds + +### Phase 3: Verification + +1. **Read `suggested-commands` memory** for project-specific build/test commands +2. Run build verification command +3. Run tests +4. Check errors: `read/problems` +5. Report results to user + +--- + +## Human-in-the-Loop (`duck/*` tools) + +**DO NOT GUESS** on critical decisions. Ask first, implement second. + +### Available Tools + +| Tool | Purpose | Best For | +|------|---------|----------| +| `duck/select_option` | Present choices, get selection | Multiple valid approaches, validating assumptions | +| `duck/provide_information` | Open-ended questions | Gathering requirements, understanding context | +| `duck/request_manual_test` | Request manual verification | Validating functionality on device | + +### When to Ask + +**Always ask when:** +- Spec/plan is ambiguous or has gaps +- Multiple valid approaches with different trade-offs +- API design decisions not specified +- Edge case behavior undefined +- Change may impact other parts of the system + +**Critical Rule:** If asking 3+ questions per task → re-read spec or consult Architect agent first. + +### How to Ask + +#### For Decisions with Clear Options (Preferred) +```yaml +duck/select_option: + question: "[Context]: The existing code uses pattern X, but the spec suggests Y. Which should I follow?" + options: + - "Follow existing pattern X - maintains consistency" + - "Use new pattern Y from spec - aligns with future direction" + - "Hybrid approach - use Y for new code, leave X unchanged" +``` +User can select from options OR choose "Other" to provide a custom answer. + +#### For Open-ended Questions +```yaml +duck/provide_information: + question: "[Context]: The spec doesn't define behavior when [edge case]. What should happen?" +``` + +#### For Manual Testing +```yaml +duck/request_manual_test: + test_description: "Navigate to X screen and verify Y behavior" + expected_outcome: "Should display Z without errors" +``` + +### DON'T Ask For +- Trivial formatting choices +- Obvious spec implementations +- Internal implementation details +- Questions answerable from codebase + +### After User Guidance +1. Implement chosen approach +2. Add code comment: `// Decision: [choice] per user guidance` +3. Update memory if broadly applicable + +--- + +## Delegating to Subagents + +### How Delegation Works + +- Main agent can call `#agent/runSubagent` multiple times, subagents will run in parallel +- It is desired to run multiple subagents in parallel to complete multiple tasks which are independent +- **Subagents cannot spawn subagents** - only main agent has `#agent/runSubagent` tool +- Subagents return a single message with their results + +**Context window is your most precious resource.** Delegate to preserve it for orchestration. + +**10+ tool calls without producing code → STOP and delegate.** + +### When to Delegate vs Self-Implement + +| Scenario | Decision | +|----------|----------| +| Single file, clear change | Self-implement | +| 2-3 tightly coupled files | Self-implement | +| 3+ files OR multiple modules | **Delegate** | +| Unfamiliar code area | **Delegate exploration** | +| Tests for new code | **Delegate** | +| Complete feature task | **Delegate** | +| Design questions | **Delegate to Simple-Architect** | + +### Available Subagents + +| Agent | Purpose | Use When | +|-------|---------|----------| +| **Simple-Developer** | Implementation, tests, bug fixes | Coding tasks, writing tests, code exploration | +| **Simple-Architect** | Analysis, design, documentation | Architecture questions, creating plans, task breakdown | + +**Note:** Simple-* agents cannot delegate further. They execute tasks directly and report back. + +### Delegation Templates + +Provide **clean, detailed instructions** with all necessary context. Subagents work independently. + +#### For Implementation Tasks +``` +[TASK]: [Clear, specific description of what to implement] + +Context: +- [Why this change is needed] +- [How it fits into the larger feature/system] +- [Any decisions already made] + +Spec: `plans/[filename].md` (if applicable) + +Files to Modify: +- [file1.kt]: [what changes needed] +- [file2.kt]: [what changes needed] + +Acceptance Criteria: +- [ ] [Criterion 1] +- [ ] [Criterion 2] +- [ ] Tests pass +- [ ] Follows existing patterns + +Return: Summary of changes made, any issues encountered. +``` + +#### For Exploration (No Changes) +``` +[EXPLORATION]: [What to investigate] + +Context: +- [Why this exploration is needed] +- [What you're trying to understand] + +Scope: +- Files: [files or directories to explore] +- Focus: [specific aspects to analyze] + +Questions to Answer: +1. [Question 1] +2. [Question 2] + +Do NOT make changes, only research and report. + +Return: Summary of findings with code references. +``` + +#### For Architecture Analysis +``` +[ANALYSIS]: [What to analyze] + +Context: +- [Background information] +- [Why analysis is needed] + +Questions: +1. [Specific question 1] +2. [Specific question 2] + +Return: Analysis report with options and recommendation. +``` + +### After Delegation + +1. Review subagent's report +2. Verify subagent's work compiles (if code was written) +3. Run tests (see `suggested-commands` memory for project commands) +4. Inform user of completion/issues + +--- + +## Tool Quick Reference + +### Memories (Read at start of tasks) + +| Memory | Contains | When to Read | +|--------|----------|-------------| +| `architecture-patterns` | Current architecture, patterns, module structure | Before implementation | +| `code-style-conventions` | Coding standards, naming, formatting | Before writing code | +| `suggested-commands` | Build, test, lint commands for this project | Before verification | +| `task-completion-checklist` | Project-specific completion criteria | Before marking done | + +Use `serena/list_memories` to see all available memories, `serena/read_memory` to read specific ones. + +### Code Navigation (Serena - prefer over readFile) +| Tool | Purpose | +|------|---------| +| `serena/get_symbols_overview` | File structure overview | +| `serena/find_symbol` | Find specific symbol | +| `serena/find_referencing_symbols` | Find usages | +| `serena/search_for_pattern` | Pattern search | + +### Files +| Tool | Purpose | +|------|---------| +| `read/readFile` | Read file (only when needed) | +| `edit/createFile` | Create new file | +| `edit/editFiles` | Precise edits | +| `execute/runInTerminal` | Git, file ops | + +--- + +## Code Quality + +**Read `code-style-conventions` memory** for project-specific standards. + +General Kotlin Multiplatform principles apply. Specific conventions, patterns, and requirements are documented in project memories. + +### Trust Order +1. **Specification/Plan document** - PRIMARY source of truth +2. **Actual codebase** - Current patterns +3. **Project memories** - Architecture patterns, conventions (verify if uncertain) + +--- + +## Behavioral Guidelines + +### DO ✅ +- **Read relevant memories first** - architecture, conventions, commands +- **Delegate first** - Default to delegation for multi-file tasks +- Read specs/plans (source of truth) +- Ask user on critical unknowns (`duck/*` tools) +- Use symbol tools (not full file reads) +- Search for reusable code patterns +- Verify builds after changes (use commands from memory) +- Follow conventions from `code-style-conventions` memory +- Keep user informed of progress + +### DON'T ❌ +- Guess on critical decisions +- Skip reading memories at task start +- Skip build verification +- Over-ask on trivial matters +- Create summary markdown files (unless asked) +- Continue exploring beyond 10 tool calls without delegating + +--- + +## Error Handling + +| Issue | Action | +|-------|--------| +| Compilation errors | Read error → `serena/find_symbol` → Fix → Verify | +| Test failures | Read output → Check expected vs actual → Fix | +| IDE vs Gradle conflicts | **Trust Gradle** (IDE shows KMP false positives) | + +--- + +## Domain Knowledge + +This agent specializes in **Kotlin Multiplatform (KMP)** and **Compose Multiplatform** development. + +**Project-specific knowledge is stored in memories:** +- `architecture-patterns` - Module structure, patterns, dependencies +- `code-style-conventions` - Coding standards and conventions +- `suggested-commands` - Build, test, and other commands + +Always read relevant memories at the start of a task to understand project context. + +--- + +## Task Checklist + +**Read `task-completion-checklist` memory** for project-specific completion criteria. + +General checklist: +- [ ] Task requirements met +- [ ] Conventions followed (per `code-style-conventions` memory) +- [ ] Build passes (per `suggested-commands` memory) +- [ ] Tests pass (if applicable) +- [ ] User informed of completion diff --git a/.github/agents/Simple-Architect.agent.md b/.github/agents/Simple-Architect.agent.md new file mode 100644 index 00000000..0cd0773b --- /dev/null +++ b/.github/agents/Simple-Architect.agent.md @@ -0,0 +1,373 @@ +--- +name: Simple-Architect +description: Focused software architect agent for analyzing architectural problems and providing recommendations. Expert in Kotlin Multiplatform system design and code organization. Executes delegated analysis tasks without spawning subagents. +tools: [read, serena/activate_project, serena/find_file, serena/find_referencing_symbols, serena/find_symbol, serena/get_symbols_overview, serena/list_dir, serena/list_memories, serena/read_memory, serena/search_for_pattern, serena/think_about_task_adherence, serena/think_about_whether_you_are_done, serena/write_memory, 'duck/*', edit/createDirectory, edit/createFile, edit/editFiles, edit/rename, search, web, todo] +user-invocable: false +agents: [] +--- + +# Simple-Architect Agent + +Focused Software Architect for executing well-defined analysis and design tasks in **Kotlin Multiplatform** projects. +--- + +## Core Philosophy + +### Evidence-Based Analysis +Every recommendation is backed by analysis of the existing codebase, patterns, and constraints. + +### Thorough Investigation +Explore the codebase deeply to understand full context before making recommendations. + +### Clear Communication +Present findings with clear options, trade-offs, and justified recommendations. + +--- + +## Primary Responsibilities + +### 1. Architectural Analysis +- Analyze complex architectural challenges +- Identify structural issues and technical debt +- Map dependencies and relationships +- Document findings comprehensively + +### 2. Code Organization Analysis +- Analyze module boundaries and dependencies +- Identify coupling issues +- Propose package restructuring +- Design clean APIs between components + +### 3. Task Refinement +- Break down complex requirements into actionable tasks +- Identify dependencies and sequencing +- Estimate effort and risk +- Create structured implementation plans + +### 4. Research & Investigation +- Deep dive into unfamiliar code areas +- Compare implementation approaches +- Gather context for decision making +- Report findings to orchestrating agent + +### 5. Documentation Creation +- Create implementation plans and save to `plans/` directory +- Write architecture decision records (ADRs) +- Document analysis findings in markdown +- Update existing documentation files + +--- + +## Working Method + +### Phase 1: Understand the Request + +1. Read the analysis request from orchestrating agent +2. Identify what information is needed +3. Plan the investigation approach + +### Phase 2: Investigation + +1. **Start with memories** - Check existing documentation +2. **List directories** - Understand project structure +3. **Get symbol overview** - Understand file structure before reading code +4. **Find related symbols** - Map dependencies and relationships +5. **Search for patterns** - Find similar implementations +6. **Read targeted code** - Only read what's necessary + +### Phase 3: Analysis & Synthesis + +1. Map the current state +2. Identify gaps or issues +3. Enumerate possible approaches +4. Analyze trade-offs + +### Phase 4: Report or Document + +**If reporting to orchestrating agent:** +- Clear problem statement +- Analysis of current state +- Options with pros/cons +- Recommendation with justification + +**If creating documentation:** +- Save to specified location (default: `plans/` for implementation plans) +- Use clear markdown formatting +- Include all sections requested by orchestrator +- Confirm completion with file path and brief summary + +--- + +## Human-in-the-Loop Tools (`duck/*`) + +Use when **critical information is missing** that cannot be found in the codebase. + +### Available Tools + +| Tool | Purpose | Best For | +|------|---------|----------| +| `duck/select_option` | Present choices, get selection | Decisions with clear options, validating assumptions | +| `duck/provide_information` | Open-ended questions | Detailed context, exploring unknowns | +| `duck/request_manual_test` | Request verification | Validating functionality on device | + +### When to Use + +- Requirements are ambiguous and cannot be inferred +- Multiple valid paths with significant trade-offs requiring user preference +- Conflicting information found that needs resolution +- Critical assumption needs validation before proceeding + +### When NOT to Use + +- Information is available in codebase (investigate first) +- Trivial decisions that don't impact outcome +- Questions the orchestrating agent should handle +- Decisions within your delegated scope + +### Usage Patterns + +**Resolving Ambiguity:** +```yaml +duck/select_option: + question: "[Context]: The codebase shows two conflicting patterns for X. Which should the new implementation follow?" + options: + - "Pattern A (found in [module]) - [description]" + - "Pattern B (found in [module]) - [description]" +``` + +**Gathering Missing Context:** +```yaml +duck/provide_information: + question: "[Context]: I found the existing implementation handles case X but not case Y. Should the analysis consider Y as in-scope?" +``` + +**Validating Critical Assumption:** +```yaml +duck/select_option: + question: "[Context]: I'm assuming the solution must support [platform/version]. Is that correct?" + options: + - "Yes, that's a hard requirement" + - "No, that can be excluded" + - "It's nice-to-have but not required" +``` + +### HITL Best Practices + +1. **Exhaust codebase investigation first** - Don't ask for what you can find +2. **Be specific** - Provide context for why you're asking +3. **Offer options when possible** - Guides the conversation +4. **Minimize interruptions** - Batch related questions if appropriate +5. **Respect delegation boundaries** - Major decisions go to orchestrator + +--- + +## Delegating to Subagents + +### How Delegation Works + +- Main agent can call `#agent/runSubagent` multiple times, subagents will run in parallel +- It is desired to run multiple subagents in parallel to complete multiple tasks which are independent +- **Subagents cannot spawn subagents** - only main agent has `#agent/runSubagent` tool +- Subagents return a single message with their results + +**Context window is your most precious resource.** Delegate to preserve it for orchestration. + +--- + +## Research Workflow + +### Understanding Current Architecture + +1. **Start with memories** - Check existing documentation +2. **Get symbol overview** - Understand file structure before reading code +3. **Find related symbols** - Map dependencies and relationships +4. **Search for patterns** - Find similar implementations + +### Exploring New Areas + +1. **List directories** - Understand project structure +2. **Find files** - Locate relevant source files +3. **Get symbol overview** - Understand file contents +4. **Read targeted code** - Only read what's necessary +5. **Find references** - Understand usage patterns + +### Web Research (when needed) + +1. Research industry best practices +2. Compare framework approaches +3. Investigate third-party solutions +4. Gather benchmarks and case studies + +--- + +## Output Formats + +### Architecture Analysis Report +```markdown +## Problem Statement +[Clear description of the issue being analyzed] + +## Current State +[Analysis of existing architecture] + +## Options Considered +### Option A: [Name] +- Description: ... +- Pros: ... +- Cons: ... +- Effort: ... + +### Option B: [Name] +[Same structure] + +## Recommendation +[Chosen option with detailed justification] + +## Implementation Considerations +[Notes for implementation phase] + +## Risks & Mitigations +- Risk: [Description] → Mitigation: [Strategy] +``` + +### Task Breakdown +```markdown +## Epic: [High-level goal] + +### Task 1: [Title] +- Description: ... +- Dependencies: ... +- Estimated effort: ... +- Acceptance criteria: ... + +### Task 2: [Title] +[Same structure] + +### Sequencing +[Task dependency graph or ordered list] +``` + +### Investigation Report +```markdown +## Investigation: [Topic] + +## Summary +[Brief answer to the investigation question] + +## Findings +### [Area 1] +[Details and relevant code references] + +### [Area 2] +[Details and relevant code references] + +## Relevant Files +- [file1.kt]: [relevance] +- [file2.kt]: [relevance] + +## Recommendations +[Suggested next steps based on findings] +``` + +### Task Completion Report +```markdown +## Analysis Complete: [Topic] + +## Summary +[Brief answer/recommendation] + +## Key Findings +- [Finding 1] +- [Finding 2] +- [Finding 3] + +## Recommendation +[Clear recommendation with justification] + +## Supporting Evidence +[References to code, patterns, or documentation] + +## Open Questions (if any) +[Things needing further investigation or user decision] +``` + +### Documentation Created Report +```markdown +## Documentation Complete: [Document Title] + +## File Location +`[path/to/file.md]` + +## Summary +[Brief description of what the document covers] + +## Sections Included +- [Section 1] +- [Section 2] +- [Section 3] + +## Notes +[Any relevant notes for the orchestrator] +``` + +--- + +## Behavioral Guidelines + +### DO ✅ +- Complete assigned analysis fully +- Gather comprehensive context before concluding +- Present options with clear trade-offs +- Back recommendations with evidence +- Consider long-term maintainability +- Document assumptions explicitly +- Report findings clearly to orchestrating agent +- Use `duck/*` tools only when codebase doesn't have the answer +- Create well-structured markdown documents when requested +- Save plans to `plans/` directory unless specified otherwise + +### DON'T ❌ +- Assume requirements without evidence +- Rush to conclusions +- Ignore existing architectural patterns +- Over-use HITL tools for trivial matters +- Provide implementation details when only analysis requested +- Forget about non-functional requirements +- Leave questions from orchestrator unanswered + +--- + +## Domain Knowledge + +This agent specializes in: +- **Kotlin Multiplatform (KMP)** development +- **Compose Multiplatform** UI framework +- **Cross-platform mobile** (Android, iOS) +- **MVI/MVVM architecture** patterns +- **Modular architecture** and dependency management + +Leverage project memories for: +- Existing architecture patterns +- Code style conventions +- Project structure +- Historical decisions + +--- + +## Checklist + +### For Analysis Tasks: +- [ ] Analysis request fully addressed +- [ ] Codebase thoroughly investigated +- [ ] Options and trade-offs documented +- [ ] Recommendation provided with reasoning +- [ ] Findings clearly organized +- [ ] Evidence referenced +- [ ] Open questions identified (if any) + +### For Documentation Tasks: +- [ ] Document created at specified location +- [ ] All requested sections included +- [ ] Markdown properly formatted +- [ ] Content is clear and actionable +- [ ] File path confirmed in completion report diff --git a/.github/agents/Simple-Developer.agent.md b/.github/agents/Simple-Developer.agent.md new file mode 100644 index 00000000..1f161fa6 --- /dev/null +++ b/.github/agents/Simple-Developer.agent.md @@ -0,0 +1,255 @@ +--- +name: Simple-Developer +description: Focused Kotlin Multiplatform developer agent for implementing well-defined tasks. Specializes in Compose Multiplatform and MVI architecture. Executes delegated tasks without spawning subagents. +tools: [execute/getTerminalOutput, execute/awaitTerminal, execute/killTerminal, execute/runInTerminal, read/terminalSelection, read/terminalLastCommand, read/problems, read/readFile, agent, 'gradle-mcp/*', serena/activate_project, serena/delete_memory, serena/find_file, serena/find_referencing_symbols, serena/find_symbol, serena/get_current_config, serena/get_symbols_overview, serena/list_dir, serena/list_memories, serena/read_memory, serena/search_for_pattern, serena/switch_modes, serena/think_about_collected_information, serena/think_about_task_adherence, serena/think_about_whether_you_are_done, serena/write_memory, 'duck/*', edit/createDirectory, edit/createFile, edit/editFiles, edit/rename, search, web, todo] +user-invocable: false +agents: ["Simple-Developer","Simple-Architect"] +--- + +# Simple-Developer Agent + +Focused **Kotlin Multiplatform Developer** for executing well-defined implementation tasks. +--- + +## Core Philosophy + +| Principle | Description | +|-----------|-------------| +| **Execution Focus** | Complete the assigned task fully. No delegation available. | +| **Context Efficiency** | Use symbol tools to minimize file reading. | +| **Specification-Driven** | Implement exactly what specs/plans say. Ask when unclear. | +| **Human-in-the-Loop** | Use `duck/*` tools for critical unknowns. Never guess. | +| **Quality First** | Every change compiles, has tests, follows conventions. | + +--- + +## Responsibilities + +1. **Implementation** - Write code according to specifications +2. **Bug Fixing** - Diagnose with symbol tools, minimal targeted fixes +3. **Testing** - Write and run tests for implemented features +4. **Code Exploration** - Research and report findings when requested +5. **Documentation** - Add KDoc to public APIs + +--- + +## Working Method + +### Phase 1: Understand the Task + +1. Read the task description provided by orchestrating agent +2. **Read relevant memories** (`serena/list_memories` → `serena/read_memory`): + - `architecture-patterns` - For understanding current patterns + - `code-style-conventions` - For coding standards + - `suggested-commands` - For build/test commands +3. Read spec/plan document if referenced +4. Get symbol overview of target file(s) +5. Identify critical unknowns → **Ask user if any** using `duck/*` tools + +### Phase 2: Implementation + +1. Use symbol-based navigation (don't read entire files) +2. Search for reusable code patterns +3. Make incremental changes, verify as you go +4. Create new files when needed + +### Phase 3: Verification + +1. **Read `suggested-commands` memory** for project-specific build/test commands +2. Run build verification command +3. Run tests +4. Check errors: `read/problems` +5. Report results back to orchestrating agent + +--- + +## Human-in-the-Loop (`duck/*` tools) + +**DO NOT GUESS** on critical decisions. Ask first, implement second. + +### Available Tools + +| Tool | Purpose | Best For | +|------|---------|----------| +| `duck/select_option` | Present choices, get selection | Multiple valid approaches, validating assumptions | +| `duck/provide_information` | Open-ended questions | Need detailed context, exploring unknowns | +| `duck/request_manual_test` | Request manual verification | Validating functionality on device | + +### When to Use + +- Spec/plan is ambiguous or has gaps +- Multiple valid approaches with different trade-offs +- API design decisions not specified +- Edge case behavior undefined + +### When NOT to Use + +- Information is available in codebase (investigate first) +- Trivial decisions that don't impact outcome +- Questions the orchestrating agent should handle +- Internal implementation details + +### How to Ask + +#### For Decisions with Clear Options (Preferred) +```yaml +duck/select_option: + question: "[Context]: The task says X but existing code does Y. Which should I follow?" + options: + - "Follow task instructions (X) - [trade-off]" + - "Match existing code (Y) - [trade-off]" +``` +User can select from options OR choose "Other" to provide a custom answer. + +#### For Open-ended Questions +```yaml +duck/provide_information: + question: "[Context]: The spec doesn't define behavior for [edge case]. What should happen?" +``` + +#### For Manual Testing +```yaml +duck/request_manual_test: + test_description: "Run the app and verify [specific behavior]" + expected_outcome: "Should [expected result]" +``` + +### HITL Best Practices + +1. **Exhaust codebase investigation first** - Don't ask for what you can find +2. **Be specific** - Provide context for why you're asking +3. **Offer options when possible** - Guides the conversation +4. **Minimize interruptions** - Batch related questions if appropriate + +### After User Guidance +1. Implement chosen approach +2. Add code comment: `// Decision: [choice] per user guidance` + +--- + +## Tool Quick Reference + +### Memories (Read at start of tasks) + +| Memory | Contains | When to Read | +|--------|----------|-------------| +| `architecture-patterns` | Current architecture, patterns, module structure | Before implementation | +| `code-style-conventions` | Coding standards, naming, formatting | Before writing code | +| `suggested-commands` | Build, test, lint commands for this project | Before verification | +| `task-completion-checklist` | Project-specific completion criteria | Before marking done | + +Use `serena/list_memories` to see all available memories, `serena/read_memory` to read specific ones. + +### Code Navigation (Serena - prefer over readFile) +| Tool | Purpose | +|------|---------| +| `serena/get_symbols_overview` | File structure overview | +| `serena/find_symbol` | Find specific symbol | +| `serena/find_referencing_symbols` | Find usages | +| `serena/search_for_pattern` | Pattern search | + +### Files +| Tool | Purpose | +|------|---------| +| `read/readFile` | Read file (only when needed) | +| `edit/createFile` | Create new file | +| `edit/editFiles` | Precise edits | +| `execute/runInTerminal` | Git, file ops | + +--- + +## Code Quality + +**Read `code-style-conventions` memory** for project-specific standards. + +General Kotlin Multiplatform principles apply. Specific conventions, patterns, and requirements are documented in project memories. + +### Trust Order +1. **Task instructions from orchestrator** - PRIMARY +2. **Specification/Plan document** - Reference +3. **Actual codebase** - Current patterns +4. **Project memories** - Architecture patterns, conventions (verify if uncertain) + +--- + +## Behavioral Guidelines + +### DO ✅ +- **Read relevant memories first** - architecture, conventions, commands +- Complete assigned tasks fully +- Read task instructions carefully +- Ask user on critical unknowns (`duck/*` tools) +- Use symbol tools (not full file reads) +- Search for reusable code patterns +- Verify builds after changes (use commands from memory) +- Follow conventions from `code-style-conventions` memory +- Report clear results to orchestrating agent + +### DON'T ❌ +- Guess on critical decisions +- Skip reading memories at task start +- Skip build verification +- Leave tasks incomplete +- Over-ask on trivial matters +- Create summary markdown files (unless specifically asked) + +--- + +## Error Handling + +| Issue | Action | +|-------|--------| +| Compilation errors | Read error → `serena/find_symbol` → Fix → Verify | +| Test failures | Read output → Check expected vs actual → Fix | +| IDE vs Gradle conflicts | **Trust Gradle** (IDE shows KMP false positives) | + +--- + +## Domain Knowledge + +This agent specializes in **Kotlin Multiplatform (KMP)** and **Compose Multiplatform** development. + +**Project-specific knowledge is stored in memories:** +- `architecture-patterns` - Module structure, patterns, dependencies +- `code-style-conventions` - Coding standards and conventions +- `suggested-commands` - Build, test, and other commands + +Always read relevant memories at the start of a task to understand project context. + +--- + +## Task Completion Report + +When completing a task, report back with: + +```markdown +## Summary +[Brief description of what was done] + +## Changes Made +- [File 1]: [Change description] +- [File 2]: [Change description] + +## Verification +- Build: ✅/❌ +- Tests: ✅/❌ + +## Issues Encountered +[Any problems or decisions made] + +## Next Steps (if applicable) +[Recommendations for follow-up work] +``` + +--- + +## Task Checklist + +**Read `task-completion-checklist` memory** for project-specific completion criteria. + +General checklist: +- [ ] Task requirements met +- [ ] Conventions followed (per `code-style-conventions` memory) +- [ ] Build passes (per `suggested-commands` memory) +- [ ] Tests pass (if applicable) +- [ ] Clear report prepared for orchestrator diff --git a/core/src/commonMain/kotlin/pro/respawn/flowmvi/annotation/MustUseReturnValues.kt b/core/src/commonMain/kotlin/pro/respawn/flowmvi/annotation/MustUseReturnValues.kt index 23aa0bd4..7bbe634b 100644 --- a/core/src/commonMain/kotlin/pro/respawn/flowmvi/annotation/MustUseReturnValues.kt +++ b/core/src/commonMain/kotlin/pro/respawn/flowmvi/annotation/MustUseReturnValues.kt @@ -1,3 +1,3 @@ package pro.respawn.flowmvi.annotation -public typealias MustUseReturnValues = kotlin.MustUseReturnValue +public typealias MustUseReturnValues = kotlin.MustUseReturnValues diff --git a/core/src/commonMain/kotlin/pro/respawn/flowmvi/dsl/TransitionScope.kt b/core/src/commonMain/kotlin/pro/respawn/flowmvi/dsl/TransitionScope.kt new file mode 100644 index 00000000..fe9f5d5b --- /dev/null +++ b/core/src/commonMain/kotlin/pro/respawn/flowmvi/dsl/TransitionScope.kt @@ -0,0 +1,65 @@ +@file:Suppress("UNCHECKED_CAST") + +package pro.respawn.flowmvi.dsl + +import pro.respawn.flowmvi.annotation.NotIntendedForInheritance +import pro.respawn.flowmvi.api.FlowMVIDSL +import pro.respawn.flowmvi.api.MVIAction +import pro.respawn.flowmvi.api.MVIIntent +import pro.respawn.flowmvi.api.MVIState +import pro.respawn.flowmvi.api.PipelineContext + +/** + * Scope available inside FSM [on][StateTransitionsBuilder.on] handlers. + * Delegates to [PipelineContext] for full store API access, and adds + * typed [state] and convenience [transitionTo] methods. + * + * Everything you can do in [reduce][pro.respawn.flowmvi.plugins.reduce], you can do here — plus you get the current + * state guaranteed to be of type [T], and a shorthand for state transitions. + */ +@FlowMVIDSL +@OptIn(ExperimentalSubclassOptIn::class) +@SubclassOptInRequired(NotIntendedForInheritance::class) +public interface TransitionScope : + PipelineContext { + + /** + * The current state, guaranteed to be of type [T] at the time this handler was invoked. + * + * **Note**: If [pro.respawn.flowmvi.api.StoreConfiguration.parallelIntents] is `true`, the actual store state + * may have changed by the time you read this. Use [updateState] for atomic operations. + * For most use cases this value is stable because the default is sequential intent processing. + */ + public val state: T + + /** + * Transition to [target] state. Shorthand for `updateState { target }`. + * The transition will be validated by the FSM's enforcement rules via `onState`. + */ + @FlowMVIDSL + public suspend fun transitionTo(target: S) + + /** + * Transition to [target] state and emit [sideEffect] action. + * Shorthand for `updateState { target }; action(sideEffect)`. + */ + @FlowMVIDSL + public suspend fun transitionTo(target: S, sideEffect: A) +} + +@OptIn(NotIntendedForInheritance::class) +@PublishedApi +internal class TransitionScopeImpl( + private val pipeline: PipelineContext, + override val state: T, +) : TransitionScope, PipelineContext by pipeline { + + override suspend fun transitionTo(target: S) { + updateState { target } + } + + override suspend fun transitionTo(target: S, sideEffect: A) { + updateState { target } + action(sideEffect) + } +} diff --git a/core/src/commonMain/kotlin/pro/respawn/flowmvi/dsl/TransitionsBuilder.kt b/core/src/commonMain/kotlin/pro/respawn/flowmvi/dsl/TransitionsBuilder.kt new file mode 100644 index 00000000..1c72605c --- /dev/null +++ b/core/src/commonMain/kotlin/pro/respawn/flowmvi/dsl/TransitionsBuilder.kt @@ -0,0 +1,154 @@ +@file:Suppress("UNCHECKED_CAST") + +package pro.respawn.flowmvi.dsl + +import pro.respawn.flowmvi.api.FlowMVIDSL +import pro.respawn.flowmvi.api.MVIAction +import pro.respawn.flowmvi.api.MVIIntent +import pro.respawn.flowmvi.api.MVIState +import pro.respawn.flowmvi.api.PipelineContext +import pro.respawn.flowmvi.api.Store +import pro.respawn.flowmvi.plugins.ComposeDefinition +import pro.respawn.flowmvi.plugins.StateDefinition +import pro.respawn.flowmvi.plugins.TransitionGraph +import kotlin.reflect.KClass + +/** + * Builder that collects state definitions and compiles a [TransitionGraph]. + */ +@FlowMVIDSL +public class TransitionsBuilder +@PublishedApi internal constructor() { + + @PublishedApi + internal val definitions: MutableMap, StateDefinition> = mutableMapOf() + + @PublishedApi + internal val topLevelCompositions: MutableList> = mutableListOf() + + /** + * Compose a child store into this store's state. The child's state changes are merged into the + * parent state at all times while the parent store is active. + * + * The child store is automatically started as a lifecycle child of the parent store. + * + * @param store The child store to compose + * @param merge Maps the child's state into the parent's state. Called each time the child state changes. + * Receiver is the current parent state; parameter is the new child state. Returns the new parent state. + * @param consume Optional lambda to handle actions emitted by the child store. Runs in the parent's + * [PipelineContext], so you can call [action][pro.respawn.flowmvi.api.ActionReceiver.action], + * [intent][pro.respawn.flowmvi.api.IntentReceiver.intent], `updateState`, etc. + */ + @FlowMVIDSL + public fun compose( + store: Store, + merge: S.(childState: CS) -> S, + consume: (suspend PipelineContext.(CA) -> Unit)? = null, + ) { + topLevelCompositions += ComposeDefinition( + store = store, + merge = merge, + consume = consume, + scopedToState = null, + ) + } + + /** + * Define intent handlers for state type [T]. + * Only intents registered via [StateTransitionsBuilder.on] will be handled when the store is in state [T]. + * + * @throws IllegalArgumentException if [T] is already defined in this transitions block. + */ + @FlowMVIDSL + public inline fun state( + @BuilderInference block: StateTransitionsBuilder.() -> Unit, + ) { + val stateClass = T::class + require(stateClass !in definitions) { + "State ${stateClass.simpleName} is already defined in this transitions block" + } + val builder = StateTransitionsBuilder(stateClass).apply(block) + definitions[stateClass] = builder.build() + } + + @PublishedApi + internal fun build(): TransitionGraph = TransitionGraph( + definitions = definitions.toMap(), + ) +} + +/** + * Builder for declaring intent handlers within a specific state type [T]. + */ +@FlowMVIDSL +public class StateTransitionsBuilder +@PublishedApi internal constructor( + @PublishedApi internal val stateType: KClass, +) { + + @PublishedApi + internal val handlers: MutableMap, suspend PipelineContext.(state: S, intent: I) -> Unit> = + mutableMapOf() + + @PublishedApi + internal val compositions: MutableList> = mutableListOf() + + /** + * Compose a child store into this store's state, scoped to state type [T]. + * + * The child subscription is **only active while the parent store is in state [T]**. + * When the parent transitions into [T], the subscription starts and the child's current state + * is immediately merged. When the parent transitions out of [T], the subscription is cancelled. + * + * The child store is automatically started as a lifecycle child of the parent store + * (it outlives individual state scopes and is stopped when the parent store stops). + * + * @param store The child store to compose + * @param merge Maps the child's state into the parent's state. Receiver is the current parent state + * typed as [T]; returns the new parent state (may be any [S] to support transitions). + * @param consume Optional lambda to handle actions emitted by the child store. + */ + @FlowMVIDSL + public fun compose( + store: Store, + merge: T.(childState: CS) -> S, + consume: (suspend PipelineContext.(CA) -> Unit)? = null, + ) { + compositions += ComposeDefinition( + store = store, + merge = merge as S.(Any) -> S, // widened for storage; safety ensured by scoped activation + consume = consume, + scopedToState = stateType, + ) + } + + /** + * Handle intent of type [E] when the store is in state [T]. + * Inside the lambda, you have full [pro.respawn.flowmvi.api.PipelineContext] access + * plus typed [TransitionScope.state]. + * Use [TransitionScope.transitionTo] to change state, + * or call `updateState`/`action`/`intent`/`launch` directly. + * + * @throws IllegalArgumentException if [E] is already handled for state [T]. + */ + @FlowMVIDSL + public inline fun on( + noinline block: suspend TransitionScope.(E) -> Unit, + ) { + val intentClass = E::class + require(intentClass !in handlers) { + "Intent ${intentClass.simpleName} is already handled for state ${stateType.simpleName}" + } + handlers[intentClass] = handler@{ state, intent -> + val scope = TransitionScopeImpl(this, state as T) + scope.block(intent as E) + } + } + + @PublishedApi + internal fun build(): StateDefinition = StateDefinition( + stateType = stateType, + handlers = handlers.toMap(), + compositions = compositions.toList(), + ) +} diff --git a/core/src/commonMain/kotlin/pro/respawn/flowmvi/plugins/ComposePlugin.kt b/core/src/commonMain/kotlin/pro/respawn/flowmvi/plugins/ComposePlugin.kt new file mode 100644 index 00000000..10708ce6 --- /dev/null +++ b/core/src/commonMain/kotlin/pro/respawn/flowmvi/plugins/ComposePlugin.kt @@ -0,0 +1,89 @@ +package pro.respawn.flowmvi.plugins + +import kotlinx.atomicfu.locks.SynchronizedObject +import kotlinx.atomicfu.locks.synchronized +import kotlinx.coroutines.Job +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.launch +import pro.respawn.flowmvi.annotation.InternalFlowMVIAPI +import pro.respawn.flowmvi.api.MVIAction +import pro.respawn.flowmvi.api.MVIIntent +import pro.respawn.flowmvi.api.MVIState +import pro.respawn.flowmvi.api.PipelineContext +import pro.respawn.flowmvi.api.StorePlugin +import pro.respawn.flowmvi.dsl.collect +import pro.respawn.flowmvi.dsl.plugin + +@Suppress("UNCHECKED_CAST") +@OptIn(InternalFlowMVIAPI::class) +@PublishedApi +internal fun buildComposePlugin( + topLevel: List>, + scoped: List>, +): StorePlugin? { + if (topLevel.isEmpty() && scoped.isEmpty()) return null + + return plugin { + onStart { + for (def in topLevel) { + launchComposeSubscription(def) + } + + if (scoped.isNotEmpty()) { + launchScopedCompositions(scoped) + } + } + } +} + +@Suppress("UNCHECKED_CAST") +@OptIn(InternalFlowMVIAPI::class) +private fun PipelineContext.launchComposeSubscription( + def: ComposeDefinition, +): Job { + val typedDef = def as ComposeDefinition + return launch { + typedDef.store.collect { + launch { + states.collect { childState -> + updateState { typedDef.merge(this, childState) } + } + } + typedDef.consume?.let { consume -> + launch { + actions.collect { childAction -> + (consume as suspend PipelineContext.(Any) -> Unit) + .invoke(this@launchComposeSubscription, childAction) + } + } + } + awaitCancellation() + } + } +} + +@OptIn(InternalFlowMVIAPI::class) +private fun PipelineContext.launchScopedCompositions( + scoped: List>, +) { + val lock = SynchronizedObject() + val jobMap = mutableMapOf, Job>() + + launch { + states.collect { parentState -> + synchronized(lock) { + for (def in scoped) { + val shouldBeActive = def.scopedToState?.isInstance(parentState) == true + val currentJob = jobMap[def] + + if (shouldBeActive && (currentJob == null || !currentJob.isActive)) { + jobMap[def] = launchComposeSubscription(def) + } else if (!shouldBeActive && currentJob != null) { + currentJob.cancel() + jobMap.remove(def) + } + } + } + } + } +} diff --git a/core/src/commonMain/kotlin/pro/respawn/flowmvi/plugins/TransitionGraph.kt b/core/src/commonMain/kotlin/pro/respawn/flowmvi/plugins/TransitionGraph.kt new file mode 100644 index 00000000..4cfe7fe9 --- /dev/null +++ b/core/src/commonMain/kotlin/pro/respawn/flowmvi/plugins/TransitionGraph.kt @@ -0,0 +1,61 @@ +package pro.respawn.flowmvi.plugins + +import pro.respawn.flowmvi.api.MVIAction +import pro.respawn.flowmvi.api.MVIIntent +import pro.respawn.flowmvi.api.MVIState +import pro.respawn.flowmvi.api.PipelineContext +import pro.respawn.flowmvi.api.Store +import kotlin.reflect.KClass + +/** + * Internal handler function. Takes PipelineContext + current state + intent. + * The PipelineContext is the receiver so handlers can call updateState/action/intent/launch. + */ +internal typealias IntentHandler = suspend PipelineContext.(state: S, intent: I) -> Unit + +/** + * Immutable transition graph that maps (StateType, IntentType) → handler. + */ +@PublishedApi +internal data class TransitionGraph( + /** Map from state KClass to its [StateDefinition]. */ + val definitions: Map, StateDefinition>, +) + +/** + * All intent handlers registered for a particular state type. + */ +@PublishedApi +internal data class StateDefinition( + /** The KClass of the state this definition applies to. */ + val stateType: KClass, + /** Map from intent KClass to handler. */ + val handlers: Map, IntentHandler>, + /** Child store compositions scoped to this state type. */ + val compositions: List> = emptyList(), +) + +/** + * Internal representation of a `compose()` call in the transitions DSL. + * Captures the child store, merge function, optional action consumer, and scope constraint. + */ +@PublishedApi +@Suppress("UseDataClass") // holds function references; equality is not meaningful +internal class ComposeDefinition< + S : MVIState, + I : MVIIntent, + A : MVIAction, + CS : MVIState, + CI : MVIIntent, + CA : MVIAction, + >( + /** The child store to subscribe to. */ + val store: Store, + /** Merges child state into parent state. */ + val merge: S.(CS) -> S, + /** Optional handler for child actions, running in parent's PipelineContext. */ + val consume: (suspend PipelineContext.(CA) -> Unit)?, + /** If non-null, composition is active only while parent is in this state type. + * If null, always active (top-level). */ + val scopedToState: KClass?, +) diff --git a/core/src/commonMain/kotlin/pro/respawn/flowmvi/plugins/TransitionsPlugin.kt b/core/src/commonMain/kotlin/pro/respawn/flowmvi/plugins/TransitionsPlugin.kt new file mode 100644 index 00000000..1620e2c9 --- /dev/null +++ b/core/src/commonMain/kotlin/pro/respawn/flowmvi/plugins/TransitionsPlugin.kt @@ -0,0 +1,137 @@ +@file:pro.respawn.flowmvi.annotation.MustUseReturnValues + +package pro.respawn.flowmvi.plugins + +import kotlinx.atomicfu.atomic +import pro.respawn.flowmvi.annotation.InternalFlowMVIAPI +import pro.respawn.flowmvi.api.FlowMVIDSL +import pro.respawn.flowmvi.api.MVIAction +import pro.respawn.flowmvi.api.MVIIntent +import pro.respawn.flowmvi.api.MVIState +import pro.respawn.flowmvi.api.StorePlugin +import pro.respawn.flowmvi.dsl.StoreBuilder +import pro.respawn.flowmvi.dsl.TransitionsBuilder +import pro.respawn.flowmvi.dsl.plugin +import pro.respawn.flowmvi.exceptions.InvalidStateException + +/** + * Default name for [transitionsPlugin]. + * This is hardcoded so that multiple [transitions] invocations are not allowed w/o + * explicit consent of the user as most often multiple FSM plugins will conflict with each other. + * Provide your own name if you want to have multiple FSM plugins. + */ +public const val TransitionsPluginName: String = "TransitionsPlugin" + +/** + * Install a finite state machine plugin that handles intents based on the current state type. + * + * This is a full replacement for [reduce] — handlers have full `PipelineContext` access. + * Intents not matched by any FSM handler pass through to downstream plugins. + * + * Name is hardcoded because usually multiple FSM plugins are not used. + * Provide your own name if you want to have multiple FSM plugins. + * + * @see transitionsPlugin + */ +@IgnorableReturnValue +@FlowMVIDSL +public inline fun StoreBuilder.transitions( + name: String = TransitionsPluginName, + @BuilderInference block: TransitionsBuilder.() -> Unit, +): Unit = install(transitionsPlugin(name, block)) + +/** + * Create a finite state machine plugin that handles intents based on the current state type. + * + * The plugin uses `onIntent` to dispatch intents to type-matched handlers and `onState` to enforce + * that cross-type state transitions only happen through FSM handlers. + * + * * In debug mode (`config.debuggable = true`), invalid external transitions throw [InvalidStateException]. + * * In release mode, invalid external transitions are silently vetoed (the old state is kept). + * * Same-type state updates (e.g., `copy()` on a data class) are always allowed. + * * Intents not matched by any handler pass through to downstream plugins. + * + * When `compose()` calls are present in the transitions DSL, the returned plugin is a composite of: + * 1. A [childStorePlugin] that manages child store lifecycles + * 2. A compose plugin that manages state subscriptions and merging + * 3. The core FSM plugin with `onIntent` dispatch and `onState` enforcement + * + * @param name Plugin name. Defaults to [TransitionsPluginName]. + * @param block DSL block to define state transitions. + * @see transitions + */ +@FlowMVIDSL +public inline fun transitionsPlugin( + name: String = TransitionsPluginName, + @BuilderInference block: TransitionsBuilder.() -> Unit, +): StorePlugin { + val builder = TransitionsBuilder().apply(block) + val graph = builder.build() + val topLevelCompositions = builder.topLevelCompositions.toList() + val scopedCompositions = graph.definitions.values.flatMap { it.compositions } + + val fsmPlugin = buildFsmPlugin(graph, name) + + if (topLevelCompositions.isEmpty() && scopedCompositions.isEmpty()) return fsmPlugin + + val allChildStores = (topLevelCompositions + scopedCompositions) + .map { it.store } + .toSet() + + val childPlugin = childStorePlugin(allChildStores, force = null, blocking = false) + val composePlugin = buildComposePlugin(topLevelCompositions, scopedCompositions) + + return compositePlugin( + plugins = listOfNotNull(childPlugin, composePlugin, fsmPlugin), + name = name, + ) +} + +private class HandlerDepthTracker { + private val depth = atomic(0) + fun increment() = depth.incrementAndGet() + fun decrement() = depth.decrementAndGet() + val isInsideHandler: Boolean get() = depth.value > 0 +} + +/** + * Build the core FSM plugin with `onIntent` dispatch and `onState` enforcement. + */ +@OptIn(InternalFlowMVIAPI::class) +@PublishedApi +internal fun buildFsmPlugin( + graph: TransitionGraph, + name: String, +): StorePlugin { + val tracker = HandlerDepthTracker() + return plugin { + this.name = name + + onIntent { intent -> + val currentState = states.value + val stateClass = currentState::class + val definition = graph.definitions[stateClass] + ?: return@onIntent intent + val handler = definition.handlers[intent::class] + ?: return@onIntent intent + + tracker.increment() + try { + handler.invoke(this, currentState, intent) + } finally { + tracker.decrement() + } + + null // consume the intent + } + + onState { old, new -> + if (tracker.isInsideHandler) return@onState new + if (old::class == new::class) return@onState new + if (config.debuggable) { + throw InvalidStateException(new::class.simpleName, old::class.simpleName) + } + old // veto silently in release mode + } + } +} diff --git a/core/src/jvmTest/kotlin/pro/respawn/flowmvi/test/plugin/ComposePluginTest.kt b/core/src/jvmTest/kotlin/pro/respawn/flowmvi/test/plugin/ComposePluginTest.kt new file mode 100644 index 00000000..2fa139d7 --- /dev/null +++ b/core/src/jvmTest/kotlin/pro/respawn/flowmvi/test/plugin/ComposePluginTest.kt @@ -0,0 +1,444 @@ +@file:OptIn(InternalFlowMVIAPI::class) + +package pro.respawn.flowmvi.test.plugin + +import io.kotest.core.spec.style.FreeSpec +import io.kotest.matchers.shouldBe +import io.kotest.matchers.types.shouldBeInstanceOf +import pro.respawn.flowmvi.annotation.InternalFlowMVIAPI +import pro.respawn.flowmvi.api.ActionShareBehavior +import pro.respawn.flowmvi.api.MVIAction +import pro.respawn.flowmvi.api.MVIIntent +import pro.respawn.flowmvi.api.MVIState +import pro.respawn.flowmvi.dsl.store +import pro.respawn.flowmvi.plugins.reduce +import pro.respawn.flowmvi.plugins.transitions +import pro.respawn.flowmvi.util.configure +import pro.respawn.flowmvi.util.idle + +// region Contract types + +private sealed interface ChildState : MVIState { + data object Initial : ChildState + data class Loaded(val data: String) : ChildState +} + +private sealed interface ChildIntent : MVIIntent { + data class SetData(val data: String) : ChildIntent +} + +private sealed interface ChildAction : MVIAction { + data class Notify(val message: String) : ChildAction +} + +private sealed interface ParentState : MVIState { + data object Loading : ParentState + data class Content( + val childData: ChildState = ChildState.Initial, + val label: String = "", + ) : ParentState + + data class Error(val message: String) : ParentState + data class DualContent( + val child1Data: ChildState = ChildState.Initial, + val child2Data: ChildState = ChildState.Initial, + ) : ParentState +} + +private sealed interface ParentIntent : MVIIntent { + data object GoToContent : ParentIntent + data object GoToError : ParentIntent + data object GoToLoading : ParentIntent + data class UpdateLabel(val label: String) : ParentIntent + data class SetChildData(val data: String) : ParentIntent +} + +private sealed interface ParentAction : MVIAction { + data class ForwardedNotification(val message: String) : ParentAction +} + +// endregion + +// region Child store factories + +private fun childStore( + name: String = "ChildStore", +) = store(ChildState.Initial) { + configure { + debuggable = true + this.name = name + actionShareBehavior = ActionShareBehavior.Distribute() + } + reduce { intent -> + when (intent) { + is ChildIntent.SetData -> updateState { ChildState.Loaded(intent.data) } + } + } +} + +private fun childStoreWithAction( + name: String = "ChildActionStore", +) = store(ChildState.Initial) { + configure { + debuggable = true + this.name = name + actionShareBehavior = ActionShareBehavior.Distribute() + } + reduce { intent -> + when (intent) { + is ChildIntent.SetData -> { + updateState { ChildState.Loaded(intent.data) } + action(ChildAction.Notify("loaded: ${intent.data}")) + } + } + } +} + +// endregion + +class ComposePluginTest : FreeSpec({ + configure() + + "Given a parent store with top-level compose" - { + "When child state changes" - { + "Then parent state reflects the change" { + val child = childStore() + val parent = store(ParentState.Content()) { + configure { + debuggable = true + name = "ParentStore" + } + transitions { + compose(child, merge = { childState -> + when (this) { + is ParentState.Content -> copy(childData = childState) + else -> this + } + }) + } + } + + val lc = parent.start(this) + lc.awaitStartup() + idle() + + child.emit(ChildIntent.SetData("hello")) + idle() + + parent.states.value shouldBe ParentState.Content(childData = ChildState.Loaded("hello")) + + lc.closeAndWait() + } + } + } + + "Given a parent store with top-level compose and consume lambda" - { + "When child emits an action" - { + "Then consume lambda receives the child action" { + val child = childStoreWithAction() + var forwardedMessage: String? = null + val parent = store(ParentState.Content()) { + configure { + debuggable = true + name = "ParentConsumeStore" + actionShareBehavior = ActionShareBehavior.Distribute() + } + transitions { + compose(child, merge = { childState -> + when (this) { + is ParentState.Content -> copy(childData = childState) + else -> this + } + }) { childAction -> + when (childAction) { + is ChildAction.Notify -> forwardedMessage = childAction.message + } + } + } + } + + val lc = parent.start(this) + lc.awaitStartup() + idle() + + child.emit(ChildIntent.SetData("world")) + idle() + + forwardedMessage shouldBe "loaded: world" + + lc.closeAndWait() + } + } + } + + "Given a parent store with state-scoped compose" - { + "When parent enters the scoped state" - { + "Then child state is merged into parent" { + val child = childStore() + val parent = store(ParentState.Loading) { + configure { + debuggable = true + name = "ScopedComposeStore" + } + transitions { + state { + on { + transitionTo(ParentState.Content()) + } + } + state { + compose(child, merge = { childState -> copy(childData = childState) }) + on { + transitionTo(state.copy(label = it.label)) + } + on { + transitionTo(ParentState.Error("error")) + } + on { + transitionTo(ParentState.Loading) + } + } + state { + on { + transitionTo(ParentState.Content()) + } + } + } + } + + val lc = parent.start(this) + lc.awaitStartup() + idle() + + // Parent is in Loading — scoped compose for Content is not active + parent.states.value.shouldBeInstanceOf() + + // Change child state before parent enters Content + child.emit(ChildIntent.SetData("pre-transition")) + idle() + + // Parent still Loading, child state not merged + parent.states.value shouldBe ParentState.Loading + + // Transition parent to Content + parent.emit(ParentIntent.GoToContent) + idle() + + // Scoped compose activates and merges child's current state + parent.states.value shouldBe ParentState.Content( + childData = ChildState.Loaded("pre-transition"), + ) + + // Further child changes are also merged + child.emit(ChildIntent.SetData("post-transition")) + idle() + + parent.states.value shouldBe ParentState.Content( + childData = ChildState.Loaded("post-transition"), + ) + + lc.closeAndWait() + } + } + + "When parent leaves the scoped state" - { + "Then child state changes no longer affect parent" { + val child = childStore() + val parent = store(ParentState.Loading) { + configure { + debuggable = true + name = "ScopedDeactivateStore" + } + transitions { + state { + on { + transitionTo(ParentState.Content()) + } + } + state { + compose(child, merge = { childState -> copy(childData = childState) }) + on { + transitionTo(ParentState.Error("error")) + } + } + state { + on { + transitionTo(ParentState.Content()) + } + } + } + } + + val lc = parent.start(this) + lc.awaitStartup() + idle() + + // Enter Content so compose is active + parent.emit(ParentIntent.GoToContent) + idle() + parent.states.value.shouldBeInstanceOf() + + // Leave Content → go to Error + parent.emit(ParentIntent.GoToError) + idle() + parent.states.value shouldBe ParentState.Error("error") + + // Change child state while parent is in Error — should not affect parent + child.emit(ChildIntent.SetData("ignored")) + idle() + + parent.states.value shouldBe ParentState.Error("error") + + lc.closeAndWait() + } + } + + "When parent re-enters the scoped state" - { + "Then child's current state is merged again" { + val child = childStore() + val parent = store(ParentState.Loading) { + configure { + debuggable = true + name = "ScopedReentryStore" + } + transitions { + state { + on { + transitionTo(ParentState.Content()) + } + } + state { + compose(child, merge = { childState -> copy(childData = childState) }) + on { + transitionTo(ParentState.Error("error")) + } + } + state { + on { + transitionTo(ParentState.Content()) + } + } + } + } + + val lc = parent.start(this) + lc.awaitStartup() + idle() + + // Enter Content + parent.emit(ParentIntent.GoToContent) + idle() + + // Update child + child.emit(ChildIntent.SetData("first")) + idle() + parent.states.value shouldBe ParentState.Content(childData = ChildState.Loaded("first")) + + // Leave Content → Error + parent.emit(ParentIntent.GoToError) + idle() + + // Update child while in Error (child is still running, just not subscribed) + child.emit(ChildIntent.SetData("second")) + idle() + parent.states.value shouldBe ParentState.Error("error") + + // Re-enter Content + parent.emit(ParentIntent.GoToContent) + idle() + + // Child's current state (Loaded("second")) should be merged immediately + parent.states.value shouldBe ParentState.Content( + childData = ChildState.Loaded("second"), + ) + + lc.closeAndWait() + } + } + } + + "Given a parent store with compose" - { + "When parent is stopped" - { + "Then child store is also stopped" { + val child = childStore() + val parent = store(ParentState.Content()) { + configure { + debuggable = true + name = "LifecycleStore" + } + transitions { + compose(child, merge = { childState -> + when (this) { + is ParentState.Content -> copy(childData = childState) + else -> this + } + }) + } + } + + val lc = parent.start(this) + lc.awaitStartup() + idle() + + // Both parent and child should be active + parent.isActive shouldBe true + child.isActive shouldBe true + + // Stop parent + lc.closeAndWait() + idle() + + // Both should be stopped + parent.isActive shouldBe false + child.isActive shouldBe false + } + } + } + + "Given a parent store with multiple compose calls" - { + "When both child stores change state" - { + "Then parent state reflects both changes" { + val child1 = childStore("Child1") + val child2 = childStore("Child2") + + val parent = store(ParentState.DualContent()) { + configure { + debuggable = true + name = "MultiComposeStore" + } + transitions { + compose(child1, merge = { childState -> + when (this) { + is ParentState.DualContent -> copy(child1Data = childState) + else -> this + } + }) + compose(child2, merge = { childState -> + when (this) { + is ParentState.DualContent -> copy(child2Data = childState) + else -> this + } + }) + } + } + + val lc = parent.start(this) + lc.awaitStartup() + idle() + + child1.emit(ChildIntent.SetData("from-child1")) + idle() + + child2.emit(ChildIntent.SetData("from-child2")) + idle() + + parent.states.value shouldBe ParentState.DualContent( + child1Data = ChildState.Loaded("from-child1"), + child2Data = ChildState.Loaded("from-child2"), + ) + + lc.closeAndWait() + } + } + } +}) diff --git a/core/src/jvmTest/kotlin/pro/respawn/flowmvi/test/plugin/TransitionsPluginTest.kt b/core/src/jvmTest/kotlin/pro/respawn/flowmvi/test/plugin/TransitionsPluginTest.kt new file mode 100644 index 00000000..68b9c0a6 --- /dev/null +++ b/core/src/jvmTest/kotlin/pro/respawn/flowmvi/test/plugin/TransitionsPluginTest.kt @@ -0,0 +1,384 @@ +@file:OptIn(InternalFlowMVIAPI::class) + +package pro.respawn.flowmvi.test.plugin + +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.FreeSpec +import io.kotest.matchers.nulls.shouldBeNull +import io.kotest.matchers.nulls.shouldNotBeNull +import io.kotest.matchers.shouldBe +import io.kotest.matchers.types.shouldBeInstanceOf +import pro.respawn.flowmvi.annotation.InternalFlowMVIAPI +import pro.respawn.flowmvi.api.MVIAction +import pro.respawn.flowmvi.api.MVIIntent +import pro.respawn.flowmvi.api.MVIState +import pro.respawn.flowmvi.dsl.store +import pro.respawn.flowmvi.exceptions.InvalidStateException +import pro.respawn.flowmvi.plugins.reduce +import pro.respawn.flowmvi.plugins.transitions +import pro.respawn.flowmvi.plugins.transitionsPlugin +import pro.respawn.flowmvi.util.configure +import pro.respawn.flowmvi.util.idle + +// region Contract types for FSM testing + +private sealed interface FSMState : MVIState { + data object Loading : FSMState + data class Content(val items: List = emptyList()) : FSMState + data class Error(val message: String) : FSMState +} + +private sealed interface FSMIntent : MVIIntent { + data object Load : FSMIntent + data class DataLoaded(val items: List) : FSMIntent + data class LoadFailed(val message: String) : FSMIntent + data object Refresh : FSMIntent + data object Retry : FSMIntent + data class UpdateFilter(val filter: String) : FSMIntent + data object Unhandled : FSMIntent +} + +private sealed interface FSMAction : MVIAction { + data class ShowError(val message: String) : FSMAction + data class NavigateTo(val screen: String) : FSMAction +} + +// endregion + +// region Plugin factories + +private fun testFsmPlugin() = transitionsPlugin { + state { + on { + // no-op handler, just consumes the intent + } + on { + transitionTo(FSMState.Content(it.items)) + } + on { + transitionTo(FSMState.Error(it.message), FSMAction.ShowError(it.message)) + } + } + state { + on { + transitionTo(FSMState.Loading) + } + on { + transitionTo(state.copy(items = state.items + it.filter)) + } + } + state { + on { + transitionTo(FSMState.Loading) + } + } +} + +// endregion + +class TransitionsPluginTest : FreeSpec({ + configure() + + "Given an FSM transitions plugin" - { + val plugin = testFsmPlugin() + + "and the store is in Loading state" - { + plugin.test(FSMState.Loading) { + + "When DataLoaded intent is sent" - { + val items = listOf("a", "b", "c") + val result = onIntent(FSMIntent.DataLoaded(items)) + + "Then state transitions to Content" { + states.value shouldBe FSMState.Content(items) + } + "Then intent is consumed" { + result.shouldBeNull() + } + } + } + } + + "and transitionTo with sideEffect emits action" - { + plugin.test(FSMState.Loading) { + onStart() + + "When LoadFailed intent is sent" - { + val result = onIntent(FSMIntent.LoadFailed("network error")) + + "Then state transitions to Error" { + states.value shouldBe FSMState.Error("network error") + } + "Then action is emitted via timeTravel" { + timeTravel.actions.last() shouldBe FSMAction.ShowError("network error") + } + "Then intent is consumed" { + result.shouldBeNull() + } + } + } + } + + "and handler calls action() directly" - { + val actionPlugin = transitionsPlugin { + state { + on { + action(FSMAction.NavigateTo("home")) + } + } + } + actionPlugin.test(FSMState.Loading) { + onStart() + + "When Load intent is sent" - { + val result = onIntent(FSMIntent.Load) + + "Then action is emitted" { + timeTravel.actions.last() shouldBe FSMAction.NavigateTo("home") + } + "Then state remains unchanged" { + states.value shouldBe FSMState.Loading + } + "Then intent is consumed" { + result.shouldBeNull() + } + } + } + } + + "and handler uses updateState directly" - { + val updatePlugin = transitionsPlugin { + state { + on { + updateState { FSMState.Content(it.items) } + } + } + } + updatePlugin.test(FSMState.Loading) { + + "When DataLoaded intent is sent" - { + onIntent(FSMIntent.DataLoaded(listOf("x"))) + + "Then state is updated via updateState" { + states.value shouldBe FSMState.Content(listOf("x")) + } + } + } + } + + "and handler receives correctly typed state" - { + var capturedState: FSMState? = null + val typedPlugin = transitionsPlugin { + state { + on { + capturedState = state + transitionTo(state.copy(items = state.items + it.filter)) + } + } + } + val initial = FSMState.Content(listOf("existing")) + typedPlugin.test(initial) { + + "When UpdateFilter intent is sent" - { + onIntent(FSMIntent.UpdateFilter("new")) + + "Then handler receives typed Content state" { + capturedState.shouldNotBeNull() + capturedState.shouldBeInstanceOf() + (capturedState as FSMState.Content).items shouldBe listOf("existing") + } + "Then state is updated with filter applied" { + states.value shouldBe FSMState.Content(listOf("existing", "new")) + } + } + } + } + + "and Load intent is sent in Loading state" - { + plugin.test(FSMState.Loading) { + + "When Load intent is sent" - { + val result = onIntent(FSMIntent.Load) + + "Then intent is consumed" { + result.shouldBeNull() + } + } + } + } + + "and an unhandled intent is sent in Loading state" - { + plugin.test(FSMState.Loading) { + + "When Unhandled intent is sent" - { + val result = onIntent(FSMIntent.Unhandled) + + "Then intent passes through" { + result.shouldNotBeNull() + result shouldBe FSMIntent.Unhandled + } + } + } + } + + "and an intent is sent for a state with no handlers" - { + val minimalPlugin = transitionsPlugin { + state { + on { } + } + // no state block + } + minimalPlugin.test(FSMState.Content(listOf("data"))) { + + "When Refresh intent is sent" - { + val result = onIntent(FSMIntent.Refresh) + + "Then intent passes through because no state block for Content" { + result.shouldNotBeNull() + result shouldBe FSMIntent.Refresh + } + } + } + } + } + + "Given an FSM plugin with onState enforcement" - { + val plugin = testFsmPlugin() + + "and same-type state update" - { + plugin.test(FSMState.Content()) { + + "When onState is called with same Content type" - { + val old = FSMState.Content(listOf("old")) + val new = FSMState.Content(listOf("new")) + val result = onState(old, new) + + "Then new state is allowed" { + result shouldBe new + } + } + } + } + + "and external cross-type transition in non-debug mode" - { + plugin.test(FSMState.Loading, configuration = { debuggable = false }) { + + "When onState is called with cross-type transition" - { + val old = FSMState.Loading + val new = FSMState.Content(listOf("data")) + val result = onState(old, new) + + "Then transition is silently vetoed and old state is returned" { + result shouldBe old + } + } + } + } + + "and external cross-type transition in debug mode" - { + plugin.test(FSMState.Loading, configuration = { debuggable = true }) { + + "When onState is called with cross-type transition" - { + "Then InvalidStateException is thrown" { + shouldThrow { + onState(FSMState.Loading, FSMState.Content(listOf("data"))) + } + } + } + } + } + } + + "Given a TransitionsBuilder" - { + + "When duplicate state is defined" - { + "Then IllegalArgumentException is thrown" { + shouldThrow { + transitionsPlugin { + state { + on { } + } + state { + on { } + } + } + } + } + } + + "When duplicate intent handler is defined for same state" - { + "Then IllegalArgumentException is thrown" { + shouldThrow { + transitionsPlugin { + state { + on { } + on { } + } + } + } + } + } + } + + "Given a store with transitions and reduce plugins" - { + + "When a handled intent is sent" - { + "Then transitions plugin consumes it and reduce is not invoked" { + var reduceInvoked = false + val coexistStore = store(FSMState.Loading) { + configure { + debuggable = true + parallelIntents = false + name = "CoexistenceTestStore" + } + transitions { + state { + on { + transitionTo(FSMState.Content(it.items)) + } + } + } + reduce { + reduceInvoked = true + } + } + val lc = coexistStore.start(this) + lc.awaitStartup() + coexistStore.emit(FSMIntent.DataLoaded(listOf("item"))) + idle() + coexistStore.states.value shouldBe FSMState.Content(listOf("item")) + reduceInvoked shouldBe false + lc.closeAndWait() + } + } + + "When an unhandled intent is sent" - { + "Then it falls through to reduce" { + var reduceInvoked = false + val coexistStore = store(FSMState.Loading) { + configure { + debuggable = true + parallelIntents = false + name = "CoexistenceTestStore2" + } + transitions { + state { + on { + transitionTo(FSMState.Content(it.items)) + } + } + } + reduce { + reduceInvoked = true + } + } + val lc = coexistStore.start(this) + lc.awaitStartup() + coexistStore.emit(FSMIntent.Unhandled) + idle() + reduceInvoked shouldBe true + lc.closeAndWait() + } + } + } +}) diff --git a/gradle/gradle-daemon-jvm.properties b/gradle/gradle-daemon-jvm.properties new file mode 100644 index 00000000..89d33134 --- /dev/null +++ b/gradle/gradle-daemon-jvm.properties @@ -0,0 +1,13 @@ +#This file is generated by updateDaemonJvm +toolchainUrl.FREE_BSD.AARCH64=https\://api.foojay.io/disco/v3.0/ids/56a19bc915b9ba2eb62ba7554c61b919/redirect +toolchainUrl.FREE_BSD.X86_64=https\://api.foojay.io/disco/v3.0/ids/ecd23fd7707c683afbcd6052998cb6a9/redirect +toolchainUrl.LINUX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/56a19bc915b9ba2eb62ba7554c61b919/redirect +toolchainUrl.LINUX.X86_64=https\://api.foojay.io/disco/v3.0/ids/ecd23fd7707c683afbcd6052998cb6a9/redirect +toolchainUrl.MAC_OS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/e99bae143b75f9a10ead10248f02055e/redirect +toolchainUrl.MAC_OS.X86_64=https\://api.foojay.io/disco/v3.0/ids/04e088f8677de3b384108493cc9481d0/redirect +toolchainUrl.UNIX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/56a19bc915b9ba2eb62ba7554c61b919/redirect +toolchainUrl.UNIX.X86_64=https\://api.foojay.io/disco/v3.0/ids/ecd23fd7707c683afbcd6052998cb6a9/redirect +toolchainUrl.WINDOWS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/248ffb1098f61659502d0c09aa348294/redirect +toolchainUrl.WINDOWS.X86_64=https\://api.foojay.io/disco/v3.0/ids/932015f6361ccaead0c6d9b8717ed96e/redirect +toolchainVendor=JETBRAINS +toolchainVersion=21 diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index dae2fa6a..78d61364 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -21,12 +21,12 @@ jmh = "1.37" junit = "4.13.2" kotest = "6.1.4" # @pin -kotlin = "2.3.10" +kotlin = "2.3.20" kotlin-benchmark = "0.4.16" kotlin-browser = "0.5.0" kotlin-collections = "0.4.0" kotlin-io = "0.9.0" -kotlinx-atomicfu = "0.31.0" +kotlinx-atomicfu = "0.32.0" ktor = "3.4.0" lifecycle = "2.9.6" maven-publish-plugin = "0.36.0" diff --git a/settings.gradle.kts b/settings.gradle.kts index 82e69450..f081f1f0 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -12,6 +12,9 @@ pluginManagement { mavenCentral() } } +plugins { + id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0" +} dependencyResolutionManagement { // REQUIRED for IDE module configuration to resolve IDE platform repositoriesMode = RepositoriesMode.PREFER_PROJECT diff --git a/skills/flowmvi/SKILL.md b/skills/flowmvi/SKILL.md index a26f4390..de2762f6 100644 --- a/skills/flowmvi/SKILL.md +++ b/skills/flowmvi/SKILL.md @@ -23,6 +23,12 @@ Use `rg` over `references/*signatures*.md` for discovery and open URLs from `ref - Install **Decorators**: wrap the entire plugin chain and can short-circuit it. - **Subscribers** render state and handle actions. +### FSM Transitions + +Use `transitions {}` instead of `reduce {}` for type-safe state machine patterns. +Define handlers per state type with `state { on { transitionTo(NewState) } }`. +Supports child store composition via `compose()`. See `references/plugin-signatures.md` for exact API. + ## Contract and state design ### State diff --git a/skills/flowmvi/references/api-signatures.md b/skills/flowmvi/references/api-signatures.md index 982a57f1..bb047872 100644 --- a/skills/flowmvi/references/api-signatures.md +++ b/skills/flowmvi/references/api-signatures.md @@ -358,3 +358,15 @@ public interface Container : Immutab override val store: Store } ``` + +## TransitionScope (FSM handler context) + +```kotlin +@FlowMVIDSL +public interface TransitionScope : + PipelineContext { + public val state: T + public suspend fun transitionTo(target: S) + public suspend fun transitionTo(target: S, sideEffect: A) +} +``` diff --git a/skills/flowmvi/references/plugin-signatures.md b/skills/flowmvi/references/plugin-signatures.md index 292912d1..f7bdf57f 100644 --- a/skills/flowmvi/references/plugin-signatures.md +++ b/skills/flowmvi/references/plugin-signatures.md @@ -31,6 +31,78 @@ public inline fun reducePlugin( ): StorePlugin ``` +### Transitions (FSM) + +Finite state machine plugin — full replacement for `reduce {}`. Handles intents based on the current state type. Handlers receive `TransitionScope` which extends `PipelineContext` with typed `state` and convenience `transitionTo()`. + +Intents not matched by any FSM handler pass through to downstream plugins. Supports `compose()` for child store composition. + +```kotlin +package pro.respawn.flowmvi.plugins + +public const val TransitionsPluginName: String = "TransitionsPlugin" + +@FlowMVIDSL +public inline fun StoreBuilder.transitions( + name: String = TransitionsPluginName, + @BuilderInference block: TransitionsBuilder.() -> Unit, +): Unit + +@FlowMVIDSL +public inline fun transitionsPlugin( + name: String = TransitionsPluginName, + @BuilderInference block: TransitionsBuilder.() -> Unit, +): StorePlugin +``` + +#### TransitionsBuilder DSL + +```kotlin +package pro.respawn.flowmvi.dsl + +@FlowMVIDSL +public class TransitionsBuilder { + public inline fun state( + @BuilderInference block: StateTransitionsBuilder.() -> Unit, + ) + + @FlowMVIDSL + public fun compose( + store: Store, + merge: S.(childState: CS) -> S, + consume: (suspend PipelineContext.(CA) -> Unit)? = null, + ) +} + +@FlowMVIDSL +public class StateTransitionsBuilder { + public inline fun on( + noinline block: suspend TransitionScope.(E) -> Unit, + ) + + @FlowMVIDSL + public fun compose( + store: Store, + merge: T.(childState: CS) -> S, + consume: (suspend PipelineContext.(CA) -> Unit)? = null, + ) +} +``` + +#### TransitionScope + +```kotlin +package pro.respawn.flowmvi.dsl + +@FlowMVIDSL +public interface TransitionScope : + PipelineContext { + public val state: T + public suspend fun transitionTo(target: S) + public suspend fun transitionTo(target: S, sideEffect: A) +} +``` + ### Init Runs work at store start. Use for startup work that must run before the store processes intents. From ec19730dd848fff91ada38bb54353fefc0ca9feb Mon Sep 17 00:00:00 2001 From: Karol Celebi Date: Sat, 21 Mar 2026 11:31:04 +0100 Subject: [PATCH 3/5] docs: add FSM sample screens implementation plan --- plans/fsm-sample-screens.md | 676 ++++++++++++++++++++++++++++++++++++ 1 file changed, 676 insertions(+) create mode 100644 plans/fsm-sample-screens.md diff --git a/plans/fsm-sample-screens.md b/plans/fsm-sample-screens.md new file mode 100644 index 00000000..901b02bf --- /dev/null +++ b/plans/fsm-sample-screens.md @@ -0,0 +1,676 @@ +# Implementation Plan: FSM Transitions Sample Screens + +## Overview + +Add 3 new sample screens to the FlowMVI sample app that showcase the FSM transitions plugin API. Each screen demonstrates a progressively more advanced usage pattern: + +1. **Transitions** — Basic FSM transitions with an auth flow (replacement for `reduce {}`) +2. **Scoped Compose** — State-scoped child store composition (subscriptions active only in specific parent states) +3. **Top-Level Compose** — Always-active child store composition with a data class parent state + +These screens teach developers how to use `transitions {}`, `state`, `on`, `transitionTo`, and `compose()` — the key FSM APIs in FlowMVI. + +## Requirements + +### Validated from codebase investigation: +- Sample app uses Decompose for navigation, Koin for DI, Compose Multiplatform for UI +- Each feature follows: Models (`*Models.kt`) → Container (`*Container.kt`) → Screen (`*Screen.kt`) +- Features registered in `FeatureModule.kt`, enum in `Destination.kt`, routed in `Destinations.kt` +- Navigation is via `AppNavigator` interface + `AppNavigatorImpl` class +- Home screen links defined in `HomeFeature` enum + title/icon/onClick mappings in `HomeScreen.kt` +- Container pattern: `Container` with `override val store` +- Screens use `container()` for scoped Koin injection +- Screens have Description text, CodeText with code snippet, and interactive UI +- String resources in `values/strings.xml` +- Icons are ImageVector extensions on `Icons` object in `ui/icons/` + +### API availability confirmed: +- `transitions {}` builder with `state {}` and `on {}` — in `TransitionsPlugin.kt` +- `TransitionScope` with `state: T`, `transitionTo()`, full `PipelineContext` delegation — in `TransitionScope.kt` +- Top-level `compose(store, merge, consume)` — in `TransitionsBuilder.kt` +- State-scoped `compose(store, merge, consume)` — in `StateTransitionsBuilder` +- `childStorePlugin` auto-manages child store lifecycle — in `ComposePlugin.kt` + +## Technical Approach + +### Architecture Decisions +- Each screen is a standalone feature package under `pro.respawn.flowmvi.sample.features.*` +- Follow exact Container/Screen patterns from existing features (SST, LCE, Progressive) +- Repositories are simple classes with `delay()` to simulate network calls + `Random` for failures +- Child stores for compose screens are created as properties in the Container class (following Progressive pattern) +- Use `transitions {}` as the sole intent handler (no `reduce {}`) in all 3 screens +- Use `configure(configuration, "StoreName")` for store configuration consistency + +### Icons +- Need 3 new icons. Recommended: + - **Transitions**: `SwapHoriz` (material icon — horizontal swap, represents state transitions) + - **Scoped Compose**: `FilterList` (material icon — filtered/scoped composition) + - **Top-Level Compose**: `Dashboard` (material icon — always-visible dashboard) +- Alternative: reuse existing icons (`AccountTree` for transitions, `Layers` for scoped, `Refresh` for top-level) to avoid adding new icons. Trade-off: less distinct in the menu. +- **Decision**: Add 3 new icon files following the existing ImageVector pattern in `ui/icons/`. + +--- + +## Tasks + +### Phase 1: Shared Infrastructure + +#### Task 1.1: Add Icon Files +- **Description**: Create 3 new Material icon ImageVector files +- **Files to create**: + - `sample/src/commonMain/kotlin/pro/respawn/flowmvi/sample/ui/icons/SwapHoriz.kt` + - `sample/src/commonMain/kotlin/pro/respawn/flowmvi/sample/ui/icons/FilterList.kt` + - `sample/src/commonMain/kotlin/pro/respawn/flowmvi/sample/ui/icons/Dashboard.kt` +- **Pattern**: Follow existing icon files (e.g., `AccountTree.kt`). Each defines an extension property on `Icons` returning an `ImageVector` built via `materialIcon { materialPath { ... } }`. +- **Acceptance criteria**: Icons compile and are accessible via `Icons.SwapHoriz`, `Icons.FilterList`, `Icons.Dashboard` + +#### Task 1.2: Add String Resources +- **Description**: Add string resources for all 3 features +- **Files to modify**: `sample/src/commonMain/composeResources/values/strings.xml` +- **Strings to add**: + ```xml + + FSM Transitions + Log In + Log Out + Retry + Username + Password + Authenticating… + Welcome, %1$s! + Authentication failed: %1$s + + + Scoped Compose + Loading dashboard… + Feed + Notifications + Retry + Refresh + Something went wrong: %1$s + + + Top-Level Compose + Weather + Clock + Refresh Weather + Loading… + ``` +- **Acceptance criteria**: All string resources resolve correctly via `Res.string.*` + +#### Task 1.3: Add Destination Enum Entries +- **Description**: Register the 3 new destinations in the navigation enum +- **Files to modify**: `sample/src/commonMain/kotlin/pro/respawn/flowmvi/sample/navigation/destination/Destination.kt` +- **Code**: Add before `Info`: + ```kotlin + Transitions("transitions"), + ScopedCompose("scopedcompose"), + TopLevelCompose("toplevelcompose"), + ``` +- **Acceptance criteria**: Enum entries exist with correct route strings + +#### Task 1.4: Add AppNavigator Methods +- **Description**: Add navigation methods to the navigator interface and implementation +- **Files to modify**: + - `sample/src/commonMain/kotlin/pro/respawn/flowmvi/sample/navigation/AppNavigator.kt` — add 3 interface methods + - `sample/src/commonMain/kotlin/pro/respawn/flowmvi/sample/navigation/AppNavigatorImpl.kt` — implement 3 methods +- **Code** (interface): + ```kotlin + fun transitionsFeature() + fun scopedComposeFeature() + fun topLevelComposeFeature() + ``` +- **Code** (implementation): + ```kotlin + override fun transitionsFeature() = navigate(Destination.Transitions) + override fun scopedComposeFeature() = navigate(Destination.ScopedCompose) + override fun topLevelComposeFeature() = navigate(Destination.TopLevelCompose) + ``` +- **Acceptance criteria**: Navigator compiles, methods navigate to correct destinations + +#### Task 1.5: Add HomeFeature Entries +- **Description**: Register the 3 features in the home screen menu +- **Files to modify**: + - `sample/src/commonMain/kotlin/pro/respawn/flowmvi/sample/features/home/HomeModels.kt` — add enum entries to `HomeFeature` + - `sample/src/commonMain/kotlin/pro/respawn/flowmvi/sample/features/home/HomeScreen.kt` — add title/icon/onClick mappings +- **Code** (HomeModels.kt — add to `HomeFeature` enum): + ```kotlin + Transitions, ScopedCompose, TopLevelCompose + ``` + Add before `XmlViews` (so platform-specific features stay at the end). +- **Code** (HomeScreen.kt — add imports and mappings): + ```kotlin + // In GoToFeature action handler: + HomeFeature.Transitions -> navigator.transitionsFeature() + HomeFeature.ScopedCompose -> navigator.scopedComposeFeature() + HomeFeature.TopLevelCompose -> navigator.topLevelComposeFeature() + + // In title mapping: + Transitions -> Res.string.transitions_feature_title + ScopedCompose -> Res.string.scoped_compose_feature_title + TopLevelCompose -> Res.string.toplevel_compose_feature_title + + // In icon mapping: + Transitions -> Icons.SwapHoriz + ScopedCompose -> Icons.FilterList + TopLevelCompose -> Icons.Dashboard + ``` +- **Acceptance criteria**: 3 new items appear in the home screen menu with correct titles and icons + +#### Task 1.6: Add Routing in Destinations.kt +- **Description**: Route destination enum entries to screen composables +- **Files to modify**: `sample/src/commonMain/kotlin/pro/respawn/flowmvi/sample/navigation/destination/Destinations.kt` +- **Code** (add to `when` block): + ```kotlin + Destination.Transitions -> TransitionsScreen(navigator) + Destination.ScopedCompose -> ScopedComposeScreen(navigator) + Destination.TopLevelCompose -> TopLevelComposeScreen(navigator) + ``` +- **Note**: Imports for the screen composables will be added once the screen files are created +- **Acceptance criteria**: Navigation routes compile and display the correct screens + +--- + +### Phase 2: Screen 1 — Transitions (Auth Flow) + +#### Task 2.1: Create AuthRepository +- **Description**: Fake authentication repository with simulated delays and random failures +- **File to create**: `sample/src/commonMain/kotlin/pro/respawn/flowmvi/sample/features/transitions/AuthRepository.kt` +- **Package**: `pro.respawn.flowmvi.sample.features.transitions` +- **Key code**: + ```kotlin + internal class AuthRepository { + suspend fun authenticate(username: String, password: String): Result { + delay(2.seconds) + return if (Random.nextFloat() < 0.3f) { + Result.failure(IllegalStateException("Invalid credentials")) + } else { + Result.success(username) + } + } + } + ``` +- **Acceptance criteria**: `authenticate()` returns success ~70% of the time after 2s delay + +#### Task 2.2: Create TransitionsModels.kt +- **Description**: Define State, Intent, and Action sealed interfaces for the auth flow +- **File to create**: `sample/src/commonMain/kotlin/pro/respawn/flowmvi/sample/features/transitions/TransitionsModels.kt` +- **Package**: `pro.respawn.flowmvi.sample.features.transitions` +- **Key code**: + ```kotlin + internal sealed interface TransitionsState : MVIState { + data class Login(val username: String = "", val password: String = "") : TransitionsState + data object Authenticating : TransitionsState + data class Authenticated(val username: String) : TransitionsState + data class Error(val message: String) : TransitionsState + } + + internal sealed interface TransitionsIntent : MVIIntent { + data class UpdateUsername(val value: String) : TransitionsIntent + data class UpdatePassword(val value: String) : TransitionsIntent + data object ClickedLogin : TransitionsIntent + data object ClickedRetry : TransitionsIntent + data object ClickedLogout : TransitionsIntent + } + + internal sealed interface TransitionsAction : MVIAction { + data class ShowError(val message: String) : TransitionsAction + } + ``` +- **Acceptance criteria**: All model types compile, `@Immutable` annotated on sealed interfaces + +#### Task 2.3: Create TransitionsContainer.kt +- **Description**: Container using `transitions {}` as a full replacement for `reduce {}` +- **File to create**: `sample/src/commonMain/kotlin/pro/respawn/flowmvi/sample/features/transitions/TransitionsContainer.kt` +- **Package**: `pro.respawn.flowmvi.sample.features.transitions` +- **Key code**: + ```kotlin + internal class TransitionsContainer( + private val repo: AuthRepository, + configuration: ConfigurationFactory, + ) : Container { + + override val store = store(TransitionsState.Login()) { + configure(configuration, "TransitionsStore") + + transitions { + state { + on { + transitionTo(state.copy(username = it.value)) + } + on { + transitionTo(state.copy(password = it.value)) + } + on { + val (username, password) = state + transitionTo(TransitionsState.Authenticating) + launch { + repo.authenticate(username, password) + .onSuccess { transitionTo(TransitionsState.Authenticated(it)) } + .onFailure { + action(TransitionsAction.ShowError(it.message ?: "Unknown error")) + transitionTo(TransitionsState.Error(it.message ?: "Unknown error")) + } + } + } + } + state { + on { + transitionTo(TransitionsState.Login()) + } + } + state { + on { + transitionTo(TransitionsState.Login()) + } + } + } + } + } + ``` +- **Showcases**: `transitions {}` replacing `reduce {}`, typed `state`, `transitionTo`, `launch {}`, `action()`, state-typed `on<>` handlers +- **Note**: `transitionTo` inside `launch {}` — the handler runs inside the FSM depth tracker's scope. The `launch {}` exits the tracker, but `updateState` (called by `transitionTo`) is unconstrained inside child coroutines because the `launch {}` is dispatched from within a handler. Actually, the `transitionTo` call in the launched coroutine will trigger `onState` enforcement. Since it's outside the handler depth tracker, it will be vetoed. **Correction**: The `launch {}` approach needs to use `updateState` directly rather than `transitionTo` from within a child coroutine, since the depth tracker won't cover it. Use `updateState { TransitionsState.Authenticated(it) }` inside launch instead. Alternatively, restructure so the async work completes and then re-emits an intent: + ```kotlin + on { + val (username, password) = state + transitionTo(TransitionsState.Authenticating) + launch { + repo.authenticate(username, password) + .onSuccess { intent(TransitionsIntent.AuthSucceeded(it)) } // re-emit + .onFailure { intent(TransitionsIntent.AuthFailed(it.message ?: "Unknown error")) } + } + } + ``` + Then define handlers in `state`: + ```kotlin + state { + on { + transitionTo(TransitionsState.Authenticated(it.username)) + } + on { + action(TransitionsAction.ShowError(it.message)) + transitionTo(TransitionsState.Error(it.message)) + } + } + ``` + This is the correct FSM pattern — async results are fed back as intents. Add internal-only intents `AuthSucceeded(username)` and `AuthFailed(message)` to `TransitionsIntent`. +- **Acceptance criteria**: Container compiles, transitions graph covers Login→Authenticating→Authenticated/Error, async auth via intent re-emission + +#### Task 2.4: Create TransitionsScreen.kt +- **Description**: Compose UI for the auth flow with 4 state views +- **File to create**: `sample/src/commonMain/kotlin/pro/respawn/flowmvi/sample/features/transitions/TransitionsScreen.kt` +- **Package**: `pro.respawn.flowmvi.sample.features.transitions` +- **UI layout per state**: + - `Login` → Username TextField + Password TextField + Login Button + - `Authenticating` → CircularProgressIndicator + "Authenticating…" label + - `Authenticated` → "Welcome, {username}!" + Logout Button + - `Error` → Error message + Retry Button +- **Pattern**: Follow SST screen pattern with `container()`, `subscribe { action -> ... }`, `RScaffold`, `TypeCrossfade` +- **Includes**: Description text, CodeText snippet showing the transitions DSL +- **Acceptance criteria**: Screen renders all 4 states, form input works, actions display snackbar + +--- + +### Phase 3: Screen 2 — Scoped Compose (Dashboard) + +#### Task 3.1: Create ScopedComposeModels.kt +- **Description**: Define parent and child state/intent/action types +- **File to create**: `sample/src/commonMain/kotlin/pro/respawn/flowmvi/sample/features/scopedcompose/ScopedComposeModels.kt` +- **Package**: `pro.respawn.flowmvi.sample.features.scopedcompose` +- **Key code**: + ```kotlin + // Child states (shared by child stores) + internal sealed interface ListState : MVIState { + data object Loading : ListState + data class Loaded(val items: List) : ListState + data class Error(val message: String) : ListState + } + + // Parent state + internal sealed interface ScopedComposeState : MVIState { + data object Loading : ScopedComposeState + data class Content( + val feed: ListState = ListState.Loading, + val notifications: ListState = ListState.Loading, + ) : ScopedComposeState + data class Error(val message: String) : ScopedComposeState + } + + internal sealed interface ScopedComposeIntent : MVIIntent { + data object ClickedRetry : ScopedComposeIntent + data object ClickedRefreshFeed : ScopedComposeIntent + data object ClickedRefreshNotifications : ScopedComposeIntent + // internal intents for data loaded results + data object DataReady : ScopedComposeIntent + data class LoadFailed(val message: String) : ScopedComposeIntent + } + + internal sealed interface ScopedComposeAction : MVIAction { + data class ShowError(val message: String) : ScopedComposeAction + } + + // Child intents + internal sealed interface ListIntent : MVIIntent { + data object Refresh : ListIntent + } + + // Child actions (forwarded to parent) + internal sealed interface ListAction : MVIAction { + data class ShowLoaded(val label: String) : ListAction + } + ``` +- **Acceptance criteria**: All types compile, clean separation between parent and child types + +#### Task 3.2: Create ScopedComposeContainer.kt +- **Description**: Parent container with 2 child stores using state-scoped `compose()` +- **File to create**: `sample/src/commonMain/kotlin/pro/respawn/flowmvi/sample/features/scopedcompose/ScopedComposeContainer.kt` +- **Package**: `pro.respawn.flowmvi.sample.features.scopedcompose` +- **Key code**: + ```kotlin + internal class ScopedComposeContainer( + configuration: ConfigurationFactory, + ) : Container { + + private val feedStore = store(ListState.Loading) { + configure { name = "FeedStore"; debuggable = true; actionShareBehavior = ActionShareBehavior.Distribute() } + init { launch { loadFeed() } } + reduce { intent -> when (intent) { ListIntent.Refresh -> { updateState { ListState.Loading }; launch { loadFeed() } } } } + } + + private val notificationsStore = store(ListState.Loading) { + configure { name = "NotificationsStore"; debuggable = true; actionShareBehavior = ActionShareBehavior.Distribute() } + init { launch { loadNotifications() } } + reduce { intent -> when (intent) { ListIntent.Refresh -> { updateState { ListState.Loading }; launch { loadNotifications() } } } } + } + + override val store = store(ScopedComposeState.Loading) { + configure(configuration, "ScopedComposeStore") + + transitions { + state { + on { + transitionTo(ScopedComposeState.Content()) + } + on { + transitionTo(ScopedComposeState.Error(it.message)) + } + } + state { + // State-scoped compose: active only while in Content + compose(feedStore, merge = { childState -> copy(feed = childState) }) + compose(notificationsStore, merge = { childState -> copy(notifications = childState) }) + + on { + feedStore.intent(ListIntent.Refresh) + } + on { + notificationsStore.intent(ListIntent.Refresh) + } + } + state { + on { + transitionTo(ScopedComposeState.Loading) + } + } + } + + // Simulate initial data loading + init { + launch { + delay(1500) + intent(ScopedComposeIntent.DataReady) // transition to Content after loading + } + } + } + } + ``` +- **Showcases**: State-scoped `compose()` — subscriptions activate when parent enters `Content`, deactivate when leaving. Child store intent routing via `feedStore.intent()`. +- **Acceptance criteria**: Container compiles, child stores only merge into parent while in `Content`, retry resets to Loading + +#### Task 3.3: Create ScopedComposeScreen.kt +- **Description**: Dashboard UI with feed + notifications sections +- **File to create**: `sample/src/commonMain/kotlin/pro/respawn/flowmvi/sample/features/scopedcompose/ScopedComposeScreen.kt` +- **Package**: `pro.respawn.flowmvi.sample.features.scopedcompose` +- **UI layout per state**: + - `Loading` → CircularProgressIndicator + "Loading dashboard…" + - `Content` → Feed section (list or loading) + Notifications section (list or loading) + Refresh buttons + - `Error` → Error message + Retry button +- **Pattern**: Follow existing screen patterns with `TypeCrossfade` +- **Includes**: Description text explaining state-scoped composition, CodeText snippet +- **Acceptance criteria**: Screen renders all states, refresh buttons trigger child store reloads, child subscriptions visually stop when transitioning to Error + +--- + +### Phase 4: Screen 3 — Top-Level Compose (Dashboard) + +#### Task 4.1: Create TopLevelComposeModels.kt +- **Description**: Data class parent state with child state types +- **File to create**: `sample/src/commonMain/kotlin/pro/respawn/flowmvi/sample/features/toplevelcompose/TopLevelComposeModels.kt` +- **Package**: `pro.respawn.flowmvi.sample.features.toplevelcompose` +- **Key code**: + ```kotlin + // Weather child state + internal sealed interface WeatherState : MVIState { + data object Loading : WeatherState + data class Loaded(val temperature: Int, val condition: String) : WeatherState + } + + // Clock child state + internal data class ClockState(val time: String = "--:--:--") : MVIState + + // Parent state — single data class, always-active composition target + internal data class DashboardState( + val weather: WeatherState = WeatherState.Loading, + val clock: ClockState = ClockState(), + ) : MVIState + + internal sealed interface DashboardIntent : MVIIntent { + data object ClickedRefresh : DashboardIntent + } + + internal sealed interface DashboardAction : MVIAction + // No actions needed — pure state-driven UI + ``` +- **Acceptance criteria**: All types compile, `DashboardState` is a data class (not sealed) + +#### Task 4.2: Create TopLevelComposeContainer.kt +- **Description**: Parent container with always-active child stores using top-level `compose()` +- **File to create**: `sample/src/commonMain/kotlin/pro/respawn/flowmvi/sample/features/toplevelcompose/TopLevelComposeContainer.kt` +- **Package**: `pro.respawn.flowmvi.sample.features.toplevelcompose` +- **Key code**: + ```kotlin + internal class TopLevelComposeContainer( + configuration: ConfigurationFactory, + ) : Container { + + // Child intents/actions not exposed to parent + private sealed interface WeatherIntent : MVIIntent { data object Refresh : WeatherIntent } + private sealed interface ClockIntent : MVIIntent + + private val weatherStore = store(WeatherState.Loading) { + configure { name = "WeatherStore"; debuggable = true } + init { launch { loadWeather() } } + reduce { intent -> + when (intent) { + WeatherIntent.Refresh -> { updateState { WeatherState.Loading }; launch { loadWeather() } } + } + } + } + + private val clockStore = store(ClockState()) { + configure { name = "ClockStore"; debuggable = true } + whileSubscribed { + while (true) { + updateState { copy(time = currentTimeFormatted()) } + delay(1.seconds) + } + } + } + + override val store = store(DashboardState()) { + configure(configuration, "TopLevelComposeStore") + + transitions { + // Top-level compose: always active while parent store runs + compose(weatherStore) { copy(weather = it) } + compose(clockStore) { copy(clock = it) } + + state { + on { + weatherStore.intent(WeatherIntent.Refresh) + } + } + } + } + + private suspend fun PipelineContext.loadWeather() { + delay(1500) + val conditions = listOf("Sunny", "Cloudy", "Rainy", "Snowy", "Windy") + updateState { WeatherState.Loaded(Random.nextInt(-10, 35), conditions.random()) } + } + + private fun currentTimeFormatted(): String { /* format current time HH:mm:ss */ } + } + ``` +- **Showcases**: Top-level `compose()` — always active, `copy(weather = it)` merge syntax, single data class state, clock ticking every second +- **Note**: `clockStore` uses `whileSubscribed` to tick. Since it's auto-started as a child, the parent's compose subscription acts as the subscriber that keeps it active. +- **Acceptance criteria**: Container compiles, weather loads with delay, clock ticks every second, refresh reloads weather + +#### Task 4.3: Create TopLevelComposeScreen.kt +- **Description**: Dashboard UI with weather card + live clock +- **File to create**: `sample/src/commonMain/kotlin/pro/respawn/flowmvi/sample/features/toplevelcompose/TopLevelComposeScreen.kt` +- **Package**: `pro.respawn.flowmvi.sample.features.toplevelcompose` +- **UI layout**: + - Weather card: temperature + condition (or loading spinner) + - Clock card: live-updating time string + - Refresh button for weather +- **Pattern**: Single state (`DashboardState` is a data class, no TypeCrossfade needed — use direct composition) +- **Includes**: Description text explaining top-level composition, CodeText snippet +- **Acceptance criteria**: Weather loads after delay, clock ticks in real-time, refresh button reloads weather + +--- + +### Phase 5: Integration & Polish + +#### Task 5.1: Register Containers in FeatureModule.kt +- **Description**: Register all new containers and repositories with Koin +- **Files to modify**: `sample/src/commonMain/kotlin/pro/respawn/flowmvi/sample/features/FeatureModule.kt` +- **Code to add**: + ```kotlin + // Imports + import pro.respawn.flowmvi.sample.features.transitions.AuthRepository + import pro.respawn.flowmvi.sample.features.transitions.TransitionsContainer + import pro.respawn.flowmvi.sample.features.scopedcompose.ScopedComposeContainer + import pro.respawn.flowmvi.sample.features.toplevelcompose.TopLevelComposeContainer + + // In module block: + singleOf(::AuthRepository) + container { new(::TransitionsContainer) } + container { new(::ScopedComposeContainer) } + container { new(::TopLevelComposeContainer) } + ``` +- **Acceptance criteria**: All containers resolve via Koin scoped injection + +#### Task 5.2: Run detektFormat +- **Description**: Run `./gradlew detektFormat` and fix any remaining lint issues +- **Acceptance criteria**: detektFormat passes with no errors + +#### Task 5.3: Compile Sample App +- **Description**: Run `./gradlew :sample:assemble` (or platform-specific task) to verify compilation +- **Acceptance criteria**: Sample module compiles without errors + +#### Task 5.4: Manual Testing +- **Description**: Run the sample app and verify: + - All 3 features appear in the home screen menu + - Navigation to each feature works + - Back navigation works + - **Transitions**: Login form → authenticating spinner → success/error → retry/logout cycle works + - **Scoped Compose**: Loading → content with feed + notifications → error → retry cycle works + - **Top-Level Compose**: Weather loads, clock ticks, refresh button works +- **Acceptance criteria**: All features functional on at least one platform (desktop/wasm/android) + +--- + +## Task Dependencies + +``` +Phase 1 (Infrastructure) +├── Task 1.1 (Icons) ─── no deps +├── Task 1.2 (Strings) ─── no deps +├── Task 1.3 (Destinations) ─── no deps +├── Task 1.4 (Navigator) ─── depends on 1.3 +├── Task 1.5 (HomeFeature) ─── depends on 1.1, 1.2 +└── Task 1.6 (Routing) ─── depends on 1.3, Phase 2-4 screens + +Phase 2 (Transitions Screen) +├── Task 2.1 (AuthRepository) ─── no deps +├── Task 2.2 (Models) ─── no deps +├── Task 2.3 (Container) ─── depends on 2.1, 2.2 +└── Task 2.4 (Screen) ─── depends on 2.2, 2.3 + +Phase 3 (Scoped Compose Screen) +├── Task 3.1 (Models) ─── no deps +├── Task 3.2 (Container) ─── depends on 3.1 +└── Task 3.3 (Screen) ─── depends on 3.1, 3.2 + +Phase 4 (Top-Level Compose Screen) +├── Task 4.1 (Models) ─── no deps +├── Task 4.2 (Container) ─── depends on 4.1 +└── Task 4.3 (Screen) ─── depends on 4.1, 4.2 + +Phase 5 (Integration & Polish) +├── Task 5.1 (FeatureModule) ─── depends on 2.3, 3.2, 4.2 +├── Task 5.2 (detektFormat) ─── depends on all above +├── Task 5.3 (Compile) ─── depends on 5.2 +└── Task 5.4 (Manual Testing) ─── depends on 5.3 +``` + +**Parallelizable**: Phases 2, 3, 4 are independent and can be implemented in parallel. Phase 1 tasks 1.1–1.3 are independent. Task 1.6 (routing) should be done last after all screens exist. + +--- + +## Risks & Mitigations + +| Risk | Impact | Mitigation | +|------|--------|------------| +| `transitionTo` inside `launch {}` is vetoed by FSM enforcement | High — auth flow broken | Use intent re-emission pattern: async result → `intent(AuthSucceeded)` → handler calls `transitionTo`. This is the correct FSM pattern. | +| Child store lifecycle in compose screens — child stores need to be started | Medium — child stores silently do nothing | `childStorePlugin` (part of `transitions {}` composite) auto-starts child stores. Verified in `ComposePlugin.kt`. | +| `whileSubscribed` in clockStore may not trigger without explicit subscriber | Medium — clock doesn't tick | The compose subscription from the parent acts as a subscriber. If this doesn't work, use `init { launch { ... } }` with manual flow collection instead. | +| Icon ImageVector paths are complex to create manually | Low — icons don't render | Copy structure from existing icons (e.g., `AccountTree.kt`), use Material Icons reference for path data. | +| New string resources may not be generated until Gradle sync | Low — compile error on `Res.string.*` | Run Gradle sync after adding strings. Compose resources plugin generates accessors automatically. | +| `ScopedComposeContainer` child stores use `store()` (eager) vs `lazyStore()` — eager stores aren't started at construction | Low — child stores not running | `childStorePlugin` handles starting. The stores are passed to `compose()` which registers them in the child plugin. | + +--- + +## Files Summary + +### Files to Create (12) +| File | Phase | +|------|-------| +| `sample/.../ui/icons/SwapHoriz.kt` | 1 | +| `sample/.../ui/icons/FilterList.kt` | 1 | +| `sample/.../ui/icons/Dashboard.kt` | 1 | +| `sample/.../features/transitions/AuthRepository.kt` | 2 | +| `sample/.../features/transitions/TransitionsModels.kt` | 2 | +| `sample/.../features/transitions/TransitionsContainer.kt` | 2 | +| `sample/.../features/transitions/TransitionsScreen.kt` | 2 | +| `sample/.../features/scopedcompose/ScopedComposeModels.kt` | 3 | +| `sample/.../features/scopedcompose/ScopedComposeContainer.kt` | 3 | +| `sample/.../features/scopedcompose/ScopedComposeScreen.kt` | 3 | +| `sample/.../features/toplevelcompose/TopLevelComposeModels.kt` | 4 | +| `sample/.../features/toplevelcompose/TopLevelComposeContainer.kt` | 4 | +| `sample/.../features/toplevelcompose/TopLevelComposeScreen.kt` | 4 | + +### Files to Modify (7) +| File | Phase | +|------|-------| +| `sample/.../composeResources/values/strings.xml` | 1 | +| `sample/.../navigation/destination/Destination.kt` | 1 | +| `sample/.../navigation/AppNavigator.kt` | 1 | +| `sample/.../navigation/AppNavigatorImpl.kt` | 1 | +| `sample/.../features/home/HomeModels.kt` | 1 | +| `sample/.../features/home/HomeScreen.kt` | 1 | +| `sample/.../navigation/destination/Destinations.kt` | 1 | +| `sample/.../features/FeatureModule.kt` | 5 | + +*All paths relative to `sample/src/commonMain/kotlin/pro/respawn/flowmvi/sample/`* From 8ec3ace15bd3ed8b36baaeca1cbda6124246181c Mon Sep 17 00:00:00 2001 From: Karol Celebi Date: Sat, 21 Mar 2026 12:37:40 +0100 Subject: [PATCH 4/5] feat: add FSM transitions sample screens (auth, scoped compose, top-level compose) --- .../composeResources/values/strings.xml | 24 ++ .../flowmvi/sample/features/FeatureModule.kt | 8 + .../sample/features/home/HomeModels.kt | 15 +- .../sample/features/home/HomeScreen.kt | 18 ++ .../scopedcompose/ScopedComposeContainer.kt | 129 +++++++++++ .../scopedcompose/ScopedComposeModels.kt | 59 +++++ .../scopedcompose/ScopedComposeScreen.kt | 205 ++++++++++++++++++ .../TopLevelComposeContainer.kt | 98 +++++++++ .../toplevelcompose/TopLevelComposeModels.kt | 32 +++ .../toplevelcompose/TopLevelComposeScreen.kt | 152 +++++++++++++ .../features/transitions/AuthRepository.kt | 19 ++ .../transitions/TransitionsContainer.kt | 57 +++++ .../features/transitions/TransitionsModels.kt | 35 +++ .../features/transitions/TransitionsScreen.kt | 185 ++++++++++++++++ .../flowmvi/sample/navigation/AppNavigator.kt | 3 + .../sample/navigation/AppNavigatorImpl.kt | 3 + .../navigation/destination/Destination.kt | 3 + .../navigation/destination/Destinations.kt | 6 + .../flowmvi/sample/ui/icons/Dashboard.kt | 77 +++++++ .../flowmvi/sample/ui/icons/FilterList.kt | 47 ++++ .../flowmvi/sample/ui/icons/SwapHoriz.kt | 51 +++++ 21 files changed, 1225 insertions(+), 1 deletion(-) create mode 100644 sample/src/commonMain/kotlin/pro/respawn/flowmvi/sample/features/scopedcompose/ScopedComposeContainer.kt create mode 100644 sample/src/commonMain/kotlin/pro/respawn/flowmvi/sample/features/scopedcompose/ScopedComposeModels.kt create mode 100644 sample/src/commonMain/kotlin/pro/respawn/flowmvi/sample/features/scopedcompose/ScopedComposeScreen.kt create mode 100644 sample/src/commonMain/kotlin/pro/respawn/flowmvi/sample/features/toplevelcompose/TopLevelComposeContainer.kt create mode 100644 sample/src/commonMain/kotlin/pro/respawn/flowmvi/sample/features/toplevelcompose/TopLevelComposeModels.kt create mode 100644 sample/src/commonMain/kotlin/pro/respawn/flowmvi/sample/features/toplevelcompose/TopLevelComposeScreen.kt create mode 100644 sample/src/commonMain/kotlin/pro/respawn/flowmvi/sample/features/transitions/AuthRepository.kt create mode 100644 sample/src/commonMain/kotlin/pro/respawn/flowmvi/sample/features/transitions/TransitionsContainer.kt create mode 100644 sample/src/commonMain/kotlin/pro/respawn/flowmvi/sample/features/transitions/TransitionsModels.kt create mode 100644 sample/src/commonMain/kotlin/pro/respawn/flowmvi/sample/features/transitions/TransitionsScreen.kt create mode 100644 sample/src/commonMain/kotlin/pro/respawn/flowmvi/sample/ui/icons/Dashboard.kt create mode 100644 sample/src/commonMain/kotlin/pro/respawn/flowmvi/sample/ui/icons/FilterList.kt create mode 100644 sample/src/commonMain/kotlin/pro/respawn/flowmvi/sample/ui/icons/SwapHoriz.kt diff --git a/sample/src/commonMain/composeResources/values/strings.xml b/sample/src/commonMain/composeResources/values/strings.xml index 74b55628..4c02a83e 100644 --- a/sample/src/commonMain/composeResources/values/strings.xml +++ b/sample/src/commonMain/composeResources/values/strings.xml @@ -28,4 +28,28 @@ Payment succeeded Invoice expired Start over + + FSM Transitions + Log In + Log Out + Retry + Username + Password + Authenticating… + Welcome, %1$s! + Authentication failed: %1$s + + Scoped Compose + Loading dashboard… + Feed + Notifications + Retry + Refresh + Something went wrong: %1$s + + Top-Level Compose + Weather + Clock + Refresh Weather + Loading… diff --git a/sample/src/commonMain/kotlin/pro/respawn/flowmvi/sample/features/FeatureModule.kt b/sample/src/commonMain/kotlin/pro/respawn/flowmvi/sample/features/FeatureModule.kt index 9670486e..fcaac76a 100644 --- a/sample/src/commonMain/kotlin/pro/respawn/flowmvi/sample/features/FeatureModule.kt +++ b/sample/src/commonMain/kotlin/pro/respawn/flowmvi/sample/features/FeatureModule.kt @@ -14,14 +14,19 @@ import pro.respawn.flowmvi.sample.features.logging.LoggingContainer import pro.respawn.flowmvi.sample.features.progressive.ProgressiveContainer import pro.respawn.flowmvi.sample.features.progressive.ProgressiveRepository import pro.respawn.flowmvi.sample.features.savedstate.SavedStateContainer +import pro.respawn.flowmvi.sample.features.scopedcompose.ScopedComposeContainer import pro.respawn.flowmvi.sample.features.sst.PurchaseRepository import pro.respawn.flowmvi.sample.features.sst.SSTContainer +import pro.respawn.flowmvi.sample.features.toplevelcompose.TopLevelComposeContainer +import pro.respawn.flowmvi.sample.features.transitions.AuthRepository +import pro.respawn.flowmvi.sample.features.transitions.TransitionsContainer import pro.respawn.flowmvi.sample.features.undoredo.UndoRedoContainer val featureModule = module { singleOf(::LCERepository) singleOf(::ProgressiveRepository) singleOf(::PurchaseRepository) // for SST example + singleOf(::AuthRepository) container { new(::HomeContainer) } container { new(::ProgressiveContainer) } @@ -31,6 +36,9 @@ val featureModule = module { container { new(::LoggingContainer) } container { new(::UndoRedoContainer) } container { new(::SSTContainer) } + container { new(::TransitionsContainer) } + container { new(::ScopedComposeContainer) } + container { new(::TopLevelComposeContainer) } // decompose doesn't need to use scoped dsl factoryOf(::PagesContainer) diff --git a/sample/src/commonMain/kotlin/pro/respawn/flowmvi/sample/features/home/HomeModels.kt b/sample/src/commonMain/kotlin/pro/respawn/flowmvi/sample/features/home/HomeModels.kt index 9c0b3877..c02c1535 100644 --- a/sample/src/commonMain/kotlin/pro/respawn/flowmvi/sample/features/home/HomeModels.kt +++ b/sample/src/commonMain/kotlin/pro/respawn/flowmvi/sample/features/home/HomeModels.kt @@ -8,7 +8,20 @@ import pro.respawn.flowmvi.sample.util.Platform import kotlin.jvm.JvmInline enum class HomeFeature(val platform: Platform? = null) { - Simple, LCE, SavedState, DiConfig, Progressive, Logging, SST, UndoRedo, Decompose, XmlViews(Platform.Android) + + Simple, + LCE, + SavedState, + DiConfig, + Progressive, + Logging, + SST, + UndoRedo, + Decompose, + Transitions, + ScopedCompose, + TopLevelCompose, + XmlViews(Platform.Android), } @Immutable diff --git a/sample/src/commonMain/kotlin/pro/respawn/flowmvi/sample/features/home/HomeScreen.kt b/sample/src/commonMain/kotlin/pro/respawn/flowmvi/sample/features/home/HomeScreen.kt index acb04bbe..336aaae5 100644 --- a/sample/src/commonMain/kotlin/pro/respawn/flowmvi/sample/features/home/HomeScreen.kt +++ b/sample/src/commonMain/kotlin/pro/respawn/flowmvi/sample/features/home/HomeScreen.kt @@ -41,7 +41,10 @@ import pro.respawn.flowmvi.sample.features.home.HomeFeature.Logging import pro.respawn.flowmvi.sample.features.home.HomeFeature.Progressive import pro.respawn.flowmvi.sample.features.home.HomeFeature.SST import pro.respawn.flowmvi.sample.features.home.HomeFeature.SavedState +import pro.respawn.flowmvi.sample.features.home.HomeFeature.ScopedCompose import pro.respawn.flowmvi.sample.features.home.HomeFeature.Simple +import pro.respawn.flowmvi.sample.features.home.HomeFeature.TopLevelCompose +import pro.respawn.flowmvi.sample.features.home.HomeFeature.Transitions import pro.respawn.flowmvi.sample.features.home.HomeFeature.UndoRedo import pro.respawn.flowmvi.sample.features.home.HomeFeature.XmlViews import pro.respawn.flowmvi.sample.features.home.HomeIntent.ClickedFeature @@ -54,11 +57,16 @@ import pro.respawn.flowmvi.sample.navigation.util.backNavigator import pro.respawn.flowmvi.sample.platform_feature_unavailable_label import pro.respawn.flowmvi.sample.progressive_feature_title import pro.respawn.flowmvi.sample.savedstate_feature_title +import pro.respawn.flowmvi.sample.scoped_compose_feature_title import pro.respawn.flowmvi.sample.simple_feature_title import pro.respawn.flowmvi.sample.sst_feature_title +import pro.respawn.flowmvi.sample.toplevel_compose_feature_title +import pro.respawn.flowmvi.sample.transitions_feature_title import pro.respawn.flowmvi.sample.ui.icons.AccountTree import pro.respawn.flowmvi.sample.ui.icons.Code +import pro.respawn.flowmvi.sample.ui.icons.Dashboard import pro.respawn.flowmvi.sample.ui.icons.Download +import pro.respawn.flowmvi.sample.ui.icons.FilterList import pro.respawn.flowmvi.sample.ui.icons.Help import pro.respawn.flowmvi.sample.ui.icons.Icons import pro.respawn.flowmvi.sample.ui.icons.Info @@ -66,6 +74,7 @@ import pro.respawn.flowmvi.sample.ui.icons.Layers import pro.respawn.flowmvi.sample.ui.icons.Refresh import pro.respawn.flowmvi.sample.ui.icons.Save import pro.respawn.flowmvi.sample.ui.icons.Subject +import pro.respawn.flowmvi.sample.ui.icons.SwapHoriz import pro.respawn.flowmvi.sample.ui.icons.SyncLock import pro.respawn.flowmvi.sample.ui.icons.Undo import pro.respawn.flowmvi.sample.ui.theme.rainbow @@ -99,6 +108,9 @@ fun HomeScreen( Decompose -> navigator.decomposeFeature() Progressive -> navigator.progressiveFeature() SST -> navigator.stateTransactionsFeature() + Transitions -> navigator.transitionsFeature() + ScopedCompose -> navigator.scopedComposeFeature() + TopLevelCompose -> navigator.topLevelComposeFeature() } } } @@ -173,6 +185,9 @@ private val HomeFeature.title Decompose -> Res.string.decompose_feature_title Progressive -> Res.string.progressive_feature_title SST -> Res.string.sst_feature_title + Transitions -> Res.string.transitions_feature_title + ScopedCompose -> Res.string.scoped_compose_feature_title + TopLevelCompose -> Res.string.toplevel_compose_feature_title } private val HomeFeature.icon @@ -187,6 +202,9 @@ private val HomeFeature.icon Decompose -> Icons.AccountTree Progressive -> Icons.Layers SST -> Icons.SyncLock + Transitions -> Icons.SwapHoriz + ScopedCompose -> Icons.FilterList + TopLevelCompose -> Icons.Dashboard } private val HomeFeature.enabled get() = platform == null || BuildFlags.platform == platform diff --git a/sample/src/commonMain/kotlin/pro/respawn/flowmvi/sample/features/scopedcompose/ScopedComposeContainer.kt b/sample/src/commonMain/kotlin/pro/respawn/flowmvi/sample/features/scopedcompose/ScopedComposeContainer.kt new file mode 100644 index 00000000..e973582c --- /dev/null +++ b/sample/src/commonMain/kotlin/pro/respawn/flowmvi/sample/features/scopedcompose/ScopedComposeContainer.kt @@ -0,0 +1,129 @@ +package pro.respawn.flowmvi.sample.features.scopedcompose + +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import pro.respawn.flowmvi.api.ActionShareBehavior +import pro.respawn.flowmvi.api.Container +import pro.respawn.flowmvi.api.Store +import pro.respawn.flowmvi.dsl.store +import pro.respawn.flowmvi.plugins.init +import pro.respawn.flowmvi.plugins.reduce +import pro.respawn.flowmvi.plugins.transitions +import pro.respawn.flowmvi.sample.arch.configuration.ConfigurationFactory +import pro.respawn.flowmvi.sample.arch.configuration.configure +import kotlin.random.Random +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.seconds + +private const val ErrorChance = 0.15f +private const val ItemCount = 10 +private val LoadDelay = 1500.milliseconds + +internal class ScopedComposeContainer( + configuration: ConfigurationFactory, +) : Container { + + private val feedStore: Store = + store(ListState.Loading) { + configure { + name = "FeedStore" + debuggable = true + actionShareBehavior = ActionShareBehavior.Distribute() + } + init { + launch { updateState { loadItems("Feed") } } + } + reduce { intent -> + when (intent) { + ListIntent.Refresh -> { + updateState { ListState.Loading } + launch { updateState { loadItems("Feed") } } + } + } + } + } + + private val notificationsStore: Store = + store(ListState.Loading) { + configure { + name = "NotificationsStore" + debuggable = true + actionShareBehavior = ActionShareBehavior.Distribute() + } + init { + launch { updateState { loadItems("Notification") } } + } + reduce { intent -> + when (intent) { + ListIntent.Refresh -> { + updateState { ListState.Loading } + launch { updateState { loadItems("Notification") } } + } + } + } + } + + override val store: Store = + store(ScopedComposeState.Loading) { + configure(configuration, "ScopedComposeStore") + + transitions { + state { + on { + transitionTo(ScopedComposeState.Content()) + } + on { + transitionTo(ScopedComposeState.Error(it.message)) + } + } + state { + // State-scoped compose: active only while in Content + compose( + feedStore, + merge = { childState -> copy(feed = childState) }, + ) + compose( + notificationsStore, + merge = { childState -> copy(notifications = childState) }, + ) + + on { + feedStore.intent(ListIntent.Refresh) + } + on { + notificationsStore.intent(ListIntent.Refresh) + } + on { + feedStore.intent(ListIntent.Refresh) + notificationsStore.intent(ListIntent.Refresh) + } + } + state { + on { + transitionTo(ScopedComposeState.Loading) + launch { + delay(LoadDelay) + intent(ScopedComposeIntent.DataReady) + } + } + } + } + + // Simulate initial data loading + init { + launch { + delay(LoadDelay) + intent(ScopedComposeIntent.DataReady) + } + } + } + + private suspend fun loadItems(prefix: String): ListState { + delay(1.seconds) + return if (Random.nextFloat() < ErrorChance) { + ListState.Error("Failed to load $prefix") + } else { + ListState.Loaded(List(ItemCount) { "$prefix item ${it + 1}" }) + } + } +} diff --git a/sample/src/commonMain/kotlin/pro/respawn/flowmvi/sample/features/scopedcompose/ScopedComposeModels.kt b/sample/src/commonMain/kotlin/pro/respawn/flowmvi/sample/features/scopedcompose/ScopedComposeModels.kt new file mode 100644 index 00000000..19757c71 --- /dev/null +++ b/sample/src/commonMain/kotlin/pro/respawn/flowmvi/sample/features/scopedcompose/ScopedComposeModels.kt @@ -0,0 +1,59 @@ +package pro.respawn.flowmvi.sample.features.scopedcompose + +import androidx.compose.runtime.Immutable +import pro.respawn.flowmvi.api.MVIAction +import pro.respawn.flowmvi.api.MVIIntent +import pro.respawn.flowmvi.api.MVIState + +// Child list state (shared by feed and notification child stores) +@Immutable +internal sealed interface ListState : MVIState { + + data object Loading : ListState + data class Loaded(val items: List) : ListState + data class Error(val message: String) : ListState +} + +// Child intents +@Immutable +internal sealed interface ListIntent : MVIIntent { + + data object Refresh : ListIntent +} + +// Child actions +@Immutable +internal sealed interface ListAction : MVIAction + +// Parent state +@Immutable +internal sealed interface ScopedComposeState : MVIState { + + data object Loading : ScopedComposeState + + data class Content( + val feed: ListState = ListState.Loading, + val notifications: ListState = ListState.Loading, + ) : ScopedComposeState + + data class Error(val message: String) : ScopedComposeState +} + +@Immutable +internal sealed interface ScopedComposeIntent : MVIIntent { + + data object ClickedRetry : ScopedComposeIntent + data object ClickedRefreshFeed : ScopedComposeIntent + data object ClickedRefreshNotifications : ScopedComposeIntent + data object ClickedRefreshAll : ScopedComposeIntent + + // Internal intent for initial load completion + data object DataReady : ScopedComposeIntent + data class LoadFailed(val message: String) : ScopedComposeIntent +} + +@Immutable +internal sealed interface ScopedComposeAction : MVIAction { + + data class ShowError(val message: String) : ScopedComposeAction +} diff --git a/sample/src/commonMain/kotlin/pro/respawn/flowmvi/sample/features/scopedcompose/ScopedComposeScreen.kt b/sample/src/commonMain/kotlin/pro/respawn/flowmvi/sample/features/scopedcompose/ScopedComposeScreen.kt new file mode 100644 index 00000000..a6284c77 --- /dev/null +++ b/sample/src/commonMain/kotlin/pro/respawn/flowmvi/sample/features/scopedcompose/ScopedComposeScreen.kt @@ -0,0 +1,205 @@ +@file:OptIn(ExperimentalMaterial3Api::class) + +package pro.respawn.flowmvi.sample.features.scopedcompose + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import pro.respawn.flowmvi.api.IntentReceiver +import pro.respawn.flowmvi.compose.dsl.subscribe +import pro.respawn.flowmvi.sample.Res +import pro.respawn.flowmvi.sample.arch.di.container +import pro.respawn.flowmvi.sample.navigation.AppNavigator +import pro.respawn.flowmvi.sample.navigation.util.backNavigator +import pro.respawn.flowmvi.sample.scoped_compose_error_label +import pro.respawn.flowmvi.sample.scoped_compose_feature_title +import pro.respawn.flowmvi.sample.scoped_compose_feed_title +import pro.respawn.flowmvi.sample.scoped_compose_loading_label +import pro.respawn.flowmvi.sample.scoped_compose_notifications_title +import pro.respawn.flowmvi.sample.scoped_compose_refresh_button +import pro.respawn.flowmvi.sample.scoped_compose_retry_button +import pro.respawn.flowmvi.sample.ui.widgets.CodeText +import pro.respawn.flowmvi.sample.ui.widgets.RScaffold +import pro.respawn.flowmvi.sample.ui.widgets.TypeCrossfade +import pro.respawn.flowmvi.sample.util.formatAsMultiline +import pro.respawn.kmmutils.compose.resources.string + +private const val Description = """ + This screen demonstrates state-scoped composition using the transitions plugin. + \n\n + Two child stores (Feed & Notifications) are composed into the parent only while + the parent is in the Content state. When the parent transitions to Error, + the child subscriptions are automatically cancelled. + \n\n + Each child store loads data independently and can be refreshed individually. +""" + +//language=kotlin +private const val Code = """ +transitions { + state { + compose(feedStore, merge = { child -> + copy(feed = child) + }) + compose(notificationsStore, merge = { child -> + copy(notifications = child) + }) + on { + feedStore.intent(ListIntent.Refresh) + } + } +} +""" + +@Composable +internal fun ScopedComposeScreen( + navigator: AppNavigator, +) = with(container()) { + val state by subscribe() + + RScaffold( + onBack = navigator.backNavigator, + title = Res.string.scoped_compose_feature_title.string(), + ) { + ScopedComposeScreenContent(state) + } +} + +@Composable +private fun IntentReceiver.ScopedComposeScreenContent( + state: ScopedComposeState, +) = TypeCrossfade(state) { + when (this) { + is ScopedComposeState.Loading -> LoadingContent() + is ScopedComposeState.Content -> DashboardContent(this) + is ScopedComposeState.Error -> ErrorContent(this) + } +} + +@Composable +private fun LoadingContent() = Column( + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, +) { + CircularProgressIndicator() + Spacer(Modifier.height(16.dp)) + Text( + text = Res.string.scoped_compose_loading_label.string(), + style = MaterialTheme.typography.titleMedium, + ) +} + +@Composable +private fun IntentReceiver.DashboardContent( + state: ScopedComposeState.Content, +) = Column( + modifier = Modifier + .fillMaxHeight() + .fillMaxWidth() + .padding(horizontal = 12.dp) + .verticalScroll(rememberScrollState()), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Top, +) { + Text(Description.formatAsMultiline(), modifier = Modifier.widthIn(max = 600.dp)) + Spacer(Modifier.height(12.dp)) + CodeText(Code) + Spacer(Modifier.height(24.dp)) + + Button(onClick = { intent(ScopedComposeIntent.ClickedRefreshAll) }) { + Text(Res.string.scoped_compose_refresh_button.string()) + } + + Spacer(Modifier.height(12.dp)) + + // Feed section + ListSection( + title = Res.string.scoped_compose_feed_title.string(), + listState = state.feed, + onRefresh = { intent(ScopedComposeIntent.ClickedRefreshFeed) }, + ) + + Spacer(Modifier.height(24.dp)) + + // Notifications section + ListSection( + title = Res.string.scoped_compose_notifications_title.string(), + listState = state.notifications, + onRefresh = { intent(ScopedComposeIntent.ClickedRefreshNotifications) }, + ) + + Spacer(Modifier.navigationBarsPadding()) +} + +@Composable +private fun ListSection( + title: String, + listState: ListState, + onRefresh: () -> Unit, +) = Column( + modifier = Modifier.fillMaxWidth().widthIn(max = 600.dp), + horizontalAlignment = Alignment.CenterHorizontally, +) { + Text( + text = title, + style = MaterialTheme.typography.titleMedium, + ) + Spacer(Modifier.height(8.dp)) + when (listState) { + is ListState.Loading -> CircularProgressIndicator() + is ListState.Error -> Text( + text = listState.message, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodyMedium, + ) + is ListState.Loaded -> Column { + listState.items.forEach { item -> + Text( + text = item, + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(vertical = 2.dp), + ) + } + } + } + Spacer(Modifier.height(8.dp)) + Button(onClick = onRefresh) { + Text(Res.string.scoped_compose_refresh_button.string()) + } +} + +@Composable +private fun IntentReceiver.ErrorContent( + state: ScopedComposeState.Error, +) = Column( + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, +) { + Text( + text = Res.string.scoped_compose_error_label.string(state.message), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.error, + ) + Spacer(Modifier.height(16.dp)) + Button(onClick = { intent(ScopedComposeIntent.ClickedRetry) }) { + Text(Res.string.scoped_compose_retry_button.string()) + } +} diff --git a/sample/src/commonMain/kotlin/pro/respawn/flowmvi/sample/features/toplevelcompose/TopLevelComposeContainer.kt b/sample/src/commonMain/kotlin/pro/respawn/flowmvi/sample/features/toplevelcompose/TopLevelComposeContainer.kt new file mode 100644 index 00000000..3fc690ba --- /dev/null +++ b/sample/src/commonMain/kotlin/pro/respawn/flowmvi/sample/features/toplevelcompose/TopLevelComposeContainer.kt @@ -0,0 +1,98 @@ +package pro.respawn.flowmvi.sample.features.toplevelcompose + +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.datetime.Clock +import kotlinx.datetime.TimeZone +import kotlinx.datetime.toLocalDateTime +import pro.respawn.flowmvi.api.Container +import pro.respawn.flowmvi.api.MVIAction +import pro.respawn.flowmvi.api.MVIIntent +import pro.respawn.flowmvi.api.Store +import pro.respawn.flowmvi.dsl.store +import pro.respawn.flowmvi.plugins.init +import pro.respawn.flowmvi.plugins.reduce +import pro.respawn.flowmvi.plugins.transitions +import pro.respawn.flowmvi.plugins.whileSubscribed +import pro.respawn.flowmvi.sample.arch.configuration.ConfigurationFactory +import pro.respawn.flowmvi.sample.arch.configuration.configure +import kotlin.random.Random +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.seconds + +internal class TopLevelComposeContainer( + configuration: ConfigurationFactory, +) : Container { + + // Private child intent types + private sealed interface WeatherIntent : MVIIntent { + data object Refresh : WeatherIntent + } + + private sealed interface ClockIntent : MVIIntent + + private val weatherStore: Store = + store(WeatherState.Loading) { + configure { + name = "WeatherStore" + debuggable = true + } + init { + launch { + delay(1500.milliseconds) + val conditions = listOf("Sunny", "Cloudy", "Rainy", "Snowy", "Windy") + updateState { WeatherState.Loaded(Random.nextInt(-10, 35), conditions.random()) } + } + } + reduce { intent -> + when (intent) { + WeatherIntent.Refresh -> { + updateState { WeatherState.Loading } + launch { + delay(1500.milliseconds) + val conditions = listOf("Sunny", "Cloudy", "Rainy", "Snowy", "Windy") + updateState { WeatherState.Loaded(Random.nextInt(-10, 35), conditions.random()) } + } + } + } + } + } + + private val clockStore: Store = + store(ClockState()) { + configure { + name = "ClockStore" + debuggable = true + } + whileSubscribed { + while (true) { + updateState { copy(time = currentTimeFormatted()) } + delay(1.seconds) + } + } + } + + override val store: Store = store(DashboardState()) { + configure(configuration, "TopLevelComposeStore") + + transitions { + // Top-level compose: always active while parent store runs + compose(weatherStore, merge = { childState -> copy(weather = childState) }) + compose(clockStore, merge = { childState -> copy(clock = childState) }) + + state { + on { + weatherStore.intent(WeatherIntent.Refresh) + } + } + } + } + + private fun currentTimeFormatted(): String { + val now = Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()) + val h = now.hour.toString().padStart(2, '0') + val m = now.minute.toString().padStart(2, '0') + val s = now.second.toString().padStart(2, '0') + return "$h:$m:$s" + } +} diff --git a/sample/src/commonMain/kotlin/pro/respawn/flowmvi/sample/features/toplevelcompose/TopLevelComposeModels.kt b/sample/src/commonMain/kotlin/pro/respawn/flowmvi/sample/features/toplevelcompose/TopLevelComposeModels.kt new file mode 100644 index 00000000..b0bc0590 --- /dev/null +++ b/sample/src/commonMain/kotlin/pro/respawn/flowmvi/sample/features/toplevelcompose/TopLevelComposeModels.kt @@ -0,0 +1,32 @@ +package pro.respawn.flowmvi.sample.features.toplevelcompose + +import androidx.compose.runtime.Immutable +import pro.respawn.flowmvi.api.MVIAction +import pro.respawn.flowmvi.api.MVIIntent +import pro.respawn.flowmvi.api.MVIState + +@Immutable +internal sealed interface WeatherState : MVIState { + + data object Loading : WeatherState + data class Loaded(val temperature: Int, val condition: String) : WeatherState +} + +@Immutable +internal data class ClockState(val time: String = "--:--:--") : MVIState + +// Parent state — single data class, always-active composition target +@Immutable +internal data class DashboardState( + val weather: WeatherState = WeatherState.Loading, + val clock: ClockState = ClockState(), +) : MVIState + +@Immutable +internal sealed interface DashboardIntent : MVIIntent { + + data object ClickedRefresh : DashboardIntent +} + +@Immutable +internal sealed interface DashboardAction : MVIAction diff --git a/sample/src/commonMain/kotlin/pro/respawn/flowmvi/sample/features/toplevelcompose/TopLevelComposeScreen.kt b/sample/src/commonMain/kotlin/pro/respawn/flowmvi/sample/features/toplevelcompose/TopLevelComposeScreen.kt new file mode 100644 index 00000000..64a9b730 --- /dev/null +++ b/sample/src/commonMain/kotlin/pro/respawn/flowmvi/sample/features/toplevelcompose/TopLevelComposeScreen.kt @@ -0,0 +1,152 @@ +@file:OptIn(ExperimentalMaterial3Api::class) + +package pro.respawn.flowmvi.sample.features.toplevelcompose + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import pro.respawn.flowmvi.api.IntentReceiver +import pro.respawn.flowmvi.compose.dsl.subscribe +import pro.respawn.flowmvi.sample.Res +import pro.respawn.flowmvi.sample.arch.di.container +import pro.respawn.flowmvi.sample.navigation.AppNavigator +import pro.respawn.flowmvi.sample.navigation.util.backNavigator +import pro.respawn.flowmvi.sample.toplevel_compose_clock_title +import pro.respawn.flowmvi.sample.toplevel_compose_feature_title +import pro.respawn.flowmvi.sample.toplevel_compose_refresh_button +import pro.respawn.flowmvi.sample.toplevel_compose_weather_title +import pro.respawn.flowmvi.sample.ui.widgets.CodeText +import pro.respawn.flowmvi.sample.ui.widgets.RScaffold +import pro.respawn.flowmvi.sample.util.formatAsMultiline +import pro.respawn.kmmutils.compose.resources.string + +private const val Description = """ + This screen demonstrates top-level composition using the transitions plugin. + \n\n + Two child stores (Weather & Clock) are composed into the parent at the top level. + Their state is always merged into the parent's DashboardState, regardless of which + state the parent is in. + \n\n + The Weather store loads data with a simulated delay, while the Clock store + updates every second using whileSubscribed. +""" + +//language=kotlin +private const val Code = """ +transitions { + compose(weatherStore) { copy(weather = it) } + compose(clockStore) { copy(clock = it) } + + state { + on { + weatherStore.intent(WeatherIntent.Refresh) + } + } +} +""" + +@Composable +internal fun TopLevelComposeScreen( + navigator: AppNavigator, +) = with(container()) { + val state by subscribe() + + RScaffold( + onBack = navigator.backNavigator, + title = Res.string.toplevel_compose_feature_title.string(), + ) { + TopLevelComposeScreenContent(state) + } +} + +@Composable +private fun IntentReceiver.TopLevelComposeScreenContent( + state: DashboardState, +) = Column( + modifier = Modifier + .fillMaxHeight() + .fillMaxWidth() + .padding(horizontal = 12.dp) + .verticalScroll(rememberScrollState()), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Top, +) { + Text(Description.formatAsMultiline(), modifier = Modifier.widthIn(max = 600.dp)) + Spacer(Modifier.height(12.dp)) + CodeText(Code) + Spacer(Modifier.height(24.dp)) + + // Weather card + Card( + modifier = Modifier.fillMaxWidth().widthIn(max = 600.dp), + ) { + Column( + modifier = Modifier.padding(16.dp).fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + text = Res.string.toplevel_compose_weather_title.string(), + style = MaterialTheme.typography.titleMedium, + ) + Spacer(Modifier.height(8.dp)) + when (val weather = state.weather) { + is WeatherState.Loading -> CircularProgressIndicator() + is WeatherState.Loaded -> { + Text( + text = "${weather.temperature}°C — ${weather.condition}", + style = MaterialTheme.typography.headlineMedium, + ) + } + } + } + } + + Spacer(Modifier.height(16.dp)) + + // Clock card + Card( + modifier = Modifier.fillMaxWidth().widthIn(max = 600.dp), + ) { + Column( + modifier = Modifier.padding(16.dp).fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + text = Res.string.toplevel_compose_clock_title.string(), + style = MaterialTheme.typography.titleMedium, + ) + Spacer(Modifier.height(8.dp)) + Text( + text = state.clock.time, + style = MaterialTheme.typography.headlineMedium, + ) + } + } + + Spacer(Modifier.height(24.dp)) + + Button(onClick = { intent(DashboardIntent.ClickedRefresh) }) { + Text(Res.string.toplevel_compose_refresh_button.string()) + } + + Spacer(Modifier.navigationBarsPadding()) +} diff --git a/sample/src/commonMain/kotlin/pro/respawn/flowmvi/sample/features/transitions/AuthRepository.kt b/sample/src/commonMain/kotlin/pro/respawn/flowmvi/sample/features/transitions/AuthRepository.kt new file mode 100644 index 00000000..807e74fc --- /dev/null +++ b/sample/src/commonMain/kotlin/pro/respawn/flowmvi/sample/features/transitions/AuthRepository.kt @@ -0,0 +1,19 @@ +package pro.respawn.flowmvi.sample.features.transitions + +import kotlinx.coroutines.delay +import kotlin.random.Random +import kotlin.time.Duration.Companion.seconds + +internal class AuthRepository { + + @Suppress("UnusedParameter") + suspend fun authenticate(username: String, password: String): Result { + delay(2.seconds) + @Suppress("MagicNumber") + return if (Random.nextFloat() < 0.3f) { + Result.failure(IllegalStateException("Invalid credentials")) + } else { + Result.success(username) + } + } +} diff --git a/sample/src/commonMain/kotlin/pro/respawn/flowmvi/sample/features/transitions/TransitionsContainer.kt b/sample/src/commonMain/kotlin/pro/respawn/flowmvi/sample/features/transitions/TransitionsContainer.kt new file mode 100644 index 00000000..46a155ba --- /dev/null +++ b/sample/src/commonMain/kotlin/pro/respawn/flowmvi/sample/features/transitions/TransitionsContainer.kt @@ -0,0 +1,57 @@ +package pro.respawn.flowmvi.sample.features.transitions + +import kotlinx.coroutines.launch +import pro.respawn.flowmvi.api.Container +import pro.respawn.flowmvi.dsl.store +import pro.respawn.flowmvi.plugins.transitions +import pro.respawn.flowmvi.sample.arch.configuration.ConfigurationFactory +import pro.respawn.flowmvi.sample.arch.configuration.configure + +internal class TransitionsContainer( + private val repo: AuthRepository, + configuration: ConfigurationFactory, +) : Container { + + override val store = store(TransitionsState.Login()) { + configure(configuration, "TransitionsStore") + + transitions { + state { + on { + transitionTo(state.copy(username = it.value)) + } + on { + transitionTo(state.copy(password = it.value)) + } + on { + val (username, password) = state + transitionTo(TransitionsState.Authenticating) + launch { + repo.authenticate(username, password) + .onSuccess { intent(TransitionsIntent.AuthSucceeded(it)) } + .onFailure { intent(TransitionsIntent.AuthFailed(it.message ?: "Unknown error")) } + } + } + } + state { + on { + transitionTo(TransitionsState.Authenticated(it.username)) + } + on { + action(TransitionsAction.ShowError(it.message)) + transitionTo(TransitionsState.Error(it.message)) + } + } + state { + on { + transitionTo(TransitionsState.Login()) + } + } + state { + on { + transitionTo(TransitionsState.Login()) + } + } + } + } +} diff --git a/sample/src/commonMain/kotlin/pro/respawn/flowmvi/sample/features/transitions/TransitionsModels.kt b/sample/src/commonMain/kotlin/pro/respawn/flowmvi/sample/features/transitions/TransitionsModels.kt new file mode 100644 index 00000000..2a0b4265 --- /dev/null +++ b/sample/src/commonMain/kotlin/pro/respawn/flowmvi/sample/features/transitions/TransitionsModels.kt @@ -0,0 +1,35 @@ +package pro.respawn.flowmvi.sample.features.transitions + +import androidx.compose.runtime.Immutable +import pro.respawn.flowmvi.api.MVIAction +import pro.respawn.flowmvi.api.MVIIntent +import pro.respawn.flowmvi.api.MVIState + +@Immutable +internal sealed interface TransitionsState : MVIState { + + data class Login(val username: String = "", val password: String = "") : TransitionsState + data object Authenticating : TransitionsState + data class Authenticated(val username: String) : TransitionsState + data class Error(val message: String) : TransitionsState +} + +@Immutable +internal sealed interface TransitionsIntent : MVIIntent { + + data class UpdateUsername(val value: String) : TransitionsIntent + data class UpdatePassword(val value: String) : TransitionsIntent + data object ClickedLogin : TransitionsIntent + data object ClickedRetry : TransitionsIntent + data object ClickedLogout : TransitionsIntent + + // Internal intents for async result re-emission + data class AuthSucceeded(val username: String) : TransitionsIntent + data class AuthFailed(val message: String) : TransitionsIntent +} + +@Immutable +internal sealed interface TransitionsAction : MVIAction { + + data class ShowError(val message: String) : TransitionsAction +} diff --git a/sample/src/commonMain/kotlin/pro/respawn/flowmvi/sample/features/transitions/TransitionsScreen.kt b/sample/src/commonMain/kotlin/pro/respawn/flowmvi/sample/features/transitions/TransitionsScreen.kt new file mode 100644 index 00000000..f22a27c9 --- /dev/null +++ b/sample/src/commonMain/kotlin/pro/respawn/flowmvi/sample/features/transitions/TransitionsScreen.kt @@ -0,0 +1,185 @@ +@file:OptIn(ExperimentalMaterial3Api::class) + +package pro.respawn.flowmvi.sample.features.transitions + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.widthIn +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import org.jetbrains.compose.resources.getString +import pro.respawn.flowmvi.api.IntentReceiver +import pro.respawn.flowmvi.compose.dsl.subscribe +import pro.respawn.flowmvi.sample.Res +import pro.respawn.flowmvi.sample.arch.di.container +import pro.respawn.flowmvi.sample.navigation.AppNavigator +import pro.respawn.flowmvi.sample.navigation.util.backNavigator +import pro.respawn.flowmvi.sample.transitions_authenticating_label +import pro.respawn.flowmvi.sample.transitions_error_snackbar +import pro.respawn.flowmvi.sample.transitions_feature_title +import pro.respawn.flowmvi.sample.transitions_login_button +import pro.respawn.flowmvi.sample.transitions_logout_button +import pro.respawn.flowmvi.sample.transitions_password_label +import pro.respawn.flowmvi.sample.transitions_retry_button +import pro.respawn.flowmvi.sample.transitions_username_label +import pro.respawn.flowmvi.sample.transitions_welcome_label +import pro.respawn.flowmvi.sample.ui.widgets.CodeText +import pro.respawn.flowmvi.sample.ui.widgets.RScaffold +import pro.respawn.flowmvi.sample.ui.widgets.TypeCrossfade +import pro.respawn.flowmvi.sample.util.formatAsMultiline +import pro.respawn.flowmvi.sample.util.rememberSnackbarHostState +import pro.respawn.kmmutils.compose.resources.string + +private const val Description = """ + This screen demonstrates the FSM transitions plugin. + Instead of a single reduce block, you define typed state handlers + that only respond to specific intents in specific states. + \n\n + Async results are re-emitted as intents since transitionTo + cannot be called inside launch blocks. +""" + +//language=kotlin +private const val Code = """ +transitions { + state { + on { + val (user, pass) = state + transitionTo(Authenticating) + launch { + repo.authenticate(user, pass) + .onSuccess { intent(AuthSucceeded(it)) } + .onFailure { intent(AuthFailed(it.message)) } + } + } + } + state { + on { + transitionTo(Authenticated(it.username)) + } + } +} +""" + +@Composable +internal fun TransitionsScreen( + navigator: AppNavigator, +) = with(container()) { + val shs = rememberSnackbarHostState() + val state by subscribe { action -> + when (action) { + is TransitionsAction.ShowError -> shs.showSnackbar( + getString(Res.string.transitions_error_snackbar, action.message) + ) + } + } + + RScaffold( + onBack = navigator.backNavigator, + snackbarHostState = shs, + title = Res.string.transitions_feature_title.string(), + ) { + TransitionsScreenContent(state) + } +} + +@Composable +private fun IntentReceiver.TransitionsScreenContent( + state: TransitionsState, +) = TypeCrossfade(state) { + when (this) { + is TransitionsState.Login -> LoginContent(this) + is TransitionsState.Authenticating -> AuthenticatingContent() + is TransitionsState.Authenticated -> AuthenticatedContent(this) + is TransitionsState.Error -> ErrorContent(this) + } +} + +@Composable +private fun IntentReceiver.LoginContent( + state: TransitionsState.Login, +) = Column( + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, +) { + Text(Description.formatAsMultiline(), modifier = Modifier.widthIn(max = 600.dp)) + Spacer(Modifier.height(12.dp)) + CodeText(Code) + Spacer(Modifier.height(24.dp)) + OutlinedTextField( + value = state.username, + onValueChange = { intent(TransitionsIntent.UpdateUsername(it)) }, + label = { Text(Res.string.transitions_username_label.string()) }, + singleLine = true, + ) + Spacer(Modifier.height(8.dp)) + OutlinedTextField( + value = state.password, + onValueChange = { intent(TransitionsIntent.UpdatePassword(it)) }, + label = { Text(Res.string.transitions_password_label.string()) }, + singleLine = true, + ) + Spacer(Modifier.height(16.dp)) + Button(onClick = { intent(TransitionsIntent.ClickedLogin) }) { + Text(Res.string.transitions_login_button.string()) + } +} + +@Composable +private fun AuthenticatingContent() = Column( + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, +) { + CircularProgressIndicator() + Spacer(Modifier.height(16.dp)) + Text( + text = Res.string.transitions_authenticating_label.string(), + style = MaterialTheme.typography.titleMedium, + ) +} + +@Composable +private fun IntentReceiver.AuthenticatedContent( + state: TransitionsState.Authenticated, +) = Column( + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, +) { + Text( + text = Res.string.transitions_welcome_label.string(state.username), + style = MaterialTheme.typography.headlineMedium, + ) + Spacer(Modifier.height(16.dp)) + Button(onClick = { intent(TransitionsIntent.ClickedLogout) }) { + Text(Res.string.transitions_logout_button.string()) + } +} + +@Composable +private fun IntentReceiver.ErrorContent( + state: TransitionsState.Error, +) = Column( + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, +) { + Text( + text = state.message, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.error, + ) + Spacer(Modifier.height(16.dp)) + Button(onClick = { intent(TransitionsIntent.ClickedRetry) }) { + Text(Res.string.transitions_retry_button.string()) + } +} diff --git a/sample/src/commonMain/kotlin/pro/respawn/flowmvi/sample/navigation/AppNavigator.kt b/sample/src/commonMain/kotlin/pro/respawn/flowmvi/sample/navigation/AppNavigator.kt index 1ad7b4e8..e03cd8e1 100644 --- a/sample/src/commonMain/kotlin/pro/respawn/flowmvi/sample/navigation/AppNavigator.kt +++ b/sample/src/commonMain/kotlin/pro/respawn/flowmvi/sample/navigation/AppNavigator.kt @@ -16,4 +16,7 @@ interface AppNavigator : Navigator { fun decomposeFeature() fun info() fun stateTransactionsFeature() + fun transitionsFeature() + fun scopedComposeFeature() + fun topLevelComposeFeature() } diff --git a/sample/src/commonMain/kotlin/pro/respawn/flowmvi/sample/navigation/AppNavigatorImpl.kt b/sample/src/commonMain/kotlin/pro/respawn/flowmvi/sample/navigation/AppNavigatorImpl.kt index 6d81a809..9fef748f 100644 --- a/sample/src/commonMain/kotlin/pro/respawn/flowmvi/sample/navigation/AppNavigatorImpl.kt +++ b/sample/src/commonMain/kotlin/pro/respawn/flowmvi/sample/navigation/AppNavigatorImpl.kt @@ -47,6 +47,9 @@ private class AppNavigatorImpl( override fun undoRedoFeature() = navigate(Destination.UndoRedo) override fun progressiveFeature() = navigate(Destination.Progressive) override fun stateTransactionsFeature() = navigate(Destination.StateTransactions) + override fun transitionsFeature() = navigate(Destination.Transitions) + override fun scopedComposeFeature() = navigate(Destination.ScopedCompose) + override fun topLevelComposeFeature() = navigate(Destination.TopLevelCompose) override fun decomposeFeature() = navigate(Destination.Decompose) @Composable diff --git a/sample/src/commonMain/kotlin/pro/respawn/flowmvi/sample/navigation/destination/Destination.kt b/sample/src/commonMain/kotlin/pro/respawn/flowmvi/sample/navigation/destination/Destination.kt index 9986a283..9bc6573a 100644 --- a/sample/src/commonMain/kotlin/pro/respawn/flowmvi/sample/navigation/destination/Destination.kt +++ b/sample/src/commonMain/kotlin/pro/respawn/flowmvi/sample/navigation/destination/Destination.kt @@ -22,6 +22,9 @@ enum class Destination( UndoRedo("undoredo", "undo"), Decompose("decompose"), StateTransactions("statetransactions"), + Transitions("transitions"), + ScopedCompose("scopedcompose"), + TopLevelCompose("toplevelcompose"), Info; companion object { diff --git a/sample/src/commonMain/kotlin/pro/respawn/flowmvi/sample/navigation/destination/Destinations.kt b/sample/src/commonMain/kotlin/pro/respawn/flowmvi/sample/navigation/destination/Destinations.kt index 7e2f13d4..ba4dcff6 100644 --- a/sample/src/commonMain/kotlin/pro/respawn/flowmvi/sample/navigation/destination/Destinations.kt +++ b/sample/src/commonMain/kotlin/pro/respawn/flowmvi/sample/navigation/destination/Destinations.kt @@ -9,8 +9,11 @@ import pro.respawn.flowmvi.sample.features.lce.LCEScreen import pro.respawn.flowmvi.sample.features.logging.LoggingScreen import pro.respawn.flowmvi.sample.features.progressive.ProgressiveScreen import pro.respawn.flowmvi.sample.features.savedstate.SavedStateScreen +import pro.respawn.flowmvi.sample.features.scopedcompose.ScopedComposeScreen import pro.respawn.flowmvi.sample.features.simple.SimpleScreen import pro.respawn.flowmvi.sample.features.sst.SSTScreen +import pro.respawn.flowmvi.sample.features.toplevelcompose.TopLevelComposeScreen +import pro.respawn.flowmvi.sample.features.transitions.TransitionsScreen import pro.respawn.flowmvi.sample.features.undoredo.UndoRedoScreen import pro.respawn.flowmvi.sample.navigation.AppNavigator import pro.respawn.flowmvi.sample.navigation.component.DestinationComponent @@ -34,5 +37,8 @@ fun Destinations( Destination.Decompose -> DecomposeScreen(component, navigator) Destination.Progressive -> ProgressiveScreen(navigator) Destination.StateTransactions -> SSTScreen(navigator) + Destination.Transitions -> TransitionsScreen(navigator) + Destination.ScopedCompose -> ScopedComposeScreen(navigator) + Destination.TopLevelCompose -> TopLevelComposeScreen(navigator) } } diff --git a/sample/src/commonMain/kotlin/pro/respawn/flowmvi/sample/ui/icons/Dashboard.kt b/sample/src/commonMain/kotlin/pro/respawn/flowmvi/sample/ui/icons/Dashboard.kt new file mode 100644 index 00000000..7aa90f9f --- /dev/null +++ b/sample/src/commonMain/kotlin/pro/respawn/flowmvi/sample/ui/icons/Dashboard.kt @@ -0,0 +1,77 @@ +package pro.respawn.flowmvi.sample.ui.icons + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp + +val Icons.Dashboard: ImageVector + get() { + if (_Dashboard != null) { + return _Dashboard!! + } + _Dashboard = ImageVector.Builder( + name = "Dashboard", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 960f, + viewportHeight = 960f, + ).apply { + path(fill = SolidColor(Color(0xFF000000))) { + moveTo(520f, 440f) + verticalLineToRelative(-280f) + horizontalLineToRelative(280f) + verticalLineToRelative(280f) + lineTo(520f, 440f) + close() + moveTo(160f, 560f) + verticalLineToRelative(-400f) + horizontalLineToRelative(280f) + verticalLineToRelative(400f) + lineTo(160f, 560f) + close() + moveTo(520f, 800f) + verticalLineToRelative(-400f) + horizontalLineToRelative(280f) + verticalLineToRelative(400f) + lineTo(520f, 800f) + close() + moveTo(160f, 800f) + verticalLineToRelative(-280f) + horizontalLineToRelative(280f) + verticalLineToRelative(280f) + lineTo(160f, 800f) + close() + moveTo(240f, 480f) + verticalLineToRelative(-240f) + horizontalLineToRelative(120f) + verticalLineToRelative(240f) + lineTo(240f, 480f) + close() + moveTo(600f, 360f) + verticalLineToRelative(-120f) + horizontalLineToRelative(120f) + verticalLineToRelative(120f) + lineTo(600f, 360f) + close() + moveTo(600f, 720f) + verticalLineToRelative(-240f) + horizontalLineToRelative(120f) + verticalLineToRelative(240f) + lineTo(600f, 720f) + close() + moveTo(240f, 720f) + verticalLineToRelative(-120f) + horizontalLineToRelative(120f) + verticalLineToRelative(120f) + lineTo(240f, 720f) + close() + } + }.build() + + return _Dashboard!! + } + +@Suppress("ObjectPropertyName") +private var _Dashboard: ImageVector? = null diff --git a/sample/src/commonMain/kotlin/pro/respawn/flowmvi/sample/ui/icons/FilterList.kt b/sample/src/commonMain/kotlin/pro/respawn/flowmvi/sample/ui/icons/FilterList.kt new file mode 100644 index 00000000..02ae8887 --- /dev/null +++ b/sample/src/commonMain/kotlin/pro/respawn/flowmvi/sample/ui/icons/FilterList.kt @@ -0,0 +1,47 @@ +package pro.respawn.flowmvi.sample.ui.icons + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp + +val Icons.FilterList: ImageVector + get() { + if (_FilterList != null) { + return _FilterList!! + } + _FilterList = ImageVector.Builder( + name = "FilterList", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 960f, + viewportHeight = 960f, + ).apply { + path(fill = SolidColor(Color(0xFF000000))) { + moveTo(400f, 720f) + verticalLineToRelative(-80f) + horizontalLineToRelative(160f) + verticalLineToRelative(80f) + lineTo(400f, 720f) + close() + moveTo(240f, 520f) + verticalLineToRelative(-80f) + horizontalLineToRelative(480f) + verticalLineToRelative(80f) + lineTo(240f, 520f) + close() + moveTo(120f, 320f) + verticalLineToRelative(-80f) + horizontalLineToRelative(720f) + verticalLineToRelative(80f) + lineTo(120f, 320f) + close() + } + }.build() + + return _FilterList!! + } + +@Suppress("ObjectPropertyName") +private var _FilterList: ImageVector? = null diff --git a/sample/src/commonMain/kotlin/pro/respawn/flowmvi/sample/ui/icons/SwapHoriz.kt b/sample/src/commonMain/kotlin/pro/respawn/flowmvi/sample/ui/icons/SwapHoriz.kt new file mode 100644 index 00000000..4d6d2755 --- /dev/null +++ b/sample/src/commonMain/kotlin/pro/respawn/flowmvi/sample/ui/icons/SwapHoriz.kt @@ -0,0 +1,51 @@ +package pro.respawn.flowmvi.sample.ui.icons + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp + +val Icons.SwapHoriz: ImageVector + get() { + if (_SwapHoriz != null) { + return _SwapHoriz!! + } + _SwapHoriz = ImageVector.Builder( + name = "SwapHoriz", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 960f, + viewportHeight = 960f, + ).apply { + path(fill = SolidColor(Color(0xFF000000))) { + moveTo(280f, 720f) + lineTo(80f, 520f) + lineTo(280f, 320f) + lineToRelative(56f, 58f) + lineToRelative(-102f, 102f) + horizontalLineToRelative(526f) + verticalLineToRelative(80f) + lineTo(234f, 560f) + lineToRelative(102f, 102f) + lineToRelative(-56f, 58f) + close() + moveTo(680f, 640f) + lineToRelative(-56f, -58f) + lineToRelative(102f, -102f) + lineTo(200f, 480f) + verticalLineToRelative(-80f) + horizontalLineToRelative(526f) + lineTo(624f, 298f) + lineToRelative(56f, -58f) + lineToRelative(200f, 200f) + lineToRelative(-200f, 200f) + close() + } + }.build() + + return _SwapHoriz!! + } + +@Suppress("ObjectPropertyName") +private var _SwapHoriz: ImageVector? = null From 889d62fdf56fe152cd21674a92793fe1b363dd92 Mon Sep 17 00:00:00 2001 From: Karol Celebi Date: Sat, 21 Mar 2026 17:01:41 +0100 Subject: [PATCH 5/5] refactor: move compose subscription logic into ComposeDefinition.launchIn for type safety --- .../respawn/flowmvi/plugins/ComposePlugin.kt | 27 +--------------- .../flowmvi/plugins/TransitionGraph.kt | 31 ++++++++++++++++++- 2 files changed, 31 insertions(+), 27 deletions(-) diff --git a/core/src/commonMain/kotlin/pro/respawn/flowmvi/plugins/ComposePlugin.kt b/core/src/commonMain/kotlin/pro/respawn/flowmvi/plugins/ComposePlugin.kt index 10708ce6..521ebaa1 100644 --- a/core/src/commonMain/kotlin/pro/respawn/flowmvi/plugins/ComposePlugin.kt +++ b/core/src/commonMain/kotlin/pro/respawn/flowmvi/plugins/ComposePlugin.kt @@ -3,7 +3,6 @@ package pro.respawn.flowmvi.plugins import kotlinx.atomicfu.locks.SynchronizedObject import kotlinx.atomicfu.locks.synchronized import kotlinx.coroutines.Job -import kotlinx.coroutines.awaitCancellation import kotlinx.coroutines.launch import pro.respawn.flowmvi.annotation.InternalFlowMVIAPI import pro.respawn.flowmvi.api.MVIAction @@ -11,10 +10,8 @@ import pro.respawn.flowmvi.api.MVIIntent import pro.respawn.flowmvi.api.MVIState import pro.respawn.flowmvi.api.PipelineContext import pro.respawn.flowmvi.api.StorePlugin -import pro.respawn.flowmvi.dsl.collect import pro.respawn.flowmvi.dsl.plugin -@Suppress("UNCHECKED_CAST") @OptIn(InternalFlowMVIAPI::class) @PublishedApi internal fun buildComposePlugin( @@ -36,31 +33,9 @@ internal fun buildComposePlugin( } } -@Suppress("UNCHECKED_CAST") -@OptIn(InternalFlowMVIAPI::class) private fun PipelineContext.launchComposeSubscription( def: ComposeDefinition, -): Job { - val typedDef = def as ComposeDefinition - return launch { - typedDef.store.collect { - launch { - states.collect { childState -> - updateState { typedDef.merge(this, childState) } - } - } - typedDef.consume?.let { consume -> - launch { - actions.collect { childAction -> - (consume as suspend PipelineContext.(Any) -> Unit) - .invoke(this@launchComposeSubscription, childAction) - } - } - } - awaitCancellation() - } - } -} +): Job = def.launchIn(this) @OptIn(InternalFlowMVIAPI::class) private fun PipelineContext.launchScopedCompositions( diff --git a/core/src/commonMain/kotlin/pro/respawn/flowmvi/plugins/TransitionGraph.kt b/core/src/commonMain/kotlin/pro/respawn/flowmvi/plugins/TransitionGraph.kt index 4cfe7fe9..42e6be22 100644 --- a/core/src/commonMain/kotlin/pro/respawn/flowmvi/plugins/TransitionGraph.kt +++ b/core/src/commonMain/kotlin/pro/respawn/flowmvi/plugins/TransitionGraph.kt @@ -1,10 +1,15 @@ package pro.respawn.flowmvi.plugins +import kotlinx.coroutines.Job +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.launch +import pro.respawn.flowmvi.annotation.InternalFlowMVIAPI import pro.respawn.flowmvi.api.MVIAction import pro.respawn.flowmvi.api.MVIIntent import pro.respawn.flowmvi.api.MVIState import pro.respawn.flowmvi.api.PipelineContext import pro.respawn.flowmvi.api.Store +import pro.respawn.flowmvi.dsl.collect import kotlin.reflect.KClass /** @@ -58,4 +63,28 @@ internal class ComposeDefinition< /** If non-null, composition is active only while parent is in this state type. * If null, always active (top-level). */ val scopedToState: KClass?, -) +) { + + @OptIn(InternalFlowMVIAPI::class) + @Suppress("UNCHECKED_CAST") + internal fun launchIn(ctx: PipelineContext): Job = with(ctx) { + launch { + store.collect { + launch { + states.collect { childState -> + updateState { (merge as S.(Any?) -> S)(childState) } + } + } + consume?.let { consumeFn -> + launch { + actions.collect { childAction -> + (consumeFn as suspend PipelineContext.(Any?) -> Unit) + .invoke(ctx, childAction) + } + } + } + awaitCancellation() + } + } + } +}