-
-
Notifications
You must be signed in to change notification settings - Fork 2.7k
feat(message-list): create the Message List state machine handling all states and transitions #10329
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
rafaeltonholo
wants to merge
5
commits into
thunderbird:main
Choose a base branch
from
rafaeltonholo:feat/9497/create-message-list-state-machine
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
feat(message-list): create the Message List state machine handling all states and transitions #10329
Changes from all commits
Commits
Show all changes
5 commits
Select commit
Hold shift + click to select a range
fdbd4b1
feat(mvi): add `StateMachineViewModel` abstract class that integrates…
rafaeltonholo 9557167
feat(message-list): create the Message List state machine handling al…
rafaeltonholo 36e565f
refactor(message-list): change `account.uuid: String` to `account.id:…
rafaeltonholo 07ae65c
chore(message-list): add unit tests for MessageListStateMachine
rafaeltonholo 8715593
chore(message-list): add unit tests for MessageListStateMachine
rafaeltonholo File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
62 changes: 62 additions & 0 deletions
62
.../commonMain/kotlin/net/thunderbird/core/common/state/sideeffect/StateSideEffectHandler.kt
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,62 @@ | ||
| package net.thunderbird.core.common.state.sideeffect | ||
|
|
||
| import kotlinx.coroutines.CoroutineScope | ||
| import net.thunderbird.core.logging.Logger | ||
|
|
||
| /** | ||
| * A base class for handling side effects that arise from state transitions in the message list UI. | ||
| * | ||
| * Each implementation of this class is responsible for a specific side effect, such as loading more messages, | ||
| * marking messages as read, or showing notifications. The handler decides whether to act based on the | ||
| * incoming event and the resulting state change. | ||
| * | ||
| * @property logger A [Logger] for logging the handler's operations. | ||
| * @property dispatch A function to send new [TEvent]s back to the UI's event loop. | ||
| */ | ||
| abstract class StateSideEffectHandler<TState : Any, TEvent : Any>( | ||
| private val logger: Logger, | ||
| protected val dispatch: suspend (TEvent) -> Unit, | ||
| ) { | ||
| /** | ||
| * Determines whether this side effect handler should be triggered. | ||
| * | ||
| * This function is called for every state change to check if the conditions for executing | ||
| * this specific side effect are met. | ||
| * | ||
| * @param event The [TEvent] that triggered the state change. | ||
| * @param newState The new [TState] after the event was processed. | ||
| * @return `true` if this handler should execute its `handle` method, `false` otherwise. | ||
| */ | ||
| abstract fun accept(event: TEvent, newState: TState): Boolean | ||
|
|
||
| /** | ||
| * Handles the side effect based on the state transition. | ||
| * | ||
| * This function is invoked when the `accept` method returns `true`, indicating that this handler | ||
| * should process the state change. It's responsible for executing the actual side effect, | ||
| * such as dispatching a new event, logging, or interacting with other system components. | ||
| * | ||
| * @param oldState The state before the event was processed. | ||
| * @param newState The new state after the event has been processed. | ||
| */ | ||
| abstract suspend fun handle(oldState: TState, newState: TState) | ||
|
|
||
| /** | ||
| * Handles a state change by checking if this handler should react to the [event] and the [newState]. | ||
| * If it should, it calls the [handle] method to perform the side effect. | ||
| * | ||
| * @param event The event that triggered the state change. | ||
| * @param oldState The state before the event was processed. | ||
| * @param newState The state after the event was processed. | ||
| */ | ||
| suspend fun handle(event: TEvent, oldState: TState, newState: TState) { | ||
| logger.verbose { "handle() called with: event = $event, oldState = $oldState, newState = $newState" } | ||
| if (accept(event, newState)) { | ||
| handle(oldState, newState) | ||
| } | ||
| } | ||
|
|
||
| fun interface Factory<TState : Any, TEvent : Any> { | ||
| fun create(scope: CoroutineScope, dispatch: suspend (TEvent) -> Unit): StateSideEffectHandler<TState, TEvent> | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
114 changes: 114 additions & 0 deletions
114
...ommon/src/main/kotlin/net/thunderbird/core/ui/compose/common/mvi/StateMachineViewModel.kt
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,114 @@ | ||
| package net.thunderbird.core.ui.compose.common.mvi | ||
|
|
||
| import androidx.lifecycle.ViewModel | ||
| import androidx.lifecycle.viewModelScope | ||
| import app.k9mail.core.ui.compose.common.mvi.UnidirectionalViewModel | ||
| import kotlinx.coroutines.flow.MutableSharedFlow | ||
| import kotlinx.coroutines.flow.SharedFlow | ||
| import kotlinx.coroutines.flow.StateFlow | ||
| import kotlinx.coroutines.flow.asSharedFlow | ||
| import kotlinx.coroutines.launch | ||
| import net.thunderbird.core.common.state.StateMachine | ||
| import net.thunderbird.core.common.state.sideeffect.StateSideEffectHandler | ||
| import net.thunderbird.core.logging.Logger | ||
|
|
||
| /** | ||
| * An abstract base ViewModel that implements [UnidirectionalViewModel] and provides a | ||
| * MVI (Model-View-Intent) architecture based on a [StateMachine]. | ||
| * | ||
| * This class serves as a bridge between the UI (View) and the business logic (Model), | ||
| * which is encapsulated within a [StateMachine]. It receives user actions as [events][TEvent], | ||
| * processes them through the state machine, and exposes the resulting [state][TState] | ||
| * and one-time [UI side effects][TUiSideEffect] to the UI. | ||
| * | ||
| * @param TState The type that represents the state of the ViewModel. For example, the | ||
| * UI state of a screen. | ||
| * @param TEvent The type that represents user actions that can be handled by the ViewModel. | ||
| * For example, a button click. | ||
| * @param TUiSideEffect The type that represents one-time side effects that can occur | ||
| * in response to state changes. For example, a navigation event or showing a toast message. | ||
| * @param logger The [Logger] instance for logging events and state changes. | ||
| * @param sideEffectHandlersFactories A list of factories for creating [StateSideEffectHandler]s. | ||
| * These handlers can be used to trigger side effects in response to state transitions. | ||
| */ | ||
| abstract class StateMachineViewModel<TState : Any, TEvent : Any, TUiSideEffect>( | ||
| protected val logger: Logger, | ||
| sideEffectHandlersFactories: List<StateSideEffectHandler.Factory<TState, TEvent>> = emptyList(), | ||
| ) : | ||
| ViewModel(), | ||
| UnidirectionalViewModel<TState, TEvent, TUiSideEffect> { | ||
|
|
||
| /** | ||
| * The state machine responsible for managing the state transitions. | ||
| * | ||
| * It processes events and updates the [state] accordingly. Subclasses must provide | ||
| * an implementation of this [net.thunderbird.core.common.state.StateMachine]. | ||
| */ | ||
| protected abstract val stateMachine: StateMachine<TState, TEvent> | ||
|
|
||
| override val state: StateFlow<TState> get() = stateMachine.currentState | ||
|
|
||
| private val _effect = MutableSharedFlow<TUiSideEffect>() | ||
| override val effect: SharedFlow<TUiSideEffect> = _effect.asSharedFlow() | ||
| private val sideEffectHandlers = sideEffectHandlersFactories.map { it.create(viewModelScope, ::event) } | ||
|
|
||
| private val handledOneTimeEvents = mutableSetOf<TEvent>() | ||
|
|
||
| /** | ||
| * Emits a side effect to the UI. | ||
| * | ||
| * Side effects are events that should be consumed by the UI only once, such as showing | ||
| * a toast message, navigating to another screen, or triggering a one-time animation. | ||
| * This function should be called from within the ViewModel to signal such an event to | ||
| * the UI layer, which collects the `effect` flow. | ||
| * | ||
| * @param effect The [TUiSideEffect] to emit. | ||
| */ | ||
| protected fun emitEffect(effect: TUiSideEffect) { | ||
| viewModelScope.launch { | ||
| _effect.emit(effect) | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Ensures that one-time events are only handled once. | ||
| * | ||
| * When you can't ensure that an event is only sent once, but you want the event to only | ||
| * be handled once, call this method. It will ensure [block] is only executed the first | ||
| * time this function is called. Subsequent calls with an [event] argument equal to that | ||
| * of a previous invocation will not execute [block]. | ||
| * | ||
| * Multiple one-time events are supported. | ||
| */ | ||
| protected fun handleOneTimeEvent(event: TEvent, block: () -> Unit) { | ||
| if (event !in handledOneTimeEvents) { | ||
| handledOneTimeEvents.add(event) | ||
| block() | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Processes an event by launching a coroutine in the [viewModelScope] to delegate it to | ||
| * the [stateMachine]. | ||
| * | ||
| * This function is the entry point for all user actions or other events that can modify | ||
| * the ViewModel's state. | ||
| * | ||
| * It ensures that events are processed asynchronously off the main thread. | ||
| * | ||
| * @param event The [TEvent] to be processed. | ||
| */ | ||
| final override fun event(event: TEvent) { | ||
| viewModelScope.launch { | ||
| val currentState = stateMachine.currentStateSnapshot | ||
| val newState = stateMachine.process(event) | ||
| if (newState != currentState) { | ||
| logger.verbose { "event(${event::class.simpleName}): state update." } | ||
| sideEffectHandlers | ||
| .filter { it.accept(event, newState) } | ||
| .forEach { it.handle(event, oldState = currentState, newState) } | ||
| } | ||
| stateMachine.process(event) | ||
| } | ||
| } | ||
| } |
175 changes: 175 additions & 0 deletions
175
...n/src/test/kotlin/net/thunderbird/core/ui/compose/common/mvi/StateMachineViewModelTest.kt
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,175 @@ | ||
| package net.thunderbird.core.ui.compose.common.mvi | ||
|
|
||
| import app.cash.turbine.test | ||
| import assertk.assertThat | ||
| import assertk.assertions.isEqualTo | ||
| import assertk.assertions.isFalse | ||
| import assertk.assertions.isTrue | ||
| import kotlinx.coroutines.flow.MutableStateFlow | ||
| import kotlinx.coroutines.flow.StateFlow | ||
| import kotlinx.coroutines.flow.asStateFlow | ||
| import kotlinx.coroutines.test.runTest | ||
| import net.thunderbird.core.common.state.StateMachine | ||
| import net.thunderbird.core.common.state.sideeffect.StateSideEffectHandler | ||
| import net.thunderbird.core.logging.Logger | ||
| import net.thunderbird.core.logging.testing.TestLogger | ||
| import net.thunderbird.core.testing.coroutines.MainDispatcherRule | ||
| import org.junit.Rule | ||
| import org.junit.Test | ||
|
|
||
| class StateMachineViewModelTest { | ||
|
|
||
| @get:Rule | ||
| val mainDispatcherRule = MainDispatcherRule() | ||
|
|
||
| @Test | ||
| fun `should emit initial state`() = runTest { | ||
| val initialState = "Initial state" | ||
| val viewModel = TestStateMachineViewModel( | ||
| initialState = initialState, | ||
| ) | ||
|
|
||
| viewModel.state.test { | ||
| assertThat(awaitItem()).isEqualTo(initialState) | ||
| } | ||
| } | ||
|
|
||
| @Test | ||
| fun `should update state`() = runTest { | ||
| val viewModel = TestStateMachineViewModel( | ||
| initialState = "Initial state", | ||
| ) | ||
|
|
||
| viewModel.state.test { | ||
| assertThat(awaitItem()).isEqualTo("Initial state") | ||
|
|
||
| viewModel.event("Test event") | ||
|
|
||
| assertThat(awaitItem()).isEqualTo("Test event") | ||
|
|
||
| viewModel.event("Another test event") | ||
|
|
||
| assertThat(awaitItem()).isEqualTo("Another test event") | ||
| } | ||
| } | ||
|
|
||
| @Test | ||
| fun `should emit effects`() = runTest { | ||
| val viewModel = TestStateMachineViewModel( | ||
| initialState = "Initial state", | ||
| ) | ||
|
|
||
| viewModel.effect.test { | ||
| viewModel.callEmitEffect("Test effect") | ||
|
|
||
| assertThat(awaitItem()).isEqualTo("Test effect") | ||
|
|
||
| viewModel.callEmitEffect("Another test effect") | ||
|
|
||
| assertThat(awaitItem()).isEqualTo("Another test effect") | ||
| } | ||
| } | ||
|
|
||
| @Test | ||
| fun `handleOneTimeEvent() should execute block`() = runTest { | ||
| val viewModel = TestStateMachineViewModel( | ||
| initialState = "Initial state", | ||
| ) | ||
| var eventHandled = false | ||
|
|
||
| viewModel.callHandleOneTimeEvent(event = "event") { | ||
| eventHandled = true | ||
| } | ||
|
|
||
| assertThat(eventHandled).isTrue() | ||
| } | ||
|
|
||
| @Test | ||
| fun `handleOneTimeEvent() should execute block only once`() = runTest { | ||
| val viewModel = TestStateMachineViewModel( | ||
| initialState = "Initial state", | ||
| ) | ||
| var eventHandledCount = 0 | ||
|
|
||
| repeat(2) { | ||
| viewModel.callHandleOneTimeEvent(event = "event") { | ||
| eventHandledCount++ | ||
| } | ||
| } | ||
|
|
||
| assertThat(eventHandledCount).isEqualTo(1) | ||
| } | ||
|
|
||
| @Test | ||
| fun `handleOneTimeEvent() should support multiple one-time events`() = runTest { | ||
| val viewModel = TestStateMachineViewModel( | ||
| initialState = "Initial state", | ||
| ) | ||
| var eventOneHandled = false | ||
| var eventTwoHandled = false | ||
|
|
||
| viewModel.callHandleOneTimeEvent(event = "eventOne") { | ||
| eventOneHandled = true | ||
| } | ||
|
|
||
| assertThat(eventOneHandled).isTrue() | ||
| assertThat(eventTwoHandled).isFalse() | ||
|
|
||
| viewModel.callHandleOneTimeEvent(event = "eventTwo") { | ||
| eventTwoHandled = true | ||
| } | ||
|
|
||
| assertThat(eventOneHandled).isTrue() | ||
| assertThat(eventTwoHandled).isTrue() | ||
| } | ||
|
|
||
| @Test | ||
| fun `should trigger side effect handlers on state change`() = runTest { | ||
| var sideEffectTriggered = false | ||
| val sideEffectHandler = object : StateSideEffectHandler<String, String>(TestLogger(), {}) { | ||
| override fun accept(event: String, newState: String): Boolean = true | ||
| override suspend fun handle(oldState: String, newState: String) { | ||
| sideEffectTriggered = true | ||
| } | ||
| } | ||
| val factory = StateSideEffectHandler.Factory { _, _ -> sideEffectHandler } | ||
|
|
||
| val viewModel = TestStateMachineViewModel( | ||
| initialState = "Initial state", | ||
| sideEffectHandlersFactories = listOf(factory), | ||
| ) | ||
|
|
||
| viewModel.event("Test event") | ||
| viewModel.state.test { | ||
| val state = awaitItem() | ||
| assertThat(state).isEqualTo("Test event") | ||
| assertThat(sideEffectTriggered).isTrue() | ||
| } | ||
| } | ||
|
|
||
| private class TestStateMachineViewModel( | ||
| initialState: String, | ||
| logger: Logger = TestLogger(), | ||
| override val stateMachine: StateMachine<String, String> = FakeStateMachine(initialState), | ||
| sideEffectHandlersFactories: List<StateSideEffectHandler.Factory<String, String>> = emptyList(), | ||
| ) : StateMachineViewModel<String, String, String>(logger, sideEffectHandlersFactories) { | ||
|
|
||
| fun callEmitEffect(effect: String) { | ||
| emitEffect(effect) | ||
| } | ||
|
|
||
| fun callHandleOneTimeEvent(event: String, block: () -> Unit) { | ||
| handleOneTimeEvent(event, block) | ||
| } | ||
| } | ||
|
|
||
| private class FakeStateMachine(initialState: String) : StateMachine<String, String> { | ||
| private val _currentState = MutableStateFlow(initialState) | ||
| override val currentState: StateFlow<String> = _currentState.asStateFlow() | ||
|
|
||
| override suspend fun process(event: String): String { | ||
| _currentState.value = event | ||
| return _currentState.value | ||
| } | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.