diff --git a/core/common/src/commonMain/kotlin/net/thunderbird/core/common/state/StateMachine.kt b/core/common/src/commonMain/kotlin/net/thunderbird/core/common/state/StateMachine.kt index 8170e104a16..2b054530bb3 100644 --- a/core/common/src/commonMain/kotlin/net/thunderbird/core/common/state/StateMachine.kt +++ b/core/common/src/commonMain/kotlin/net/thunderbird/core/common/state/StateMachine.kt @@ -81,9 +81,7 @@ internal class DefaultStateMachine( init { scope.launch { // delay onEnter initialization so the viewModels are ready to receive the state - println("Executed init. $stateRegistrar") delay(500.milliseconds) - println("Executed? $stateRegistrar") stateRegistrar[initialState::class]?.listeners?.onEnter?.invoke(null, null, currentStateSnapshot) } } diff --git a/core/common/src/commonMain/kotlin/net/thunderbird/core/common/state/sideeffect/StateSideEffectHandler.kt b/core/common/src/commonMain/kotlin/net/thunderbird/core/common/state/sideeffect/StateSideEffectHandler.kt new file mode 100644 index 00000000000..29082ae77e2 --- /dev/null +++ b/core/common/src/commonMain/kotlin/net/thunderbird/core/common/state/sideeffect/StateSideEffectHandler.kt @@ -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( + 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 { + fun create(scope: CoroutineScope, dispatch: suspend (TEvent) -> Unit): StateSideEffectHandler + } +} diff --git a/core/ui/compose/common/build.gradle.kts b/core/ui/compose/common/build.gradle.kts index bfd23d7a8f7..640ab94ca2b 100644 --- a/core/ui/compose/common/build.gradle.kts +++ b/core/ui/compose/common/build.gradle.kts @@ -14,5 +14,8 @@ android { } dependencies { + implementation(projects.core.common) + implementation(projects.core.logging.api) + testImplementation(projects.core.logging.testing) testImplementation(projects.core.ui.compose.testing) } diff --git a/core/ui/compose/common/src/main/kotlin/net/thunderbird/core/ui/compose/common/mvi/StateMachineViewModel.kt b/core/ui/compose/common/src/main/kotlin/net/thunderbird/core/ui/compose/common/mvi/StateMachineViewModel.kt new file mode 100644 index 00000000000..de50921d00c --- /dev/null +++ b/core/ui/compose/common/src/main/kotlin/net/thunderbird/core/ui/compose/common/mvi/StateMachineViewModel.kt @@ -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( + protected val logger: Logger, + sideEffectHandlersFactories: List> = emptyList(), +) : + ViewModel(), + UnidirectionalViewModel { + + /** + * 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 + + override val state: StateFlow get() = stateMachine.currentState + + private val _effect = MutableSharedFlow() + override val effect: SharedFlow = _effect.asSharedFlow() + private val sideEffectHandlers = sideEffectHandlersFactories.map { it.create(viewModelScope, ::event) } + + private val handledOneTimeEvents = mutableSetOf() + + /** + * 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) + } + } +} diff --git a/core/ui/compose/common/src/test/kotlin/net/thunderbird/core/ui/compose/common/mvi/StateMachineViewModelTest.kt b/core/ui/compose/common/src/test/kotlin/net/thunderbird/core/ui/compose/common/mvi/StateMachineViewModelTest.kt new file mode 100644 index 00000000000..8f6bd500e2d --- /dev/null +++ b/core/ui/compose/common/src/test/kotlin/net/thunderbird/core/ui/compose/common/mvi/StateMachineViewModelTest.kt @@ -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(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 = FakeStateMachine(initialState), + sideEffectHandlersFactories: List> = emptyList(), + ) : StateMachineViewModel(logger, sideEffectHandlersFactories) { + + fun callEmitEffect(effect: String) { + emitEffect(effect) + } + + fun callHandleOneTimeEvent(event: String, block: () -> Unit) { + handleOneTimeEvent(event, block) + } + } + + private class FakeStateMachine(initialState: String) : StateMachine { + private val _currentState = MutableStateFlow(initialState) + override val currentState: StateFlow = _currentState.asStateFlow() + + override suspend fun process(event: String): String { + _currentState.value = event + return _currentState.value + } + } +} diff --git a/feature/mail/message/list/api/build.gradle.kts b/feature/mail/message/list/api/build.gradle.kts index 587a84f2f81..7dd2d6505c0 100644 --- a/feature/mail/message/list/api/build.gradle.kts +++ b/feature/mail/message/list/api/build.gradle.kts @@ -11,6 +11,7 @@ dependencies { implementation(projects.core.common) implementation(projects.core.featureflag) + implementation(projects.core.logging.api) implementation(projects.core.preference.api) implementation(projects.core.ui.compose.common) implementation(projects.feature.account.api) diff --git a/feature/mail/message/list/api/src/main/kotlin/net/thunderbird/feature/mail/message/list/ui/MessageListContract.kt b/feature/mail/message/list/api/src/main/kotlin/net/thunderbird/feature/mail/message/list/ui/MessageListContract.kt index ac7450a4479..9420cc15168 100644 --- a/feature/mail/message/list/api/src/main/kotlin/net/thunderbird/feature/mail/message/list/ui/MessageListContract.kt +++ b/feature/mail/message/list/api/src/main/kotlin/net/thunderbird/feature/mail/message/list/ui/MessageListContract.kt @@ -1,6 +1,9 @@ package net.thunderbird.feature.mail.message.list.ui import app.k9mail.core.ui.compose.common.mvi.BaseViewModel +import net.thunderbird.core.common.state.sideeffect.StateSideEffectHandler +import net.thunderbird.core.logging.Logger +import net.thunderbird.core.ui.compose.common.mvi.StateMachineViewModel import net.thunderbird.feature.mail.message.list.ui.effect.MessageListEffect import net.thunderbird.feature.mail.message.list.ui.event.MessageListEvent import net.thunderbird.feature.mail.message.list.ui.state.MessageListState @@ -25,7 +28,13 @@ interface MessageListContract { * @see MessageListEvent * @see MessageListEffect */ - abstract class ViewModel : BaseViewModel( - initialState = MessageListState.WarmingUp(), + abstract class ViewModel( + logger: Logger, + sideEffectHandlersFactories: List, + ) : StateMachineViewModel( + logger, + sideEffectHandlersFactories, ) } + +interface MessageListStateSideEffectHandlerFactory : StateSideEffectHandler.Factory diff --git a/feature/mail/message/list/api/src/main/kotlin/net/thunderbird/feature/mail/message/list/ui/event/MessageListEvent.kt b/feature/mail/message/list/api/src/main/kotlin/net/thunderbird/feature/mail/message/list/ui/event/MessageListEvent.kt index bcaf48541b5..f9c5fdfee17 100644 --- a/feature/mail/message/list/api/src/main/kotlin/net/thunderbird/feature/mail/message/list/ui/event/MessageListEvent.kt +++ b/feature/mail/message/list/api/src/main/kotlin/net/thunderbird/feature/mail/message/list/ui/event/MessageListEvent.kt @@ -89,7 +89,9 @@ sealed interface MessageListEvent { /** * An event that is triggered when the user changes the sort order of the message list. * + * @param accountId The [AccountId] of the account for which the sort order is being changed. When `null`, + * the sort type is for the Unified Inbox. * @param sortType The new [SortType] to apply to the message list. */ - data class ChangeSortType(val sortType: SortType) : UserEvent + data class ChangeSortType(val accountId: AccountId?, val sortType: SortType) : UserEvent } diff --git a/feature/mail/message/list/api/src/main/kotlin/net/thunderbird/feature/mail/message/list/ui/state/Account.kt b/feature/mail/message/list/api/src/main/kotlin/net/thunderbird/feature/mail/message/list/ui/state/Account.kt index 95f53444ce7..d560e91195f 100644 --- a/feature/mail/message/list/api/src/main/kotlin/net/thunderbird/feature/mail/message/list/ui/state/Account.kt +++ b/feature/mail/message/list/api/src/main/kotlin/net/thunderbird/feature/mail/message/list/ui/state/Account.kt @@ -1,14 +1,15 @@ package net.thunderbird.feature.mail.message.list.ui.state import androidx.compose.ui.graphics.Color +import net.thunderbird.feature.account.AccountId /** * The minimum representation of an account with its unique identifier and associated display color. * - * @param uuid The unique identifier for the account. + * @param id The unique identifier for the account. * @param color The color assigned to the account for UI differentiation. */ data class Account( - val uuid: String, + val id: AccountId, val color: Color, ) diff --git a/feature/mail/message/list/internal/src/main/kotlin/net/thunderbird/feature/mail/message/list/internal/FeatureMessageListModule.kt b/feature/mail/message/list/internal/src/main/kotlin/net/thunderbird/feature/mail/message/list/internal/FeatureMessageListModule.kt index d60b4becd7e..80c6cda564d 100644 --- a/feature/mail/message/list/internal/src/main/kotlin/net/thunderbird/feature/mail/message/list/internal/FeatureMessageListModule.kt +++ b/feature/mail/message/list/internal/src/main/kotlin/net/thunderbird/feature/mail/message/list/internal/FeatureMessageListModule.kt @@ -1,5 +1,7 @@ package net.thunderbird.feature.mail.message.list.internal +import net.thunderbird.core.common.inject.factoryListOf +import net.thunderbird.core.common.inject.getList import net.thunderbird.feature.mail.message.list.domain.DomainContract import net.thunderbird.feature.mail.message.list.internal.domain.usecase.BuildSwipeActions import net.thunderbird.feature.mail.message.list.internal.domain.usecase.CreateArchiveFolder @@ -8,7 +10,9 @@ import net.thunderbird.feature.mail.message.list.internal.domain.usecase.SetArch import net.thunderbird.feature.mail.message.list.internal.ui.MessageListViewModel import net.thunderbird.feature.mail.message.list.internal.ui.dialog.SetupArchiveFolderDialogFragment import net.thunderbird.feature.mail.message.list.internal.ui.dialog.SetupArchiveFolderDialogViewModel +import net.thunderbird.feature.mail.message.list.internal.ui.state.machine.MessageListStateMachine import net.thunderbird.feature.mail.message.list.ui.MessageListContract +import net.thunderbird.feature.mail.message.list.ui.MessageListStateSideEffectHandlerFactory import net.thunderbird.feature.mail.message.list.ui.dialog.SetupArchiveFolderDialogContract import net.thunderbird.feature.mail.message.list.ui.dialog.SetupArchiveFolderDialogFragmentFactory import org.koin.core.module.dsl.viewModel @@ -49,8 +53,13 @@ val featureMessageListModule = module { ) as SetupArchiveFolderDialogContract.ViewModel } factory { SetupArchiveFolderDialogFragment.Factory } - - viewModel { parameters -> - MessageListViewModel() + factoryListOf() + factory { MessageListStateMachine.Factory() } + viewModel { + MessageListViewModel( + logger = get(), + messageListStateMachineFactory = get(), + stateSideEffectHandlersFactories = getList(), + ) } } diff --git a/feature/mail/message/list/internal/src/main/kotlin/net/thunderbird/feature/mail/message/list/internal/ui/MessageListViewModel.kt b/feature/mail/message/list/internal/src/main/kotlin/net/thunderbird/feature/mail/message/list/internal/ui/MessageListViewModel.kt index c011e085576..6bc77dad36e 100644 --- a/feature/mail/message/list/internal/src/main/kotlin/net/thunderbird/feature/mail/message/list/internal/ui/MessageListViewModel.kt +++ b/feature/mail/message/list/internal/src/main/kotlin/net/thunderbird/feature/mail/message/list/internal/ui/MessageListViewModel.kt @@ -1,10 +1,30 @@ package net.thunderbird.feature.mail.message.list.internal.ui +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import net.thunderbird.core.common.state.StateMachine +import net.thunderbird.core.logging.Logger +import net.thunderbird.feature.mail.message.list.internal.ui.state.machine.MessageListStateMachine import net.thunderbird.feature.mail.message.list.ui.MessageListContract +import net.thunderbird.feature.mail.message.list.ui.MessageListStateSideEffectHandlerFactory import net.thunderbird.feature.mail.message.list.ui.event.MessageListEvent +import net.thunderbird.feature.mail.message.list.ui.state.MessageListState -class MessageListViewModel : MessageListContract.ViewModel() { - override fun event(event: MessageListEvent) { - // TODO(9497): Handle events. +private const val TAG = "MessageListViewModel" + +class MessageListViewModel( + logger: Logger, + messageListStateMachineFactory: MessageListStateMachine.Factory, + stateSideEffectHandlersFactories: List, +) : MessageListContract.ViewModel(logger, stateSideEffectHandlersFactories) { + override val stateMachine: StateMachine = messageListStateMachineFactory + .create(viewModelScope, ::event) + + init { + logger.verbose(TAG) { "init() called" } + state + .onEach { state -> logger.verbose(TAG) { "state.onEach called with: state = $state" } } + .launchIn(viewModelScope) } } diff --git a/feature/mail/message/list/internal/src/main/kotlin/net/thunderbird/feature/mail/message/list/internal/ui/state/machine/MessageListStateMachine.kt b/feature/mail/message/list/internal/src/main/kotlin/net/thunderbird/feature/mail/message/list/internal/ui/state/machine/MessageListStateMachine.kt new file mode 100644 index 00000000000..11635ae0213 --- /dev/null +++ b/feature/mail/message/list/internal/src/main/kotlin/net/thunderbird/feature/mail/message/list/internal/ui/state/machine/MessageListStateMachine.kt @@ -0,0 +1,48 @@ +package net.thunderbird.feature.mail.message.list.internal.ui.state.machine + +import kotlinx.coroutines.CoroutineScope +import net.thunderbird.core.common.state.StateMachine +import net.thunderbird.core.common.state.builder.stateMachine +import net.thunderbird.core.common.state.sideeffect.StateSideEffectHandler +import net.thunderbird.feature.mail.message.list.ui.event.MessageListEvent +import net.thunderbird.feature.mail.message.list.ui.state.MessageListState + +/** + * Manages the state transitions for the message list UI. + * + * This class orchestrates the overall state of the message list screen by processing incoming [MessageListEvent]s + * and transitioning between different [MessageListState]s. It encapsulates the core logic for how the UI should + * react to user actions and data loading events. + * + * The state machine is defined using a DSL that configures transitions for various states like loading, + * displaying messages, selection mode, and search mode. + * + * Upon a state change, it delegates to a list of [StateSideEffectHandler]s, which are responsible for + * executing side effects such as fetching data from a repository, navigating, or showing toasts. This separation + * keeps the state management logic clean and focused. + * + * @param dispatch A function to send new events back into the state machine, allowing for event-driven side effects. + * @param stateMachine The underlying state machine implementation, configured with all possible states and transitions. + */ +class MessageListStateMachine( + private val scope: CoroutineScope, + private val dispatch: (MessageListEvent) -> Unit, + private val stateMachine: StateMachine = stateMachine(scope) { + warmingUpInitialState(initialState = MessageListState.WarmingUp(), dispatch) + globalState() + loadingMessagesState() + loadedMessagesState() + selectingMessagesState() + searchingMessagesState() + }, +) : StateMachine by stateMachine { + class Factory { + fun create( + scope: CoroutineScope, + dispatch: (MessageListEvent) -> Unit, + ): MessageListStateMachine = MessageListStateMachine( + scope = scope, + dispatch = dispatch, + ) + } +} diff --git a/feature/mail/message/list/internal/src/main/kotlin/net/thunderbird/feature/mail/message/list/internal/ui/state/machine/SetupGlobalState.kt b/feature/mail/message/list/internal/src/main/kotlin/net/thunderbird/feature/mail/message/list/internal/ui/state/machine/SetupGlobalState.kt new file mode 100644 index 00000000000..63cd8fbf138 --- /dev/null +++ b/feature/mail/message/list/internal/src/main/kotlin/net/thunderbird/feature/mail/message/list/internal/ui/state/machine/SetupGlobalState.kt @@ -0,0 +1,52 @@ +package net.thunderbird.feature.mail.message.list.internal.ui.state.machine + +import kotlinx.collections.immutable.toImmutableMap +import kotlinx.collections.immutable.toPersistentMap +import net.thunderbird.core.common.state.builder.StateMachineBuilder +import net.thunderbird.feature.mail.message.list.ui.event.MessageListEvent +import net.thunderbird.feature.mail.message.list.ui.state.MessageListState + +/** + * Sets up global state transitions that can occur from any state. + * + * This includes transitions for events that are not specific to a single state, + * such as updating user preferences. By defining these transitions on the parent + * [MessageListState], we avoid duplicating the logic in every single sub-state. + */ +@Suppress("CyclomaticComplexMethod") +internal fun StateMachineBuilder.globalState() { + state { + transition { state, event -> + when (state) { + is MessageListState.LoadedMessages -> state.copy(preferences = event.preferences) + is MessageListState.LoadingMessages -> state.copy(preferences = event.preferences) + is MessageListState.SearchingMessages -> state.copy(preferences = event.preferences) + is MessageListState.SelectingMessages -> state.copy(preferences = event.preferences) + is MessageListState.WarmingUp -> state.copy(preferences = event.preferences) + } + } + + transition { state, (accountId, sortType) -> + val newSelectedSortTypes = state.selectedSortTypes.toMutableMap().apply { + this[accountId] = sortType + }.toPersistentMap() + when (state) { + is MessageListState.LoadedMessages -> state.copy(selectedSortTypes = newSelectedSortTypes) + is MessageListState.LoadingMessages -> state.copy(selectedSortTypes = newSelectedSortTypes) + is MessageListState.SearchingMessages -> state.copy(selectedSortTypes = newSelectedSortTypes) + is MessageListState.SelectingMessages -> state.copy(selectedSortTypes = newSelectedSortTypes) + is MessageListState.WarmingUp -> state.copy(selectedSortTypes = newSelectedSortTypes) + } + } + + transition { state, (swipeActions) -> + when (state) { + is MessageListState.LoadedMessages -> state.copy(swipeActions = swipeActions.toImmutableMap()) + is MessageListState.LoadingMessages -> state.copy(swipeActions = swipeActions.toImmutableMap()) + is MessageListState.SearchingMessages -> state.copy(swipeActions = swipeActions.toImmutableMap()) + is MessageListState.SelectingMessages -> state.copy(swipeActions = swipeActions.toImmutableMap()) + is MessageListState.WarmingUp -> state.copy(swipeActions = swipeActions.toImmutableMap()) + } + } + } +} diff --git a/feature/mail/message/list/internal/src/main/kotlin/net/thunderbird/feature/mail/message/list/internal/ui/state/machine/SetupLoadedMessagesState.kt b/feature/mail/message/list/internal/src/main/kotlin/net/thunderbird/feature/mail/message/list/internal/ui/state/machine/SetupLoadedMessagesState.kt new file mode 100644 index 00000000000..f673a134410 --- /dev/null +++ b/feature/mail/message/list/internal/src/main/kotlin/net/thunderbird/feature/mail/message/list/internal/ui/state/machine/SetupLoadedMessagesState.kt @@ -0,0 +1,65 @@ +package net.thunderbird.feature.mail.message.list.internal.ui.state.machine + +import kotlinx.collections.immutable.toPersistentList +import net.thunderbird.core.common.state.builder.StateMachineBuilder +import net.thunderbird.feature.mail.message.list.ui.event.MessageItemEvent +import net.thunderbird.feature.mail.message.list.ui.event.MessageListEvent +import net.thunderbird.feature.mail.message.list.ui.event.MessageListSearchEvent +import net.thunderbird.feature.mail.message.list.ui.state.MessageListState + +/** + * Defines the state transitions for the [MessageListState.LoadedMessages] state. + * + * This state represents the default view where the message list has been successfully + * loaded and is displayed to the user. + * From here, the user can transition into selection mode or search mode. + * + * Transitions: + * - On [MessageItemEvent.ToggleSelectMessages]: Moves to [MessageListState.SelectingMessages], + * toggling the selected state of the specified messages. + * - On [MessageListEvent.EnterSelectionMode]: Moves to [MessageListState.SelectingMessages] + * without changing any message's selected state. + * - On [MessageListSearchEvent.EnterSearchMode]: Moves to [MessageListState.SearchingMessages] + * with an empty search query. + */ +internal fun StateMachineBuilder.loadedMessagesState() { + state { + transition { state, event -> + MessageListState.SelectingMessages( + folder = null, + messages = state.messages.map { message -> + if (message in event.messages) message.copy(selected = !message.selected) else message + }.toPersistentList(), + preferences = state.preferences, + swipeActions = state.swipeActions, + selectedSortTypes = state.selectedSortTypes, + activeMessage = state.activeMessage, + isActive = state.isActive, + ) + } + transition { state, _ -> + MessageListState.SelectingMessages( + folder = null, + messages = state.messages, + preferences = state.preferences, + swipeActions = state.swipeActions, + selectedSortTypes = state.selectedSortTypes, + activeMessage = state.activeMessage, + isActive = state.isActive, + ) + } + transition { state, _ -> + MessageListState.SearchingMessages( + searchQuery = "", + isServerSearch = false, + folder = null, + messages = state.messages, + preferences = state.preferences, + swipeActions = state.swipeActions, + selectedSortTypes = state.selectedSortTypes, + activeMessage = state.activeMessage, + isActive = state.isActive, + ) + } + } +} diff --git a/feature/mail/message/list/internal/src/main/kotlin/net/thunderbird/feature/mail/message/list/internal/ui/state/machine/SetupLoadingMessagesState.kt b/feature/mail/message/list/internal/src/main/kotlin/net/thunderbird/feature/mail/message/list/internal/ui/state/machine/SetupLoadingMessagesState.kt new file mode 100644 index 00000000000..d775f5f366a --- /dev/null +++ b/feature/mail/message/list/internal/src/main/kotlin/net/thunderbird/feature/mail/message/list/internal/ui/state/machine/SetupLoadingMessagesState.kt @@ -0,0 +1,36 @@ +package net.thunderbird.feature.mail.message.list.internal.ui.state.machine + +import kotlinx.collections.immutable.toPersistentList +import net.thunderbird.core.common.state.builder.StateMachineBuilder +import net.thunderbird.feature.mail.message.list.ui.event.MessageListEvent +import net.thunderbird.feature.mail.message.list.ui.state.MessageListState + +/** + * Defines the behavior of the state machine when it is in the [MessageListState.LoadingMessages] state. + * + * This state handles the following transitions: + * - On [MessageListEvent.UpdateLoadingProgress]: Updates the loading progress indicator. + * - On [MessageListEvent.MessagesLoaded]: Transitions to the [MessageListState.LoadedMessages] state, but only + * if the loading progress has reached 100% (`progress == 1f`). This ensures a smooth transition + * after the loading animation completes. + */ +internal fun StateMachineBuilder.loadingMessagesState() { + state { + transition { state, event -> + state.copy(progress = event.progress) + } + transition( + guard = { state, _ -> state.progress == 1f }, + ) { state, event -> + MessageListState.LoadedMessages( + folder = null, + messages = event.messages.toPersistentList(), + preferences = state.preferences, + swipeActions = state.swipeActions, + selectedSortTypes = state.selectedSortTypes, + activeMessage = null, + isActive = true, + ) + } + } +} diff --git a/feature/mail/message/list/internal/src/main/kotlin/net/thunderbird/feature/mail/message/list/internal/ui/state/machine/SetupSearchingMessagesState.kt b/feature/mail/message/list/internal/src/main/kotlin/net/thunderbird/feature/mail/message/list/internal/ui/state/machine/SetupSearchingMessagesState.kt new file mode 100644 index 00000000000..4bb126f7ebc --- /dev/null +++ b/feature/mail/message/list/internal/src/main/kotlin/net/thunderbird/feature/mail/message/list/internal/ui/state/machine/SetupSearchingMessagesState.kt @@ -0,0 +1,45 @@ +package net.thunderbird.feature.mail.message.list.internal.ui.state.machine + +import net.thunderbird.core.common.state.builder.StateMachineBuilder +import net.thunderbird.feature.mail.message.list.ui.event.MessageListEvent +import net.thunderbird.feature.mail.message.list.ui.event.MessageListSearchEvent +import net.thunderbird.feature.mail.message.list.ui.state.MessageListState + +/** + * Defines the state transitions for the [MessageListState.SearchingMessages] state. + * + * This state handles the logic for when the user is actively searching for messages. It manages + * transitions for updating the search query, switching to a remote (server-side) search, and + * exiting the search mode to return to the normal message list view. + * + * - On [MessageListSearchEvent.UpdateSearchQuery]: Updates the current search query and resets to local search. + * - On [MessageListSearchEvent.SearchRemotely]: Sets the search to be performed on the server. + * - On [MessageListSearchEvent.ExitSearchMode]: Transitions back to the [MessageListState.LoadedMessages] state, + * effectively ending the search. + */ +internal fun StateMachineBuilder.searchingMessagesState() { + state { + transition { state, event -> + state.copy(searchQuery = event.query) + } + + transition { state, _ -> + state.copy( + searchQuery = state.searchQuery, + isServerSearch = true, + ) + } + + transition { state, _ -> + MessageListState.LoadedMessages( + folder = null, + messages = state.messages, + preferences = state.preferences, + swipeActions = state.swipeActions, + selectedSortTypes = state.selectedSortTypes, + activeMessage = state.activeMessage, + isActive = state.isActive, + ) + } + } +} diff --git a/feature/mail/message/list/internal/src/main/kotlin/net/thunderbird/feature/mail/message/list/internal/ui/state/machine/SetupSelectingMessagesState.kt b/feature/mail/message/list/internal/src/main/kotlin/net/thunderbird/feature/mail/message/list/internal/ui/state/machine/SetupSelectingMessagesState.kt new file mode 100644 index 00000000000..e808da0810d --- /dev/null +++ b/feature/mail/message/list/internal/src/main/kotlin/net/thunderbird/feature/mail/message/list/internal/ui/state/machine/SetupSelectingMessagesState.kt @@ -0,0 +1,40 @@ +package net.thunderbird.feature.mail.message.list.internal.ui.state.machine + +import kotlinx.collections.immutable.toPersistentList +import net.thunderbird.core.common.state.builder.StateMachineBuilder +import net.thunderbird.feature.mail.message.list.ui.event.MessageItemEvent +import net.thunderbird.feature.mail.message.list.ui.event.MessageListEvent +import net.thunderbird.feature.mail.message.list.ui.state.MessageListState + +/** + * Defines the state transitions for when the user is actively selecting messages. + * + * This state is entered when the user long-presses a message, initiating selection mode. + * + * It handles the following events: + * - [MessageItemEvent.ToggleSelectMessages]: Toggles the selection status of one or more messages. + * - [MessageListEvent.ExitSelectionMode]: Exits selection mode, deselecting all messages and returning + * to the [MessageListState.LoadedMessages] state. + */ +internal fun StateMachineBuilder.selectingMessagesState() { + state { + transition { state, event -> + state.copy( + messages = state.messages.map { message -> + if (message in event.messages) message.copy(selected = !message.selected) else message + }.toPersistentList(), + ) + } + transition { state, _ -> + MessageListState.LoadedMessages( + folder = null, + messages = state.messages.map { message -> message.copy(selected = false) }.toPersistentList(), + preferences = state.preferences, + swipeActions = state.swipeActions, + selectedSortTypes = state.selectedSortTypes, + activeMessage = state.activeMessage, + isActive = state.isActive, + ) + } + } +} diff --git a/feature/mail/message/list/internal/src/main/kotlin/net/thunderbird/feature/mail/message/list/internal/ui/state/machine/SetupWarmingUpInitialState.kt b/feature/mail/message/list/internal/src/main/kotlin/net/thunderbird/feature/mail/message/list/internal/ui/state/machine/SetupWarmingUpInitialState.kt new file mode 100644 index 00000000000..c54c34ea6da --- /dev/null +++ b/feature/mail/message/list/internal/src/main/kotlin/net/thunderbird/feature/mail/message/list/internal/ui/state/machine/SetupWarmingUpInitialState.kt @@ -0,0 +1,50 @@ +package net.thunderbird.feature.mail.message.list.internal.ui.state.machine + +import kotlinx.collections.immutable.toImmutableMap +import net.thunderbird.core.common.state.builder.StateMachineBuilder +import net.thunderbird.feature.mail.message.list.ui.event.MessageListEvent +import net.thunderbird.feature.mail.message.list.ui.state.MessageListState + +/** + * Defines the behavior for the [MessageListState.WarmingUp] state. + * + * This state is responsible for loading initial configurations, such as user preferences, + * swipe actions, and sort types, before transitioning to the message loading state. + * + * - On entering this state, it dispatches [MessageListEvent.LoadConfigurations] to trigger the loading process. + * - It handles updates for preferences, swipe actions, and sort types as they become available. + * - Once all required configurations are loaded ([MessageListState.WarmingUp.isReady] is `true`), + * it transitions to the [MessageListState.LoadingMessages] state upon receiving the + * [MessageListEvent.AllConfigsReady] event. + * + * @param initialState The initial [MessageListState.WarmingUp] instance. + * @param dispatch A function to send events to the state machine. + */ +internal fun StateMachineBuilder.warmingUpInitialState( + initialState: MessageListState.WarmingUp, + dispatch: (MessageListEvent) -> Unit = {}, +) { + initialState(state = initialState) { + onEnter { _, _ -> + dispatch(MessageListEvent.LoadConfigurations) + } + transition { state, _ -> state.copy(isActive = true) } + transition { state, event -> + state.copy(preferences = event.preferences) + } + transition { state, event -> + state.copy(selectedSortTypes = event.sortTypes.toImmutableMap()) + } + transition( + guard = { state, _ -> state.isReady }, + ) { state, _ -> + MessageListState.LoadingMessages( + progress = 0f, + swipeActions = state.swipeActions, + preferences = requireNotNull(state.preferences), + selectedSortTypes = state.selectedSortTypes, + folder = null, + ) + } + } +} diff --git a/feature/mail/message/list/internal/src/test/kotlin/net/thunderbird/feature/mail/message/list/internal/ui/state/machine/MessageListStateMachineTest.kt b/feature/mail/message/list/internal/src/test/kotlin/net/thunderbird/feature/mail/message/list/internal/ui/state/machine/MessageListStateMachineTest.kt new file mode 100644 index 00000000000..ebd13200307 --- /dev/null +++ b/feature/mail/message/list/internal/src/test/kotlin/net/thunderbird/feature/mail/message/list/internal/ui/state/machine/MessageListStateMachineTest.kt @@ -0,0 +1,715 @@ +package net.thunderbird.feature.mail.message.list.internal.ui.state.machine + +import androidx.compose.ui.graphics.Color +import app.cash.turbine.test +import assertk.all +import assertk.assertThat +import assertk.assertions.isEqualTo +import assertk.assertions.isInstanceOf +import assertk.assertions.isNull +import assertk.assertions.isTrue +import assertk.assertions.prop +import dev.mokkery.spy +import dev.mokkery.verify +import dev.mokkery.verify.VerifyMode +import kotlin.test.Test +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.ImmutableSet +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.persistentSetOf +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import net.thunderbird.core.common.action.SwipeAction +import net.thunderbird.core.common.action.SwipeActions +import net.thunderbird.core.preference.display.visualSettings.message.list.UiDensity +import net.thunderbird.feature.account.AccountId +import net.thunderbird.feature.account.AccountIdFactory +import net.thunderbird.feature.mail.message.list.preferences.ActionRequiringUserConfirmation +import net.thunderbird.feature.mail.message.list.preferences.MessageListDateTimeFormat +import net.thunderbird.feature.mail.message.list.preferences.MessageListPreferences +import net.thunderbird.feature.mail.message.list.ui.event.MessageItemEvent +import net.thunderbird.feature.mail.message.list.ui.event.MessageListEvent +import net.thunderbird.feature.mail.message.list.ui.event.MessageListSearchEvent +import net.thunderbird.feature.mail.message.list.ui.state.Account +import net.thunderbird.feature.mail.message.list.ui.state.EmailIdentity +import net.thunderbird.feature.mail.message.list.ui.state.MessageItemAttachment +import net.thunderbird.feature.mail.message.list.ui.state.MessageItemUi +import net.thunderbird.feature.mail.message.list.ui.state.MessageItemUi.State +import net.thunderbird.feature.mail.message.list.ui.state.MessageListState +import net.thunderbird.feature.mail.message.list.ui.state.SortType + +@Suppress("MaxLineLength") +@OptIn(ExperimentalCoroutinesApi::class) +class MessageListStateMachineTest { + private fun TestScope.createStateMachine(dispatch: (MessageListEvent) -> Unit = {}) = MessageListStateMachine( + scope = this, + dispatch = dispatch, + ) + + // region [WarmingUp state] + @Test + fun `stateMachine should trigger LoadConfigurations event when it is initialized`() = runTest { + // Arrange + val dispatch = spy<(MessageListEvent) -> Unit>(obj = {}) + // Act + createStateMachine(dispatch) + advanceUntilIdle() + // Assert + verify(mode = VerifyMode.exactly(1)) { dispatch(MessageListEvent.LoadConfigurations) } + } + + @Test + fun `process() should stay on WarmingUp state when state is WarmingUp and event is LoadConfigurations`() = + runTest { + // Arrange + val stateMachine = createStateMachine() + advanceUntilIdle() + + // Act + stateMachine.process(event = MessageListEvent.LoadConfigurations) + + // Assert + stateMachine.currentState.test { + val state = awaitItem() + expectNoEvents() + assertThat(state).isInstanceOf() + } + } + + @Test + fun `process() should stay on WarmingUp state when state is WarmingUp and event is UpdatePreferences`() = + runTest { + // Arrange + val stateMachine = createStateMachine() + advanceUntilIdle() + + // Act + stateMachine.process( + event = MessageListEvent.UpdatePreferences( + preferences = createMessageListPreferences(), + ), + ) + + // Assert + stateMachine.currentState.test { + val state = awaitItem() + expectNoEvents() + assertThat(state).isInstanceOf() + } + } + + @Test + fun `process() should stay on WarmingUp state when state is WarmingUp and event is SortTypesLoaded`() = + runTest { + // Arrange + val stateMachine = createStateMachine() + advanceUntilIdle() + + // Act + stateMachine.process(event = MessageListEvent.SortTypesLoaded(emptyMap())) + + // Assert + stateMachine.currentState.test { + val state = awaitItem() + expectNoEvents() + assertThat(state).isInstanceOf() + } + } + + @Test + fun `process() should not change state to LoadedMessages when event is AllConfigsReady but state is not ready`() = + runTest { + // Arrange + val stateMachine = createStateMachine() + advanceUntilIdle() + + // Act + stateMachine.process(event = MessageListEvent.AllConfigsReady) + + // Assert + stateMachine.currentState.test { + val state = awaitItem() + expectNoEvents() + assertThat(state).isInstanceOf() + } + } + + @Test + fun `process() should change state to LoadedMessages when event is AllConfigsReady`() = runTest { + // Arrange + val stateMachine = createStateMachine() + val preferences = createMessageListPreferences() + val sortTypes = mapOf(null to SortType.DateDesc) + val swipeActions = mapOf( + AccountIdFactory.create() to SwipeActions(SwipeAction.None, SwipeAction.None), + ) + advanceUntilIdle() + + // Act + stateMachine.process(event = MessageListEvent.AllConfigsReady) + + // Assert + stateMachine.currentState.test { + assertThat(awaitItem()).isInstanceOf() + + stateMachine.process(MessageListEvent.UpdatePreferences(preferences)) + assertThat(awaitItem()).isInstanceOf() + + stateMachine.process(MessageListEvent.SortTypesLoaded(sortTypes)) + assertThat(awaitItem()).isInstanceOf() + + stateMachine.process(MessageListEvent.SwipeActionsLoaded(swipeActions)) + assertThat(awaitItem()).isInstanceOf() + stateMachine.process(event = MessageListEvent.AllConfigsReady) + assertThat(awaitItem()) + .isInstanceOf() + .all { + prop(MessageListState.LoadingMessages::preferences).isEqualTo(preferences) + prop(MessageListState.LoadingMessages::swipeActions).isEqualTo(swipeActions) + prop(MessageListState.LoadingMessages::selectedSortTypes).isEqualTo(sortTypes) + prop(MessageListState.LoadingMessages::progress).isEqualTo(0f) + prop(MessageListState.LoadingMessages::folder).isNull() + } + + expectNoEvents() + } + } + // endregion [WarmingUp state] + + // region [LoadingMessages state] + @Test + fun `process() should update LoadingMessages state with progress when state is LoadingMessages and event is UpdateLoadingProgress`() = + runTest { + // Arrange + val firstExpectedProgress = .3f + val secondExpectedProgress = .5f + val lastExpectedProgress = 1f + val stateMachine = createStateMachineOnLoadingState() + advanceUntilIdle() + + stateMachine.currentState.test { + // enforce correct state before acting. + assertThat(expectMostRecentItem()).isInstanceOf() + + // Act (Phase 1) + stateMachine.process(MessageListEvent.UpdateLoadingProgress(progress = firstExpectedProgress)) + + // Assert (Phase 1) + assertThat(awaitItem()) + .isInstanceOf() + .prop(MessageListState.LoadingMessages::progress) + .isEqualTo(firstExpectedProgress) + + // Act (Phase 2) + stateMachine.process(MessageListEvent.UpdateLoadingProgress(progress = secondExpectedProgress)) + + // Assert (Phase 2) + assertThat(awaitItem()) + .isInstanceOf() + .prop(MessageListState.LoadingMessages::progress) + .isEqualTo(secondExpectedProgress) + + // Act (Phase 3) + stateMachine.process(MessageListEvent.UpdateLoadingProgress(progress = lastExpectedProgress)) + + // Assert (Phase 3) + assertThat(awaitItem()) + .isInstanceOf() + .prop(MessageListState.LoadingMessages::progress) + .isEqualTo(lastExpectedProgress) + + expectNoEvents() + } + } + + @Test + fun `process() should not move to LoadedMessages state when state is LoadingMessages, event is MessagesLoaded but progress is not 1f`() = + runTest { + // Arrange + val stateMachine = createStateMachineOnLoadingState() + advanceUntilIdle() + + stateMachine.currentState.test { + // enforce correct state before acting. + assertThat(expectMostRecentItem()).isInstanceOf() + + // Act (Phase 1) + stateMachine.process(MessageListEvent.UpdateLoadingProgress(progress = .5f)) + + // Assert (Phase 1) + assertThat(awaitItem()) + .isInstanceOf() + + // Act (Phase 2) + stateMachine.process(MessageListEvent.MessagesLoaded(messages = emptyList())) + + // Assert (Phase 2) + expectNoEvents() + } + } + + @Test + fun `process() should move to LoadedMessages state when state is LoadingMessages, progress is 1f, and event is MessagesLoaded`() = + runTest { + // Arrange + val accountId = AccountIdFactory.create() + val messages = createMessageUiItemList(size = 10, accountId = accountId) + val preferences: MessageListPreferences = createMessageListPreferences( + density = UiDensity.Compact, + ) + val sortTypes: Map = mapOf(accountId to SortType.DateDesc) + val swipeActions: Map = mapOf( + accountId to SwipeActions(SwipeAction.None, SwipeAction.None), + ) + val stateMachine = createStateMachineOnLoadingState( + preferences = preferences, + sortTypes = sortTypes, + swipeActions = swipeActions, + ) + advanceUntilIdle() + + stateMachine.currentState.test { + // enforce correct state before acting. + assertThat(expectMostRecentItem()).isInstanceOf() + + // Act (Phase 1) + stateMachine.process(MessageListEvent.UpdateLoadingProgress(progress = 1f)) + + // Assert (Phase 1) + assertThat(awaitItem()).isInstanceOf() + + // Act (Phase 2) + stateMachine.process(MessageListEvent.MessagesLoaded(messages = messages)) + + // Assert (Phase 2) + assertThat(awaitItem()) + .isInstanceOf() + .all { + prop(MessageListState.LoadedMessages::folder).isNull() + prop(MessageListState.LoadedMessages::messages).isEqualTo(messages) + prop(MessageListState.LoadedMessages::activeMessage).isNull() + prop(MessageListState.LoadedMessages::preferences).isEqualTo(preferences) + prop(MessageListState.LoadedMessages::swipeActions).isEqualTo(swipeActions) + prop(MessageListState.LoadedMessages::selectedSortTypes).isEqualTo(sortTypes) + prop(MessageListState.LoadedMessages::isActive).isTrue() + } + } + } + // endregion [LoadingMessages state] + + // region [LoadedMessages state] + @Test + fun `process() should move to SelectingMessages when event is ToggleSelectMessages`() = + runTest { + // Arrange + val messages = createMessageUiItemList(size = 20) + val toggleSelection = messages.take(5) + val stateMachine = createStateMachineOnLoadedState(messages = messages) + advanceUntilIdle() + + stateMachine.currentState.test { + // enforce correct state before acting. + assertThat(expectMostRecentItem()).isInstanceOf() + + // Act + stateMachine.process(MessageItemEvent.ToggleSelectMessages(messages = toggleSelection)) + + // Assert + assertThat(awaitItem()) + .isInstanceOf() + .transform { it.messages } + .isEqualTo( + messages.mapIndexed { index, message -> + if (index in toggleSelection.indices) { + message.copy(selected = !message.selected) + } else { + message + } + }, + ) + + expectNoEvents() + } + } + + @Test + fun `process() should move to SelectingMessages when event is EnterSelectionMode`() = + runTest { + // Arrange + val messages = createMessageUiItemList(size = 5) + val stateMachine = createStateMachineOnLoadedState(messages = messages) + advanceUntilIdle() + + stateMachine.currentState.test { + // enforce correct state before acting. + assertThat(expectMostRecentItem()).isInstanceOf() + + // Act + stateMachine.process(MessageListEvent.EnterSelectionMode) + + // Assert + assertThat(awaitItem()) + .isInstanceOf() + .prop(MessageListState.SelectingMessages::messages).isEqualTo(messages) + + expectNoEvents() + } + } + + @Test + fun `process() should move to SearchingMessages when event is EnterSearchMode`() = + runTest { + // Arrange + val messages = createMessageUiItemList(size = 10) + val stateMachine = createStateMachineOnLoadedState(messages = messages) + advanceUntilIdle() + + stateMachine.currentState.test { + // enforce correct state before acting. + assertThat(expectMostRecentItem()).isInstanceOf() + + // Act + stateMachine.process(MessageListSearchEvent.EnterSearchMode) + + // Assert + assertThat(awaitItem()) + .isInstanceOf().all { + prop(MessageListState.SearchingMessages::searchQuery).isEqualTo("") + prop(MessageListState.SearchingMessages::isServerSearch).isEqualTo(false) + prop(MessageListState.SearchingMessages::messages).isEqualTo(messages) + } + + expectNoEvents() + } + } + // endregion [LoadedMessages state] + + // region [SelectingMessages state] + @Test + fun `process() should update SelectingMessages's message selection when event is ToggleSelectMessages`() = + runTest { + // Arrange + val messages = createMessageUiItemList(size = 20) + val toggleSelection = messages.take(5) + val stateMachine = createStateMachineOnSelectingMessages(messages = messages) + advanceUntilIdle() + + stateMachine.currentState.test { + // enforce correct state before acting. + assertThat(expectMostRecentItem()).isInstanceOf() + + // Act + stateMachine.process(MessageItemEvent.ToggleSelectMessages(messages = toggleSelection)) + + // Assert + assertThat(awaitItem()) + .isInstanceOf() + .transform { it.messages } + .isEqualTo( + messages.mapIndexed { index, message -> + if (index in toggleSelection.indices) { + message.copy(selected = !message.selected) + } else { + message + } + }, + ) + + expectNoEvents() + } + } + + @Test + fun `process() should move to LoadedMessages when state is SelectingMessages and event is ExitSelectionMode`() = + runTest { + // Arrange + val messages = createMessageUiItemList(size = 20) + .mapIndexed { index, message -> if (index < 5) message.copy(selected = true) else message } + val stateMachine = createStateMachineOnSelectingMessages(messages = messages) + advanceUntilIdle() + + stateMachine.currentState.test { + // enforce correct state before acting. + assertThat(expectMostRecentItem()).isInstanceOf() + + // Act + stateMachine.process(MessageListEvent.ExitSelectionMode) + + // Assert + assertThat(awaitItem()) + .isInstanceOf() + .prop(MessageListState.LoadedMessages::messages) + .isEqualTo(messages.map { it.copy(selected = false) }) + + expectNoEvents() + } + } + // endregion [SelectingMessages state] + + // region [SearchingMessages state] + @Test + fun `process() should update SearchingMessages's searchQuery when event is UpdateSearchQuery`() = + runTest { + // Arrange + val firstQuery = "first query" + val secondQuery = "second query" + val lastQuery = "last query" + val messages = createMessageUiItemList(size = 20) + val stateMachine = createStateMachineOnSearchingMessages(messages = messages) + advanceUntilIdle() + + stateMachine.currentState.test { + // enforce correct state before acting. + assertThat(expectMostRecentItem()).isInstanceOf() + + // Act (Phase 1) + stateMachine.process(MessageListSearchEvent.UpdateSearchQuery(query = firstQuery)) + + // Assert (Phase 1) + assertThat(awaitItem()) + .isInstanceOf() + .prop(MessageListState.SearchingMessages::searchQuery).isEqualTo(firstQuery) + + // Act (Phase 2) + stateMachine.process(MessageListSearchEvent.UpdateSearchQuery(query = secondQuery)) + + // Assert (Phase 2) + assertThat(awaitItem()) + .isInstanceOf() + .prop(MessageListState.SearchingMessages::searchQuery).isEqualTo(secondQuery) + + // Act (Phase 3) + stateMachine.process(MessageListSearchEvent.UpdateSearchQuery(query = lastQuery)) + + // Assert (Phase 3) + assertThat(awaitItem()) + .isInstanceOf() + .prop(MessageListState.SearchingMessages::searchQuery).isEqualTo(lastQuery) + + expectNoEvents() + } + } + + @Test + fun `process() should update SearchingMessages's isServerSearch when event is SearchRemotely`() = + runTest { + // Arrange + val messages = createMessageUiItemList(size = 20) + val stateMachine = createStateMachineOnSearchingMessages(messages = messages) + advanceUntilIdle() + + stateMachine.currentState.test { + // enforce correct state before acting. + assertThat(expectMostRecentItem()).isInstanceOf() + + // Act + stateMachine.process(MessageListSearchEvent.SearchRemotely) + + // Assert + assertThat(awaitItem()) + .isInstanceOf() + .prop(MessageListState.SearchingMessages::isServerSearch).isTrue() + + expectNoEvents() + } + } + + @Test + fun `process() should move to LoadedMessages when state is SearchingMessages and event is ExitSearchMode`() = + runTest { + // Arrange + val messages = createMessageUiItemList(size = 20) + val stateMachine = createStateMachineOnSearchingMessages(messages = messages) + advanceUntilIdle() + + stateMachine.currentState.test { + // enforce correct state before acting. + assertThat(expectMostRecentItem()).isInstanceOf() + + // Act + stateMachine.process(MessageListSearchEvent.ExitSearchMode) + + // Assert + assertThat(awaitItem()) + .isInstanceOf() + .prop(MessageListState.LoadedMessages::messages) + .isEqualTo(messages) + + expectNoEvents() + } + } + // endregion [SearchingMessages state] + + private suspend fun TestScope.createStateMachineOnLoadingState( + preferences: MessageListPreferences = createMessageListPreferences(), + sortTypes: Map = mapOf(null to SortType.DateDesc), + swipeActions: Map = mapOf( + AccountIdFactory.create() to SwipeActions(SwipeAction.None, SwipeAction.None), + ), + ): MessageListStateMachine { + val stateMachine = createStateMachine() + advanceUntilIdle() + stateMachine.process(MessageListEvent.UpdatePreferences(preferences)) + stateMachine.process(MessageListEvent.SortTypesLoaded(sortTypes)) + stateMachine.process(MessageListEvent.SwipeActionsLoaded(swipeActions)) + stateMachine.process(event = MessageListEvent.AllConfigsReady) + advanceUntilIdle() + return stateMachine + } + + private suspend fun TestScope.createStateMachineOnLoadedState( + messages: List, + preferences: MessageListPreferences = createMessageListPreferences(), + sortTypes: Map = mapOf(null to SortType.DateDesc), + swipeActions: Map = mapOf( + AccountIdFactory.create() to SwipeActions(SwipeAction.None, SwipeAction.None), + ), + ): MessageListStateMachine { + val stateMachine = createStateMachine() + advanceUntilIdle() + stateMachine.process(MessageListEvent.UpdatePreferences(preferences)) + stateMachine.process(MessageListEvent.SortTypesLoaded(sortTypes)) + stateMachine.process(MessageListEvent.SwipeActionsLoaded(swipeActions)) + stateMachine.process(event = MessageListEvent.AllConfigsReady) + stateMachine.process(event = MessageListEvent.UpdateLoadingProgress(progress = 1f)) + stateMachine.process(event = MessageListEvent.MessagesLoaded(messages)) + advanceUntilIdle() + return stateMachine + } + + private suspend fun TestScope.createStateMachineOnSearchingMessages( + messages: List, + preferences: MessageListPreferences = createMessageListPreferences(), + sortTypes: Map = mapOf(null to SortType.DateDesc), + swipeActions: Map = mapOf( + AccountIdFactory.create() to SwipeActions(SwipeAction.None, SwipeAction.None), + ), + ): MessageListStateMachine { + val stateMachine = createStateMachine() + advanceUntilIdle() + stateMachine.process(MessageListEvent.UpdatePreferences(preferences)) + stateMachine.process(MessageListEvent.SortTypesLoaded(sortTypes)) + stateMachine.process(MessageListEvent.SwipeActionsLoaded(swipeActions)) + stateMachine.process(event = MessageListEvent.AllConfigsReady) + stateMachine.process(event = MessageListEvent.UpdateLoadingProgress(progress = 1f)) + stateMachine.process(event = MessageListEvent.MessagesLoaded(messages)) + stateMachine.process(event = MessageListSearchEvent.EnterSearchMode) + advanceUntilIdle() + return stateMachine + } + + private suspend fun TestScope.createStateMachineOnSelectingMessages( + messages: List, + preferences: MessageListPreferences = createMessageListPreferences(), + sortTypes: Map = mapOf(null to SortType.DateDesc), + swipeActions: Map = mapOf( + AccountIdFactory.create() to SwipeActions(SwipeAction.None, SwipeAction.None), + ), + ): MessageListStateMachine { + val stateMachine = createStateMachine() + advanceUntilIdle() + stateMachine.process(MessageListEvent.UpdatePreferences(preferences)) + stateMachine.process(MessageListEvent.SortTypesLoaded(sortTypes)) + stateMachine.process(MessageListEvent.SwipeActionsLoaded(swipeActions)) + stateMachine.process(event = MessageListEvent.AllConfigsReady) + stateMachine.process(event = MessageListEvent.UpdateLoadingProgress(progress = 1f)) + stateMachine.process(event = MessageListEvent.MessagesLoaded(messages)) + stateMachine.process(event = MessageListEvent.EnterSelectionMode) + advanceUntilIdle() + return stateMachine + } +} + +private fun createMessageListPreferences( + density: UiDensity = UiDensity.Default, + groupConversations: Boolean = false, + showCorrespondentNames: Boolean = false, + showMessageAvatar: Boolean = false, + showFavouriteButton: Boolean = false, + excerptLines: Int = 1, + dateTimeFormat: MessageListDateTimeFormat = MessageListDateTimeFormat.Auto, + useVolumeKeyNavigation: Boolean = false, + serverSearchLimit: Int = 0, + actionRequiringUserConfirmation: ImmutableSet = persistentSetOf(), +) = MessageListPreferences( + density = density, + groupConversations = groupConversations, + showCorrespondentNames = showCorrespondentNames, + showMessageAvatar = showMessageAvatar, + showFavouriteButton = showFavouriteButton, + excerptLines = excerptLines, + dateTimeFormat = dateTimeFormat, + useVolumeKeyNavigation = useVolumeKeyNavigation, + serverSearchLimit = serverSearchLimit, + actionRequiringUserConfirmation = actionRequiringUserConfirmation, +) + +private fun createMessageUiItemList( + size: Int, + accountId: AccountId = AccountIdFactory.create(), + builder: (index: Int) -> MessageItemUi = { index -> + when { + index % 6 == 0 -> createMessageUiItem( + state = State.Unread, + id = "id$index", + accountId = accountId, + ) + + index % 4 == 0 -> createMessageUiItem( + state = State.Read, + id = "id$index", + accountId = accountId, + ) + + index % 2 == 0 -> createMessageUiItem( + state = State.New, + id = "id$index", + accountId = accountId, + ) + + else -> createMessageUiItem( + state = State.Active, + id = "id$index", + accountId = accountId, + ) + } + }, +): List = List(size) { builder(it) } + +private fun createMessageUiItem( + state: State, + id: String, + folderId: String = "mock", + accountId: AccountId = AccountIdFactory.create(), + senders: ImmutableList = persistentListOf(), + recipients: ImmutableList = persistentListOf(), + subject: String = "mock subject", + excerpt: String = "mock excerpt", + formattedReceivedAt: String = "Jan 2026", + attachments: ImmutableList = persistentListOf(), + starred: Boolean = false, + encrypted: Boolean = false, + answered: Boolean = false, + forwarded: Boolean = false, + selected: Boolean = false, + conversations: ImmutableList = persistentListOf(), +): MessageItemUi = MessageItemUi( + state = state, + id = id, + folderId = folderId, + account = Account(id = accountId, color = Color.Unspecified), + senders = senders, + recipients = recipients, + subject = subject, + excerpt = excerpt, + formattedReceivedAt = formattedReceivedAt, + attachments = attachments, + starred = starred, + encrypted = encrypted, + answered = answered, + forwarded = forwarded, + selected = selected, + conversations = conversations, +) diff --git a/legacy/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MessageListFragment.kt b/legacy/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MessageListFragment.kt index f1c7cdcd40f..88e3f56d958 100644 --- a/legacy/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MessageListFragment.kt +++ b/legacy/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MessageListFragment.kt @@ -1,6 +1,12 @@ package com.fsck.k9.ui.messagelist +import android.annotation.SuppressLint +import android.os.Bundle import androidx.core.os.bundleOf +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import kotlinx.coroutines.launch import net.thunderbird.feature.account.AccountIdFactory import net.thunderbird.feature.mail.message.list.ui.MessageListContract import net.thunderbird.feature.search.legacy.LocalMessageSearch @@ -10,18 +16,31 @@ import org.koin.core.parameter.parameterSetOf private const val TAG = "MessageListFragment" -// TODO(10322): Move this fragment to :feature:mail:message:list once all migration to the new +// TODO(#10322): Move this fragment to :feature:mail:message:list once all migration to the new // MessageListFragment to MVI is done. +@SuppressLint("DiscouragedApi") class MessageListFragment : AbstractMessageListFragment() { override val logTag: String = TAG - // TODO(9497): Remove suppression once we start use the new view model. - @Suppress("UnusedPrivateProperty") private val viewModel: MessageListContract.ViewModel by inject { decodeArguments() parameterSetOf(accountUuids.map { AccountIdFactory.of(it) }.toSet()) } + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.CREATED) { + viewModel.effect.collect { effect -> + when (effect) { + else -> Unit + } + } + } + } + } + companion object Factory : AbstractMessageListFragment.Factory { override fun newInstance( search: LocalMessageSearch,