Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
cf2ec38
chore(message-list): remove unnecessary preference parameter; already…
rafaeltonholo Feb 27, 2026
868621a
chore(message-list): register MessageListScreenRenderer in FeatureMes…
rafaeltonholo Feb 27, 2026
4db119d
feat(message-list): add new_message_list_fragment layout with only a …
rafaeltonholo Feb 27, 2026
361c63b
refactor(message-list): start migration of `MessageListFragment` to C…
rafaeltonholo Feb 27, 2026
ecc1325
refactor(message-list): extract side effect handler factories to a se…
rafaeltonholo Feb 27, 2026
ecce87f
feat(message-list): add AllConfigurationsReadySideEffect to handle re…
rafaeltonholo Feb 27, 2026
da367d0
chore(message-list): add isReady property to MessageListMetadata and …
rafaeltonholo Mar 2, 2026
42754f8
feat(message-list): enhance MessageListStateMachine with logging and …
rafaeltonholo Mar 2, 2026
dbc1f2b
chore(message-list): MessageListFragment.kt clean up
rafaeltonholo Mar 3, 2026
0458cd5
chore(message-list): remove unnecessary ExperimentalTime opt-in annot…
rafaeltonholo Apr 2, 2026
da8b0a6
test(message-list): update `MessageListStateMachineTest` to include f…
rafaeltonholo Apr 2, 2026
0b6f858
test: update Koin module verification with missing dependencies
rafaeltonholo Apr 2, 2026
4b1c1c3
test(message-list): remove unused preferences parameter in MessageLis…
rafaeltonholo Apr 2, 2026
408a4d4
test(message-list): add unit tests for AllConfigurationsReadySideEffect
rafaeltonholo Apr 2, 2026
4e05ce5
fix(R8): using catch RuntimeException instead of InaccessibleObjectEx…
rafaeltonholo Apr 6, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
package net.thunderbird.core.common.state.debug

import java.lang.reflect.InaccessibleObjectException

internal actual fun <T : Any> T.toPropertyMap(): Map<String, Any?> {
if (this is Collection<*> || this is Map<*, *>) return emptyMap()

Expand All @@ -15,7 +13,7 @@ internal actual fun <T : Any> T.toPropertyMap(): Map<String, Any?> {
try {
field.isAccessible = true
put(field.name, field[this@toPropertyMap])
} catch (_: InaccessibleObjectException) {
} catch (_: RuntimeException) {
// Skip fields that cannot be accessed (e.g., platform types in named modules)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ import net.thunderbird.core.ui.compose.common.mvi.BaseStateMachineViewModel
import net.thunderbird.core.ui.contract.mvi.BaseViewModel
import net.thunderbird.core.ui.contract.mvi.observe
import net.thunderbird.feature.account.AccountId
import net.thunderbird.feature.mail.message.list.preferences.MessageListPreferences
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
Expand Down Expand Up @@ -66,7 +65,6 @@ interface MessageListContract {
* @param state The current state of the message list to be rendered.
* @param dispatchEvent A lambda function to be invoked when a user action or other UI event occurs.
* @param onEffect A lambda function to handle one-time side effects from the ViewModel.
* @param preferences User preferences for the message list, influencing its appearance and behavior.
* @param modifier The modifier to be applied to the root container of the message list screen.
* @param inAppNotificationEventFilter A filter to decide whether an in-app notification should be displayed.
*/
Expand All @@ -75,7 +73,6 @@ interface MessageListContract {
state: MessageListState,
dispatchEvent: (MessageListEvent) -> Unit,
onEffect: (MessageListEffect) -> Unit,
preferences: MessageListPreferences,
modifier: Modifier = Modifier,
inAppNotificationEventFilter: (InAppNotification) -> Boolean = { true },
)
Expand All @@ -86,22 +83,20 @@ interface MessageListContract {
* This is a convenience overload of [Render] that automatically retrieves the [ViewModel]
* using Koin and observes its state.
*
* @param preferences The user's preferences for the message list.
* @param onEffect A callback to handle one-time side effects from the [ViewModel], such as navigation.
* @param modifier The modifier to be applied to the layout.
* @param viewModel The [ViewModel] instance for this screen. Defaults to the instance provided by Koin.
* @param inAppNotificationEventFilter A filter to decide whether an in-app notification should be displayed.
*/
@Composable
fun Render(
preferences: MessageListPreferences,
onEffect: (MessageListEffect) -> Unit,
modifier: Modifier = Modifier,
viewModel: ViewModel = koinViewModel(),
inAppNotificationEventFilter: (InAppNotification) -> Boolean = { true },
) {
val (state, dispatchEvent) = viewModel.observe(onEffect)
Render(state.value, dispatchEvent, onEffect, preferences, modifier, inAppNotificationEventFilter)
Render(state.value, dispatchEvent, onEffect, modifier, inAppNotificationEventFilter)
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,18 @@ data class MessageListMetadata(
val footer: MessageListFooter = MessageListFooter(),
val showAccountIndicator: Boolean = false,
val paging: PaginationUi = PaginationUi(),
)
) {
/**
* Indicates whether the message list metadata contains all required data to display the message list.
*
* @return `true` when swipe actions are configured, sorting criteria are available, and a folder
* is selected; otherwise, `false` if any of these required components are missing.
*/
val isReady: Boolean
get() = swipeActions.isNotEmpty() &&
sortCriteriaPerAccount.isNotEmpty() &&
folder != null
}

/**
* Represents the footer text to display at the bottom of the message list.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -92,10 +92,14 @@ sealed interface MessageListState {
override val preferences: MessageListPreferences? = null,
override val messages: ImmutableList<MessageItemUi> = persistentListOf(),
) : MessageListState {
/**
* Indicates whether the warming-up state has completed and is ready to transition to an active state.
*
* @return `true` when both the metadata is ready and user preferences have been loaded, signaling
* that the message list screen has completed its initialization phase and can proceed to display content.
*/
val isReady: Boolean
get() = metadata.swipeActions.isNotEmpty() &&
preferences != null &&
metadata.sortCriteriaPerAccount.isNotEmpty()
get() = metadata.isReady && preferences != null
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,6 @@ private fun MessageListScreenLoadingMessagesStatePreview(
state = params.state,
dispatchEvent = {},
onEffect = {},
preferences = params.preferences,
)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,6 @@ private fun MessageListScreenSearchingMessagesStatePreview(
state = params.state,
dispatchEvent = {},
onEffect = {},
preferences = params.preferences,
)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,6 @@ private fun MessageListScreenSelectingMessagesStatePreview(
state = params.state,
dispatchEvent = {},
onEffect = {},
preferences = params.preferences,
)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,6 @@ private fun MessageListScreenWarmingUpStatePreview(
state = params.state,
dispatchEvent = {},
onEffect = {},
preferences = params.preferences,
)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,6 @@ private fun MessageListScreenLoadedMessagesContentPreview(
state = params.state,
dispatchEvent = {},
onEffect = {},
preferences = params.preferences,
)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,6 @@ private fun MessageListScreenLoadedMessagesDensityPreview(
state = params.state,
dispatchEvent = {},
onEffect = {},
preferences = params.preferences,
)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,6 @@ private fun MessageListScreenLoadedMessagesFooterPreview(
state = params.state,
dispatchEvent = {},
onEffect = {},
preferences = params.preferences,
)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,6 @@ private fun MessageListScreenLayoutPreview(
state = params.state,
dispatchEvent = {},
onEffect = {},
preferences = params.preferences,
)
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
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.LocalDeleteOperationDecider
import net.thunderbird.feature.mail.message.list.domain.DomainContract
Expand All @@ -10,23 +9,20 @@ import net.thunderbird.feature.mail.message.list.internal.domain.usecase.GetAcco
import net.thunderbird.feature.mail.message.list.internal.domain.usecase.GetMessageListPreferences
import net.thunderbird.feature.mail.message.list.internal.domain.usecase.GetSortCriteriaPerAccount
import net.thunderbird.feature.mail.message.list.internal.domain.usecase.SetArchiveFolder
import net.thunderbird.feature.mail.message.list.internal.ui.MessageListScreenRenderer
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.internal.ui.state.sideeffect.ChangeSortCriteriaSideEffect
import net.thunderbird.feature.mail.message.list.internal.ui.state.sideeffect.LoadFolderInformationSideEffect
import net.thunderbird.feature.mail.message.list.internal.ui.state.sideeffect.LoadPreferencesSideEffect
import net.thunderbird.feature.mail.message.list.internal.ui.state.sideeffect.LoadSortCriteriaStateSideEffectHandler
import net.thunderbird.feature.mail.message.list.internal.ui.state.sideeffect.LoadSwipeActionsStateSideEffectHandler
import net.thunderbird.feature.mail.message.list.internal.ui.state.sideeffect.inject.messageListSideEffectsModule
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
import org.koin.dsl.module

val featureMessageListModule = module {
includes(messageListSideEffectsModule)
factory<DomainContract.UseCase.GetAccountFolders> { GetAccountFolders(folderRepository = get()) }
factory<DomainContract.UseCase.CreateArchiveFolder> {
CreateArchiveFolder(
Expand Down Expand Up @@ -73,44 +69,9 @@ val featureMessageListModule = module {
getDefaultSortCriteria = get(),
)
}
factoryListOf<MessageListStateSideEffectHandlerFactory>(
{
LoadPreferencesSideEffect.Factory(
logger = get(),
getMessageListPreferences = get(),
)
},
{
LoadSwipeActionsStateSideEffectHandler.Factory(
logger = get(),
buildSwipeActions = get(),
)
},
{ parameters ->
val args = parameters.get<MessageListContract.ViewModel.Args>()
LoadSortCriteriaStateSideEffectHandler.Factory(
accounts = args.accountIds,
logger = get(),
getSortCriteriaPerAccount = get(),
)
},
{
ChangeSortCriteriaSideEffect.Factory(
logger = get(),
updateSortCriteria = get(),
)
},
{ parameters ->
val args = parameters.get<MessageListContract.ViewModel.Args>()
LoadFolderInformationSideEffect.Factory(
accountIds = args.accountIds,
folderId = args.folderId,
logger = get(),
folderRepository = get(),
)
},
)
factory { MessageListStateMachine.Factory() }
factory {
MessageListStateMachine.Factory(logger = get(), clock = get(), debuggingSettingsPreferenceManager = get())
}
viewModel<MessageListContract.ViewModel> { parameters ->
MessageListViewModel(
logger = get(),
Expand All @@ -119,4 +80,5 @@ val featureMessageListModule = module {
)
}
single<LocalDeleteOperationDecider> { DefaultLocalDeleteOperationDecider() }
factory<MessageListContract.MessageListScreenRenderer> { MessageListScreenRenderer() }
}
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,6 @@ import net.thunderbird.core.ui.compose.designsystem.molecule.swipe.rememberSwipe
import net.thunderbird.feature.mail.message.list.internal.ui.MessageListScreenRenderer.Companion.TEST_TAG_MESSAGE_LIST_ROOT
import net.thunderbird.feature.mail.message.list.internal.ui.component.MessageItemSwipeBackground
import net.thunderbird.feature.mail.message.list.internal.ui.component.MessageListItem
import net.thunderbird.feature.mail.message.list.preferences.MessageListPreferences
import net.thunderbird.feature.mail.message.list.ui.MessageListContract
import net.thunderbird.feature.mail.message.list.ui.effect.MessageListEffect
import net.thunderbird.feature.mail.message.list.ui.event.MessageItemEvent
Expand All @@ -54,7 +53,6 @@ internal class MessageListScreenRenderer : MessageListContract.MessageListScreen
state: MessageListState,
dispatchEvent: (MessageListEvent) -> Unit,
onEffect: (MessageListEffect) -> Unit,
preferences: MessageListPreferences,
modifier: Modifier,
inAppNotificationEventFilter: (InAppNotification) -> Boolean,
) {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,21 @@
package net.thunderbird.feature.mail.message.list.internal.ui.state.machine

import kotlin.time.Clock
import kotlin.time.ExperimentalTime
import kotlinx.coroutines.CoroutineScope
import net.thunderbird.core.common.action.SwipeActions
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.core.logging.Logger
import net.thunderbird.core.preference.debugging.DebuggingSettingsPreferenceManager
import net.thunderbird.feature.mail.message.list.ui.event.MessageListEvent
import net.thunderbird.feature.mail.message.list.ui.state.Folder
import net.thunderbird.feature.mail.message.list.ui.state.MessageItemUi
import net.thunderbird.feature.mail.message.list.ui.state.MessageListState

private const val TAG = "MessageListStateMachine"

/**
* Manages the state transitions for the message list UI.
*
Expand All @@ -25,9 +34,27 @@ import net.thunderbird.feature.mail.message.list.ui.state.MessageListState
* @param stateMachine The underlying state machine implementation, configured with all possible states and transitions.
*/
class MessageListStateMachine(
private val logger: Logger,
private val clock: Clock,
private val scope: CoroutineScope,
private val dispatch: (MessageListEvent) -> Unit,
private val debuggingSettingsPreferenceManager: DebuggingSettingsPreferenceManager,
private val stateMachine: StateMachine<MessageListState, MessageListEvent> = stateMachine(scope) {
withLogger(logger, TAG)
if (debuggingSettingsPreferenceManager.getConfig().isDebugLoggingEnabled) {
enableDebug {
@OptIn(ExperimentalTime::class)
withClock(clock)
formatValues { obj, _ ->
when (obj) {
is MessageItemUi -> "id: ${obj.id}"
is SwipeActions -> "(l: ${obj.leftAction.name}, r: ${obj.rightAction.name})"
is Folder -> "(id: ${obj.id}, name: ${obj.name})"
else -> obj.toString()
}
}
}
}
warmingUpInitialState(initialState = MessageListState.WarmingUp(), dispatch)
globalState()
loadingMessagesState()
Expand All @@ -36,13 +63,18 @@ class MessageListStateMachine(
searchingMessagesState()
},
) : StateMachine<MessageListState, MessageListEvent> by stateMachine {
class Factory {
fun create(
scope: CoroutineScope,
dispatch: (MessageListEvent) -> Unit,
): MessageListStateMachine = MessageListStateMachine(
scope = scope,
dispatch = dispatch,
)
class Factory(
private val logger: Logger,
private val clock: Clock,
private val debuggingSettingsPreferenceManager: DebuggingSettingsPreferenceManager,
) {
fun create(scope: CoroutineScope, dispatch: (MessageListEvent) -> Unit): MessageListStateMachine =
MessageListStateMachine(
logger = logger,
clock = clock,
scope = scope,
dispatch = dispatch,
debuggingSettingsPreferenceManager = debuggingSettingsPreferenceManager,
)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
package net.thunderbird.feature.mail.message.list.internal.ui.state.sideeffect

import kotlinx.coroutines.CoroutineScope
import net.thunderbird.core.common.state.sideeffect.StateSideEffectHandler
import net.thunderbird.core.logging.Logger
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

/**
* A side effect handler that monitors the message list state and dispatches an event when all
* initial configurations have been loaded and the system is ready to proceed.
*
* @param logger A logger instance for tracking and debugging side effect execution.
* @param dispatch A suspend function that dispatches [MessageListEvent] instances to the state machine.
*/
class AllConfigurationsReadySideEffect(
logger: Logger,
dispatch: suspend (MessageListEvent) -> Unit,
) : StateSideEffectHandler<MessageListState, MessageListEvent>(logger, dispatch) {
override fun accept(event: MessageListEvent, newState: MessageListState): Boolean =
newState is MessageListState.WarmingUp && newState.isReady

override suspend fun handle(oldState: MessageListState, newState: MessageListState) {
dispatch(MessageListEvent.AllConfigsReady)
}

class Factory(
private val logger: Logger,
) : MessageListStateSideEffectHandlerFactory {
override fun create(
scope: CoroutineScope,
dispatch: suspend (MessageListEvent) -> Unit,
): StateSideEffectHandler<MessageListState, MessageListEvent> = AllConfigurationsReadySideEffect(
dispatch = dispatch,
logger = logger,
)
}
}
Loading
Loading