diff --git a/core/ui/setting/api/src/commonMain/kotlin/net/thunderbird/core/ui/setting/SettingValue.kt b/core/ui/setting/api/src/commonMain/kotlin/net/thunderbird/core/ui/setting/SettingValue.kt index 792d811123b..236a360e9a7 100644 --- a/core/ui/setting/api/src/commonMain/kotlin/net/thunderbird/core/ui/setting/SettingValue.kt +++ b/core/ui/setting/api/src/commonMain/kotlin/net/thunderbird/core/ui/setting/SettingValue.kt @@ -39,6 +39,32 @@ sealed interface SettingValue : Setting { override val requiresEditView: Boolean = true } + /** + * A setting that displays a text value and triggers an action when clicked. + * + * Unlike [Text], this setting does not require an edit view. Instead, + * the entire setting item acts as a clickable action that can be used + * for navigation, opening dialogs, launching pickers, or performing + * custom actions. + * + * @param id The unique identifier for the setting. + * @param title A lambda that returns the title of the setting. + * @param description A lambda that returns the description of the setting. Default is null. + * @param icon A lambda that returns the icon of the setting as an [ImageVector]. Default is null. + * @param value The current text value displayed by the setting. + * @param onClick The action to invoke when the setting is clicked. + */ + data class ActionText( + override val id: String, + val title: () -> String, + val description: () -> String? = { null }, + val icon: () -> ImageVector? = { null }, + override val value: String, + val onClick: () -> Unit, + ) : SettingValue { + override val requiresEditView: Boolean = false + } + /** * A setting that holds a color value. * diff --git a/core/ui/setting/impl-dialog/src/debug/kotlin/net/thunderbird/core/ui/setting/dialog/ui/components/list/value/ActionTextItemPreview.kt b/core/ui/setting/impl-dialog/src/debug/kotlin/net/thunderbird/core/ui/setting/dialog/ui/components/list/value/ActionTextItemPreview.kt new file mode 100644 index 00000000000..92458a33947 --- /dev/null +++ b/core/ui/setting/impl-dialog/src/debug/kotlin/net/thunderbird/core/ui/setting/dialog/ui/components/list/value/ActionTextItemPreview.kt @@ -0,0 +1,16 @@ +package net.thunderbird.core.ui.setting.dialog.ui.components.list.value + +import androidx.compose.runtime.Composable +import androidx.compose.ui.tooling.preview.Preview +import app.k9mail.core.ui.compose.designsystem.PreviewWithThemes +import net.thunderbird.core.ui.setting.dialog.ui.fake.FakeSettingData + +@Composable +@Preview(showBackground = true) +internal fun ActionTextItemPreview() { + PreviewWithThemes { + ActionTextItem( + setting = FakeSettingData.actionText, + ) + } +} diff --git a/core/ui/setting/impl-dialog/src/debug/kotlin/net/thunderbird/core/ui/setting/dialog/ui/fake/FakeSettingData.kt b/core/ui/setting/impl-dialog/src/debug/kotlin/net/thunderbird/core/ui/setting/dialog/ui/fake/FakeSettingData.kt index ae066bdfc17..5aeab2bde90 100644 --- a/core/ui/setting/impl-dialog/src/debug/kotlin/net/thunderbird/core/ui/setting/dialog/ui/fake/FakeSettingData.kt +++ b/core/ui/setting/impl-dialog/src/debug/kotlin/net/thunderbird/core/ui/setting/dialog/ui/fake/FakeSettingData.kt @@ -16,6 +16,15 @@ internal object FakeSettingData { value = "Value", ) + val actionText = SettingValue.ActionText( + id = "text", + icon = { Icons.Outlined.Delete }, + title = { "Title" }, + description = { "Description" }, + value = "Value", + onClick = {}, + ) + val color = SettingValue.Color( id = "color", icon = { Icons.Outlined.Delete }, diff --git a/core/ui/setting/impl-dialog/src/main/kotlin/net/thunderbird/core/ui/setting/dialog/ui/components/dialog/SettingDialog.kt b/core/ui/setting/impl-dialog/src/main/kotlin/net/thunderbird/core/ui/setting/dialog/ui/components/dialog/SettingDialog.kt index bacca404b07..e0f608daba9 100644 --- a/core/ui/setting/impl-dialog/src/main/kotlin/net/thunderbird/core/ui/setting/dialog/ui/components/dialog/SettingDialog.kt +++ b/core/ui/setting/impl-dialog/src/main/kotlin/net/thunderbird/core/ui/setting/dialog/ui/components/dialog/SettingDialog.kt @@ -55,6 +55,7 @@ internal fun SettingDialog( is SettingValue.IconList, is SettingValue.SegmentedButton<*>, is SettingValue.Switch, + is SettingValue.ActionText, -> Unit } } diff --git a/core/ui/setting/impl-dialog/src/main/kotlin/net/thunderbird/core/ui/setting/dialog/ui/components/list/SettingItem.kt b/core/ui/setting/impl-dialog/src/main/kotlin/net/thunderbird/core/ui/setting/dialog/ui/components/list/SettingItem.kt index 4cd2cae7fd5..b8032134af0 100644 --- a/core/ui/setting/impl-dialog/src/main/kotlin/net/thunderbird/core/ui/setting/dialog/ui/components/list/SettingItem.kt +++ b/core/ui/setting/impl-dialog/src/main/kotlin/net/thunderbird/core/ui/setting/dialog/ui/components/list/SettingItem.kt @@ -8,6 +8,7 @@ import net.thunderbird.core.ui.setting.SettingValue import net.thunderbird.core.ui.setting.dialog.ui.components.list.decoration.CustomItem import net.thunderbird.core.ui.setting.dialog.ui.components.list.decoration.SectionDividerItem import net.thunderbird.core.ui.setting.dialog.ui.components.list.decoration.SectionHeaderItem +import net.thunderbird.core.ui.setting.dialog.ui.components.list.value.ActionTextItem import net.thunderbird.core.ui.setting.dialog.ui.components.list.value.ColorItem import net.thunderbird.core.ui.setting.dialog.ui.components.list.value.IconListItem import net.thunderbird.core.ui.setting.dialog.ui.components.list.value.SegmentedButtonItem @@ -56,6 +57,13 @@ private fun RenderSettingValue( ) } + is SettingValue.ActionText -> { + ActionTextItem( + setting = setting, + modifier = modifier, + ) + } + is SettingValue.Color -> { ColorItem( setting = setting, diff --git a/core/ui/setting/impl-dialog/src/main/kotlin/net/thunderbird/core/ui/setting/dialog/ui/components/list/value/ActionTextItem.kt b/core/ui/setting/impl-dialog/src/main/kotlin/net/thunderbird/core/ui/setting/dialog/ui/components/list/value/ActionTextItem.kt new file mode 100644 index 00000000000..29a3ea0e55f --- /dev/null +++ b/core/ui/setting/impl-dialog/src/main/kotlin/net/thunderbird/core/ui/setting/dialog/ui/components/list/value/ActionTextItem.kt @@ -0,0 +1,23 @@ +package net.thunderbird.core.ui.setting.dialog.ui.components.list.value + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import app.k9mail.core.ui.compose.designsystem.atom.text.TextBodyMedium +import app.k9mail.core.ui.compose.designsystem.atom.text.TextTitleMedium +import net.thunderbird.core.ui.setting.SettingValue +import net.thunderbird.core.ui.setting.component.list.item.SettingItemLayout + +@Composable +internal fun ActionTextItem( + setting: SettingValue.ActionText, + modifier: Modifier = Modifier, +) { + SettingItemLayout( + onClick = setting.onClick, + icon = setting.icon(), + modifier = modifier, + ) { + TextTitleMedium(text = setting.title()) + TextBodyMedium(text = setting.value) + } +} diff --git a/feature/account/settings/api/src/main/kotlin/net/thunderbird/feature/account/settings/api/AccountSettingsRoute.kt b/feature/account/settings/api/src/main/kotlin/net/thunderbird/feature/account/settings/api/AccountSettingsRoute.kt index 3d40d51c3bd..51fa66efaef 100644 --- a/feature/account/settings/api/src/main/kotlin/net/thunderbird/feature/account/settings/api/AccountSettingsRoute.kt +++ b/feature/account/settings/api/src/main/kotlin/net/thunderbird/feature/account/settings/api/AccountSettingsRoute.kt @@ -27,6 +27,28 @@ sealed interface AccountSettingsRoute : Route { } } + @Serializable + data class FetchingMailSettings(val accountId: String) : AccountSettingsRoute { + override val basePath: String = BASE_PATH + + override fun route(): String = "$basePath/$accountId" + + companion object { + const val BASE_PATH = "$ACCOUNT_SETTINGS_BASE_PATH/fetching_mail" + } + } + + @Serializable + data class AdvancedFetchingMailSettings(val accountId: String) : AccountSettingsRoute { + override val basePath: String = BASE_PATH + + override fun route(): String = "$basePath/$accountId" + + companion object { + const val BASE_PATH = "$ACCOUNT_SETTINGS_BASE_PATH/fetching_mail/advanced" + } + } + @Serializable data class SearchSettings(val accountId: String) : AccountSettingsRoute { override val basePath: String = BASE_PATH diff --git a/feature/account/settings/impl/build.gradle.kts b/feature/account/settings/impl/build.gradle.kts index ab421481697..a110a7be7f4 100644 --- a/feature/account/settings/impl/build.gradle.kts +++ b/feature/account/settings/impl/build.gradle.kts @@ -37,6 +37,8 @@ dependencies { implementation(libs.uri) + implementation(projects.feature.launcher) + debugImplementation(projects.core.ui.setting.implDialog) testImplementation(projects.core.logging.testing) diff --git a/feature/account/settings/impl/src/main/kotlin/net/thunderbird/feature/account/settings/AccountSettingsModule.kt b/feature/account/settings/impl/src/main/kotlin/net/thunderbird/feature/account/settings/AccountSettingsModule.kt index e370d7fb855..c97be032513 100644 --- a/feature/account/settings/impl/src/main/kotlin/net/thunderbird/feature/account/settings/AccountSettingsModule.kt +++ b/feature/account/settings/impl/src/main/kotlin/net/thunderbird/feature/account/settings/AccountSettingsModule.kt @@ -9,11 +9,15 @@ import net.thunderbird.feature.account.settings.impl.domain.usecase.GetAccountNa import net.thunderbird.feature.account.settings.impl.domain.usecase.GetAccountProfile import net.thunderbird.feature.account.settings.impl.domain.usecase.GetLegacyAccount import net.thunderbird.feature.account.settings.impl.domain.usecase.UpdateAvatarImage +import net.thunderbird.feature.account.settings.impl.domain.usecase.UpdateFetchingMailSettings import net.thunderbird.feature.account.settings.impl.domain.usecase.UpdateGeneralSettings import net.thunderbird.feature.account.settings.impl.domain.usecase.UpdateReadEmailSettings import net.thunderbird.feature.account.settings.impl.domain.usecase.UpdateSearchSettings import net.thunderbird.feature.account.settings.impl.domain.usecase.ValidateAccountName import net.thunderbird.feature.account.settings.impl.domain.usecase.ValidateAvatarMonogram +import net.thunderbird.feature.account.settings.impl.ui.fetchingMail.FetchingMailSettingsBuilder +import net.thunderbird.feature.account.settings.impl.ui.fetchingMail.FetchingMailSettingsContract +import net.thunderbird.feature.account.settings.impl.ui.fetchingMail.FetchingMailSettingsViewModel import net.thunderbird.feature.account.settings.impl.ui.general.GeneralSettingsBuilder import net.thunderbird.feature.account.settings.impl.ui.general.GeneralSettingsContract import net.thunderbird.feature.account.settings.impl.ui.general.GeneralSettingsValidator @@ -43,6 +47,12 @@ val featureAccountSettingsModule = module { ) } + factory { + UpdateFetchingMailSettings( + repository = get(), + ) + } + factory { UpdateSearchSettings( repository = get(), @@ -109,6 +119,12 @@ val featureAccountSettingsModule = module { ) } + factory { + FetchingMailSettingsBuilder( + resources = get(), + ) + } + factory { SearchSettingBuilder( resources = get(), @@ -126,6 +142,17 @@ val featureAccountSettingsModule = module { ) } + viewModel { params -> + FetchingMailSettingsViewModel( + accountId = params.get(), + resources = get(), + logger = get(), + getAccountName = get(), + getLegacyAccount = get(), + updateFetchingMailSettings = get(), + ) + } + viewModel { params -> SearchSettingsViewModel( accountId = params.get(), diff --git a/feature/account/settings/impl/src/main/kotlin/net/thunderbird/feature/account/settings/impl/DefaultAccountSettingsNavigation.kt b/feature/account/settings/impl/src/main/kotlin/net/thunderbird/feature/account/settings/impl/DefaultAccountSettingsNavigation.kt index 193737f8e94..9b68e817b74 100644 --- a/feature/account/settings/impl/src/main/kotlin/net/thunderbird/feature/account/settings/impl/DefaultAccountSettingsNavigation.kt +++ b/feature/account/settings/impl/src/main/kotlin/net/thunderbird/feature/account/settings/impl/DefaultAccountSettingsNavigation.kt @@ -6,12 +6,15 @@ import net.thunderbird.core.ui.navigation.deepLinkComposable import net.thunderbird.feature.account.AccountIdFactory import net.thunderbird.feature.account.settings.api.AccountSettingsNavigation import net.thunderbird.feature.account.settings.api.AccountSettingsRoute +import net.thunderbird.feature.account.settings.impl.ui.fetchingMail.FetchingMailSettingsScreen +import net.thunderbird.feature.account.settings.impl.ui.fetchingMail.advanced.AdvancedFetchingMailSettingsScreen import net.thunderbird.feature.account.settings.impl.ui.general.GeneralSettingsScreen import net.thunderbird.feature.account.settings.impl.ui.readingMail.ReadingMailSettingsScreen import net.thunderbird.feature.account.settings.impl.ui.search.SearchSettingsScreen internal class DefaultAccountSettingsNavigation : AccountSettingsNavigation { + @Suppress("LongMethod") override fun registerRoutes( navGraphBuilder: NavGraphBuilder, onBack: () -> Unit, @@ -45,6 +48,35 @@ internal class DefaultAccountSettingsNavigation : AccountSettingsNavigation { } } + with(navGraphBuilder) { + deepLinkComposable( + basePath = AccountSettingsRoute.FetchingMailSettings.BASE_PATH, + ) { backStackEntry -> + val fetchingMailSettingsRoute = backStackEntry.toRoute() + val accountId = AccountIdFactory.of(fetchingMailSettingsRoute.accountId) + + FetchingMailSettingsScreen( + accountId = accountId, + onBack = onBack, + ) + } + } + + with(navGraphBuilder) { + deepLinkComposable( + basePath = AccountSettingsRoute.AdvancedFetchingMailSettings.BASE_PATH, + ) { backStackEntry -> + val advancedFetchingMailSettingsRoute = + backStackEntry.toRoute() + val accountId = AccountIdFactory.of(advancedFetchingMailSettingsRoute.accountId) + + AdvancedFetchingMailSettingsScreen( + accountId = accountId, + onBack = onBack, + ) + } + } + with(navGraphBuilder) { deepLinkComposable( basePath = AccountSettingsRoute.SearchSettings.Companion.BASE_PATH, diff --git a/feature/account/settings/impl/src/main/kotlin/net/thunderbird/feature/account/settings/impl/domain/AccountSettingsDomainContract.kt b/feature/account/settings/impl/src/main/kotlin/net/thunderbird/feature/account/settings/impl/domain/AccountSettingsDomainContract.kt index 71a042b1f3a..13969f64d7a 100644 --- a/feature/account/settings/impl/src/main/kotlin/net/thunderbird/feature/account/settings/impl/domain/AccountSettingsDomainContract.kt +++ b/feature/account/settings/impl/src/main/kotlin/net/thunderbird/feature/account/settings/impl/domain/AccountSettingsDomainContract.kt @@ -45,6 +45,13 @@ internal interface AccountSettingsDomainContract { ): Outcome } + fun interface UpdateFetchingMailSettings { + suspend operator fun invoke( + accountId: AccountId, + command: UpdateFetchingMailSettingsCommand, + ): Outcome + } + fun interface UpdateAvatarImage { suspend operator fun invoke( accountId: AccountId, @@ -76,6 +83,19 @@ internal interface AccountSettingsDomainContract { data class UpdateServerSearchLimit(val value: Int) : UpdateSearchSettingsCommand } + sealed interface UpdateFetchingMailSettingsCommand { + data class UpdateLocalFolderSize(val value: Int) : UpdateFetchingMailSettingsCommand + data class UpdateSyncMessageFrom(val value: Int) : UpdateFetchingMailSettingsCommand + data class UpdateFetchMessageUpTo(val value: Int) : UpdateFetchingMailSettingsCommand + data class UpdateFolderPollFrequency(val value: Int) : UpdateFetchingMailSettingsCommand + data class UpdateSyncServerDeletions(val value: Boolean) : UpdateFetchingMailSettingsCommand + data class UpdateMarkAsReadWhenDeleted(val value: Boolean) : UpdateFetchingMailSettingsCommand + data class UpdateWhenIDeleteAMessage(val value: String) : UpdateFetchingMailSettingsCommand + data class UpdateEraseDeletedMessageOnServer(val value: String) : UpdateFetchingMailSettingsCommand + data class UpdateOnMaxFolderToCheckWithPushChange(val value: Int) : UpdateFetchingMailSettingsCommand + data class UpdateRefreshIdleConnectionFrequencyChange(val value: Int) : UpdateFetchingMailSettingsCommand + } + sealed interface AccountSettingError { data class NotFound( val message: String, diff --git a/feature/account/settings/impl/src/main/kotlin/net/thunderbird/feature/account/settings/impl/domain/usecase/UpdateFetchingMailSettings.kt b/feature/account/settings/impl/src/main/kotlin/net/thunderbird/feature/account/settings/impl/domain/usecase/UpdateFetchingMailSettings.kt new file mode 100644 index 00000000000..4d5e468d0e4 --- /dev/null +++ b/feature/account/settings/impl/src/main/kotlin/net/thunderbird/feature/account/settings/impl/domain/usecase/UpdateFetchingMailSettings.kt @@ -0,0 +1,109 @@ +package net.thunderbird.feature.account.settings.impl.domain.usecase + +import kotlinx.coroutines.flow.firstOrNull +import net.thunderbird.core.android.account.DeletePolicy +import net.thunderbird.core.android.account.Expunge +import net.thunderbird.core.android.account.LegacyAccountRepository +import net.thunderbird.core.outcome.Outcome +import net.thunderbird.feature.account.AccountId +import net.thunderbird.feature.account.settings.impl.domain.AccountSettingsDomainContract +import net.thunderbird.feature.account.settings.impl.domain.AccountSettingsDomainContract.UseCase + +internal class UpdateFetchingMailSettings( + private val repository: LegacyAccountRepository, +) : UseCase.UpdateFetchingMailSettings { + + @Suppress("LongMethod", "CyclomaticComplexMethod") + override suspend fun invoke( + accountId: AccountId, + command: AccountSettingsDomainContract.UpdateFetchingMailSettingsCommand, + ): Outcome { + val account = repository.getById(accountId) + .firstOrNull() + ?: return Outcome.failure( + AccountSettingsDomainContract.AccountSettingError.NotFound( + "Account not found", + ), + ) + + when (command) { + is AccountSettingsDomainContract.UpdateFetchingMailSettingsCommand.UpdateLocalFolderSize -> { + repository.update(account.copy(displayCount = command.value)) + } + + is AccountSettingsDomainContract.UpdateFetchingMailSettingsCommand.UpdateSyncMessageFrom -> { + repository.update(account.copy(maximumPolledMessageAge = command.value)) + } + + is AccountSettingsDomainContract.UpdateFetchingMailSettingsCommand.UpdateFetchMessageUpTo -> { + repository.update(account.copy(maximumAutoDownloadMessageSize = command.value)) + } + + is AccountSettingsDomainContract.UpdateFetchingMailSettingsCommand.UpdateFolderPollFrequency -> { + repository.update(account.copy(automaticCheckIntervalMinutes = command.value)) + } + + is AccountSettingsDomainContract.UpdateFetchingMailSettingsCommand.UpdateSyncServerDeletions -> { + repository.update(account.copy(isSyncRemoteDeletions = command.value)) + } + + is AccountSettingsDomainContract.UpdateFetchingMailSettingsCommand.UpdateMarkAsReadWhenDeleted -> { + repository.update(account.copy(isMarkMessageAsReadOnDelete = command.value)) + } + + is AccountSettingsDomainContract.UpdateFetchingMailSettingsCommand.UpdateWhenIDeleteAMessage -> { + when (command.value) { + "NEVER" -> { + repository.update(account.copy(deletePolicy = DeletePolicy.NEVER)) + } + + "ON_DELETE" -> { + repository.update(account.copy(deletePolicy = DeletePolicy.ON_DELETE)) + } + + "MARK_AS_READ" -> { + repository.update(account.copy(deletePolicy = DeletePolicy.MARK_AS_READ)) + } + + else -> { + error("Invalid delete policy value: ${command.value}") + } + } + } + + is AccountSettingsDomainContract.UpdateFetchingMailSettingsCommand.UpdateEraseDeletedMessageOnServer -> { + when (command.value) { + "EXPUNGE_IMMEDIATELY" -> { + repository.update(account.copy(expungePolicy = Expunge.EXPUNGE_IMMEDIATELY)) + } + + "EXPUNGE_ON_POLL" -> { + repository.update(account.copy(expungePolicy = Expunge.EXPUNGE_ON_POLL)) + } + + "EXPUNGE_MANUALLY" -> { + repository.update(account.copy(expungePolicy = Expunge.EXPUNGE_MANUALLY)) + } + + else -> { + error("Invalid expunge policy value: ${command.value}") + } + } + } + + is AccountSettingsDomainContract + .UpdateFetchingMailSettingsCommand.UpdateOnMaxFolderToCheckWithPushChange, + -> { + repository.update(account.copy(maxPushFolders = command.value)) + } + + is AccountSettingsDomainContract + .UpdateFetchingMailSettingsCommand.UpdateRefreshIdleConnectionFrequencyChange, + -> { + repository.update(account.copy(idleRefreshMinutes = command.value)) + } + } + + return Outcome.success(Unit) + } +} diff --git a/feature/account/settings/impl/src/main/kotlin/net/thunderbird/feature/account/settings/impl/ui/fetchingMail/FetchingMailSettingsBuilder.kt b/feature/account/settings/impl/src/main/kotlin/net/thunderbird/feature/account/settings/impl/ui/fetchingMail/FetchingMailSettingsBuilder.kt new file mode 100644 index 00000000000..26a3c7247d4 --- /dev/null +++ b/feature/account/settings/impl/src/main/kotlin/net/thunderbird/feature/account/settings/impl/ui/fetchingMail/FetchingMailSettingsBuilder.kt @@ -0,0 +1,394 @@ +package net.thunderbird.feature.account.settings.impl.ui.fetchingMail + +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList +import net.thunderbird.core.android.account.DeletePolicy +import net.thunderbird.core.android.account.Expunge +import net.thunderbird.core.common.resources.StringsResourceManager +import net.thunderbird.core.ui.setting.Setting +import net.thunderbird.core.ui.setting.SettingValue +import net.thunderbird.core.ui.setting.SettingValue.Select.SelectOption +import net.thunderbird.core.ui.setting.Settings +import net.thunderbird.feature.account.settings.R + +@Suppress("TooManyFunctions") +class FetchingMailSettingsBuilder( + private val resources: StringsResourceManager, +) : FetchingMailSettingsContract.SettingsBuilder { + + override fun buildCoreFetchingMailSettings( + state: FetchingMailSettingsContract.State, + onEvent: (FetchingMailSettingsContract.Event) -> Unit, + ): Settings { + val settings = mutableListOf() + settings += localFolderSize(value = state.localFolderSize) + settings += syncMessageFrom(value = state.syncMessageFrom) + settings += fetchMessageUpTo(value = state.fetchMessageUpTo) + settings += folderPollFrequency(value = state.folderPollFrequency) + settings += syncServerDeletions(value = state.syncServerDeletions) + settings += markAsReadWhenDeleted(value = state.markAsReadWhenDeleted) + settings += whenIDeleteAMessage(value = state.whenIDeleteAMessage) + settings += eraseDeletedMessageOnServer( + value = state.eraseDeletedMessageOnServer, + ) + settings += incomingServer(onEvent = onEvent) + settings += advanced(onEvent = onEvent) + + return settings.toImmutableList() + } + + override fun buildAdvancedFetchingMailSettings( + state: FetchingMailSettingsContract.State, + onEvent: (FetchingMailSettingsContract.Event) -> Unit, + ): Settings { + val settings = mutableListOf() + settings += maxFolderToCheckWithPush(value = state.maxFolderToCheckWithPush) + settings += refreshIdleConnection(value = state.refreshIdleConnection) + return settings.toImmutableList() + } + + val localFolderSizeOptions = persistentListOf( + SelectOption(10.toString()) { + resources.stringResource(R.string.account_settings_options_mail_display_count_10) + }, + SelectOption(25.toString()) { + resources.stringResource(R.string.account_settings_options_mail_display_count_25) + }, + SelectOption(50.toString()) { + resources.stringResource(R.string.account_settings_options_mail_display_count_50) + }, + SelectOption(100.toString()) { + resources.stringResource(R.string.account_settings_options_mail_display_count_100) + }, + SelectOption(250.toString()) { + resources.stringResource(R.string.account_settings_options_mail_display_count_250) + }, + SelectOption(500.toString()) { + resources.stringResource(R.string.account_settings_options_mail_display_count_500) + }, + SelectOption(1000.toString()) { + resources.stringResource(R.string.account_settings_options_mail_display_count_1000) + }, + SelectOption(2500.toString()) { + resources.stringResource(R.string.account_settings_options_mail_display_count_2500) + }, + SelectOption(5000.toString()) { + resources.stringResource(R.string.account_settings_options_mail_display_count_5000) + }, + SelectOption(10000.toString()) { + resources.stringResource(R.string.account_settings_options_mail_display_count_10000) + }, + SelectOption("all") { + resources.stringResource(R.string.account_settings_options_mail_display_count_all) + }, + ) + + val syncMessageFromOptions = persistentListOf( + SelectOption("-1") { + resources.stringResource(R.string.account_settings_message_age_any) + }, + SelectOption("0") { + resources.stringResource(R.string.account_settings_message_age_0) + }, + SelectOption("1") { + resources.stringResource(R.string.account_settings_message_age_1) + }, + SelectOption("2") { + resources.stringResource(R.string.account_settings_message_age_2) + }, + SelectOption("7") { + resources.stringResource(R.string.account_settings_message_age_7) + }, + SelectOption("14") { + resources.stringResource(R.string.account_settings_message_age_14) + }, + SelectOption("21") { + resources.stringResource(R.string.account_settings_message_age_21) + }, + SelectOption("28") { + resources.stringResource(R.string.account_settings_message_age_1_month) + }, + SelectOption("56") { + resources.stringResource(R.string.account_settings_message_age_2_months) + }, + SelectOption("84") { + resources.stringResource(R.string.account_settings_message_age_3_months) + }, + SelectOption("168") { + resources.stringResource(R.string.account_settings_message_age_6_months) + }, + SelectOption("365") { + resources.stringResource(R.string.account_settings_message_age_1_year) + }, + ) + + val fetchMessageUpToOptions = persistentListOf( + SelectOption("1024") { + resources.stringResource(R.string.account_settings_autodownload_message_size_1) + }, + SelectOption("2048") { + resources.stringResource(R.string.account_settings_autodownload_message_size_2) + }, + SelectOption("4096") { + resources.stringResource(R.string.account_settings_autodownload_message_size_4) + }, + SelectOption("8192") { + resources.stringResource(R.string.account_settings_autodownload_message_size_8) + }, + SelectOption("16384") { + resources.stringResource(R.string.account_settings_autodownload_message_size_16) + }, + SelectOption("32768") { + resources.stringResource(R.string.account_settings_autodownload_message_size_32) + }, + SelectOption("65536") { + resources.stringResource(R.string.account_settings_autodownload_message_size_64) + }, + SelectOption("131072") { + resources.stringResource(R.string.account_settings_autodownload_message_size_128) + }, + SelectOption("262144") { + resources.stringResource(R.string.account_settings_autodownload_message_size_256) + }, + SelectOption("524288") { + resources.stringResource(R.string.account_settings_autodownload_message_size_512) + }, + SelectOption("1048576") { + resources.stringResource(R.string.account_settings_autodownload_message_size_1024) + }, + SelectOption("2097152") { + resources.stringResource(R.string.account_settings_autodownload_message_size_2048) + }, + SelectOption("5242880") { + resources.stringResource(R.string.account_settings_autodownload_message_size_5120) + }, + SelectOption("10485760") { + resources.stringResource(R.string.account_settings_autodownload_message_size_10240) + }, + SelectOption("0") { + resources.stringResource(R.string.account_settings_autodownload_message_size_any) + }, + ) + + val folderPollFrequencyOptions = persistentListOf( + SelectOption("-1") { + resources.stringResource(R.string.account_settings_options_mail_check_frequency_never) + }, + SelectOption("15") { + resources.stringResource(R.string.account_settings_options_mail_check_frequency_15min) + }, + SelectOption("30") { + resources.stringResource(R.string.account_settings_options_mail_check_frequency_30min) + }, + SelectOption("60") { + resources.stringResource(R.string.account_settings_options_mail_check_frequency_1hour) + }, + SelectOption("120") { + resources.stringResource(R.string.account_settings_options_mail_check_frequency_2hour) + }, + SelectOption("180") { + resources.stringResource(R.string.account_settings_options_mail_check_frequency_3hour) + }, + SelectOption("360") { + resources.stringResource(R.string.account_settings_options_mail_check_frequency_6hour) + }, + SelectOption("720") { + resources.stringResource(R.string.account_settings_options_mail_check_frequency_12hour) + }, + SelectOption("1440") { + resources.stringResource(R.string.account_settings_options_mail_check_frequency_24hour) + }, + ) + + val eraseDeletedMessageOnServerOptions = persistentListOf( + SelectOption(Expunge.EXPUNGE_IMMEDIATELY.name) { + resources.stringResource(R.string.account_settings_expunge_policy_immediately) + }, + SelectOption(Expunge.EXPUNGE_ON_POLL.name) { + resources.stringResource(R.string.account_settings_expunge_policy_on_poll) + }, + SelectOption(Expunge.EXPUNGE_MANUALLY.name) { + resources.stringResource(R.string.account_settings_expunge_policy_manual) + }, + ) + + val whenIDeleteAMessageOptions = persistentListOf( + SelectOption(DeletePolicy.NEVER.name) { + resources.stringResource(R.string.account_settings_incoming_delete_policy_never_label) + }, + SelectOption(DeletePolicy.ON_DELETE.name) { + resources.stringResource(R.string.account_settings_incoming_delete_policy_delete_label) + }, + SelectOption(DeletePolicy.MARK_AS_READ.name) { + resources.stringResource(R.string.account_settings_incoming_delete_policy_markread_label) + }, + ) + + val maxFolderToCheckWithPushOptions = persistentListOf( + SelectOption("5") { + resources.stringResource(R.string.account_settings_push_limit_5) + }, + SelectOption("10") { + resources.stringResource(R.string.account_settings_push_limit_10) + }, + SelectOption("25") { + resources.stringResource(R.string.account_settings_push_limit_25) + }, + SelectOption("50") { + resources.stringResource(R.string.account_settings_push_limit_50) + }, + SelectOption("100") { + resources.stringResource(R.string.account_settings_push_limit_100) + }, + SelectOption("250") { + resources.stringResource(R.string.account_settings_push_limit_250) + }, + SelectOption("500") { + resources.stringResource(R.string.account_settings_push_limit_500) + }, + SelectOption("1000") { + resources.stringResource(R.string.account_settings_push_limit_1000) + }, + ) + + val refreshIdleConnectionOptions = persistentListOf( + SelectOption("2") { + resources.stringResource(R.string.account_settings_idle_refresh_period_2min) + }, + SelectOption("3") { + resources.stringResource(R.string.account_settings_idle_refresh_period_3min) + }, + SelectOption("6") { + resources.stringResource(R.string.account_settings_idle_refresh_period_6min) + }, + SelectOption("12") { + resources.stringResource(R.string.account_settings_idle_refresh_period_12min) + }, + SelectOption("24") { + resources.stringResource(R.string.account_settings_idle_refresh_period_24min) + }, + SelectOption("36") { + resources.stringResource(R.string.account_settings_idle_refresh_period_36min) + }, + SelectOption("48") { + resources.stringResource(R.string.account_settings_idle_refresh_period_48min) + }, + SelectOption("60") { + resources.stringResource(R.string.account_settings_idle_refresh_period_60min) + }, + + ) + + private fun localFolderSize(value: SelectOption): Setting = SettingValue.Select( + id = FetchingMailSettingsId.LOCAL_FOLDER_SIZE, + title = { resources.stringResource(R.string.account_settings_mail_display_count_label) }, + description = { null }, + icon = { null }, + displayValueAsSecondaryText = true, + value = value, + options = localFolderSizeOptions, + ) + + private fun syncMessageFrom(value: SelectOption): Setting = SettingValue.Select( + id = FetchingMailSettingsId.SYNC_MESSAGE_FROM, + title = { resources.stringResource(R.string.account_settings_message_age_label) }, + description = { null }, + icon = { null }, + displayValueAsSecondaryText = true, + value = value, + options = syncMessageFromOptions, + ) + + private fun fetchMessageUpTo(value: SelectOption): Setting = SettingValue.Select( + id = FetchingMailSettingsId.FETCH_MESSAGE_UP_TO, + title = { resources.stringResource(R.string.account_settings_autodownload_message_size_label) }, + description = { null }, + icon = { null }, + displayValueAsSecondaryText = true, + value = value, + options = fetchMessageUpToOptions, + ) + + private fun folderPollFrequency(value: SelectOption): Setting = SettingValue.Select( + id = FetchingMailSettingsId.FOLDER_POLL_FREQUENCY, + title = { resources.stringResource(R.string.account_settings_mail_check_frequency_label) }, + description = { null }, + icon = { null }, + displayValueAsSecondaryText = true, + value = value, + options = folderPollFrequencyOptions, + ) + + private fun syncServerDeletions(value: Boolean): Setting = SettingValue.Switch( + id = FetchingMailSettingsId.SYNC_SERVER_DELETIONS, + title = { resources.stringResource(R.string.account_settings_sync_remote_deletetions_label) }, + description = { resources.stringResource(R.string.account_settings_sync_remote_deletetions_summary) }, + value = value, + ) + + private fun markAsReadWhenDeleted(value: Boolean): Setting = SettingValue.Switch( + id = FetchingMailSettingsId.MARK_AS_READ_WHEN_DELETED, + title = { resources.stringResource(R.string.account_settings_mark_message_as_read_on_delete_label) }, + description = { resources.stringResource(R.string.account_settings_mark_message_as_read_on_delete_summary) }, + value = value, + ) + + private fun whenIDeleteAMessage(value: SelectOption): Setting = SettingValue.Select( + id = FetchingMailSettingsId.WHEN_I_DELETE_A_MESSAGE, + title = { resources.stringResource(R.string.account_settings_incoming_delete_policy_label) }, + description = { null }, + icon = { null }, + displayValueAsSecondaryText = true, + value = value, + options = whenIDeleteAMessageOptions, + ) + + private fun eraseDeletedMessageOnServer(value: SelectOption): Setting = SettingValue.Select( + id = FetchingMailSettingsId.ERASE_DELETED_MESSAGE_ON_SERVER, + title = { resources.stringResource(R.string.account_settings_expunge_policy_label) }, + description = { null }, + icon = { null }, + displayValueAsSecondaryText = true, + value = value, + options = eraseDeletedMessageOnServerOptions, + ) + + private fun incomingServer(onEvent: (FetchingMailSettingsContract.Event) -> Unit): Setting = + SettingValue.ActionText( + id = FetchingMailSettingsId.IN_COMING_SERVER, + title = { resources.stringResource(R.string.account_settings_incoming_label) }, + description = { null }, + icon = { null }, + value = resources.stringResource(R.string.account_settings_incoming_summary), + onClick = { onEvent(FetchingMailSettingsContract.Event.OnInComingServerClick) }, + ) + + private fun advanced(onEvent: (FetchingMailSettingsContract.Event) -> Unit): Setting = SettingValue.ActionText( + id = FetchingMailSettingsId.ADVANCE, + title = { resources.stringResource(R.string.account_settings_push_advanced_title) }, + description = { null }, + icon = { null }, + value = "", + onClick = { onEvent(FetchingMailSettingsContract.Event.OnAdvanceClick) }, + ) + + private fun maxFolderToCheckWithPush(value: SelectOption): Setting = SettingValue.Select( + id = FetchingMailSettingsId.MAX_FOLDER_TO_CHECK_WITH_PUSH, + title = { resources.stringResource(R.string.account_settings_push_limit_label) }, + description = { null }, + icon = { null }, + displayValueAsSecondaryText = true, + value = value, + options = maxFolderToCheckWithPushOptions, + ) + + private fun refreshIdleConnection(value: SelectOption): Setting = SettingValue.Select( + id = FetchingMailSettingsId.REFRESH_IDLE_CONNECTION, + title = { resources.stringResource(R.string.account_settings_idle_refresh_period_label) }, + description = { null }, + icon = { null }, + displayValueAsSecondaryText = true, + value = value, + options = refreshIdleConnectionOptions, + ) +} diff --git a/feature/account/settings/impl/src/main/kotlin/net/thunderbird/feature/account/settings/impl/ui/fetchingMail/FetchingMailSettingsContent.kt b/feature/account/settings/impl/src/main/kotlin/net/thunderbird/feature/account/settings/impl/ui/fetchingMail/FetchingMailSettingsContent.kt new file mode 100644 index 00000000000..758c07fa0d6 --- /dev/null +++ b/feature/account/settings/impl/src/main/kotlin/net/thunderbird/feature/account/settings/impl/ui/fetchingMail/FetchingMailSettingsContent.kt @@ -0,0 +1,145 @@ +package net.thunderbird.feature.account.settings.impl.ui.fetchingMail + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import app.k9mail.core.ui.compose.designsystem.atom.DropdownMenuBox +import app.k9mail.core.ui.compose.designsystem.atom.button.ButtonIcon +import app.k9mail.core.ui.compose.designsystem.organism.AlertDialog +import kotlinx.collections.immutable.persistentListOf +import net.thunderbird.core.common.provider.AppNameProvider +import net.thunderbird.core.ui.compose.designsystem.atom.icon.Icons +import net.thunderbird.core.ui.setting.Setting +import net.thunderbird.core.ui.setting.SettingValue +import net.thunderbird.core.ui.setting.SettingViewProvider +import net.thunderbird.feature.account.settings.R +import net.thunderbird.feature.account.settings.impl.ui.fetchingMail.FetchingMailSettingsContract.Event +import net.thunderbird.feature.account.settings.impl.ui.fetchingMail.FetchingMailSettingsContract.SettingsBuilder +import net.thunderbird.feature.account.settings.impl.ui.fetchingMail.FetchingMailSettingsContract.State + +@Suppress("LongMethod") +@Composable +internal fun FetchingMailSettingsContent( + state: State, + onEvent: (Event) -> Unit, + onAccountRemove: () -> Unit, + provider: SettingViewProvider, + builder: SettingsBuilder, + appNameProvider: AppNameProvider, + modifier: Modifier = Modifier, +) { + val settings = remember(state, builder, onEvent) { + builder.buildCoreFetchingMailSettings(state = state, onEvent = onEvent) + } + + var showDialog by remember { mutableStateOf(false) } + + provider.SettingView( + title = stringResource(R.string.account_settings_fetching_mail), + subtitle = state.subtitle, + settings = settings, + onSettingValueChange = { setting -> + handleSettingChange(setting, onEvent) + }, + onBack = { onEvent(Event.OnBackPressed) }, + modifier = modifier, + actions = { + var expanded by remember { mutableStateOf(false) } + + DropdownMenuBox( + expanded = expanded, + onExpandedChange = { shouldExpand -> + expanded = shouldExpand + }, + options = persistentListOf( + stringResource(R.string.account_settings_remove_account_action), + ), + onItemSelected = { + showDialog = true + expanded = false + }, + ) { + ButtonIcon( + onClick = { expanded = true }, + imageVector = Icons.Outlined.MoreVert, + ) + } + }, + ) + + if (showDialog) { + AlertDialog( + title = stringResource(R.string.account_settings_account_delete_dlg_title), + text = stringResource( + R.string.account_settings_account_delete_dlg_instructions_fmt, + state.subtitle.toString(), + appNameProvider.appName, + ), + confirmText = stringResource(R.string.account_settings_okay_action), + dismissText = stringResource(R.string.account_settings_cancel_action), + onConfirmClick = { + showDialog = false + onAccountRemove() + }, + onDismissClick = { showDialog = false }, + onDismissRequest = { showDialog = false }, + ) + } +} + +@Suppress("CyclomaticComplexMethod") +private fun handleSettingChange( + setting: Setting, + onEvent: (Event) -> Unit, +) { + when (setting) { + is SettingValue.Switch -> { + when (setting.id) { + FetchingMailSettingsId.SYNC_SERVER_DELETIONS -> onEvent( + Event.OnSyncServerDeletionsToggle(setting.value), + ) + FetchingMailSettingsId.MARK_AS_READ_WHEN_DELETED -> onEvent( + Event.OnMarkAsReadWhenDeletedToggle(setting.value), + ) + else -> Unit + } + } + is SettingValue.Select -> { + when (setting.id) { + FetchingMailSettingsId.LOCAL_FOLDER_SIZE -> { + onEvent(Event.OnLocalFolderSizeChange(setting.value)) + } + FetchingMailSettingsId.SYNC_MESSAGE_FROM -> { + onEvent(Event.OnSyncMessageFromChange(setting.value)) + } + FetchingMailSettingsId.FOLDER_POLL_FREQUENCY -> { + onEvent(Event.OnFolderPollFrequencyChange(setting.value)) + } + FetchingMailSettingsId.WHEN_I_DELETE_A_MESSAGE -> { + onEvent(Event.OnWhenIDeleteAMessageChange(setting.value)) + } + FetchingMailSettingsId.ERASE_DELETED_MESSAGE_ON_SERVER -> { + onEvent(Event.OnEraseDeletedMessageOnServerChange(setting.value)) + } + FetchingMailSettingsId.FETCH_MESSAGE_UP_TO -> { + onEvent(Event.OnFetchMessageUpToChange(setting.value)) + } + else -> Unit + } + } + is SettingValue.Text -> { + when (setting.id) { + FetchingMailSettingsId.IN_COMING_SERVER -> { + onEvent(Event.OnInComingServerClick) + } + else -> Unit + } + } + + else -> Unit + } +} diff --git a/feature/account/settings/impl/src/main/kotlin/net/thunderbird/feature/account/settings/impl/ui/fetchingMail/FetchingMailSettingsContract.kt b/feature/account/settings/impl/src/main/kotlin/net/thunderbird/feature/account/settings/impl/ui/fetchingMail/FetchingMailSettingsContract.kt new file mode 100644 index 00000000000..4f61705ca4b --- /dev/null +++ b/feature/account/settings/impl/src/main/kotlin/net/thunderbird/feature/account/settings/impl/ui/fetchingMail/FetchingMailSettingsContract.kt @@ -0,0 +1,67 @@ +package net.thunderbird.feature.account.settings.impl.ui.fetchingMail + +import androidx.compose.runtime.Stable +import net.thunderbird.core.ui.contract.mvi.UnidirectionalViewModel +import net.thunderbird.core.ui.setting.SettingValue.Select.SelectOption +import net.thunderbird.core.ui.setting.Settings + +@Suppress("standard:max-line-length") +interface FetchingMailSettingsContract { + interface ViewModel : UnidirectionalViewModel + + @Stable + data class State( + val subtitle: String? = null, + val localFolderSize: SelectOption, + val syncMessageFrom: SelectOption, + val fetchMessageUpTo: SelectOption, + val folderPollFrequency: SelectOption, + val syncServerDeletions: Boolean = false, + val markAsReadWhenDeleted: Boolean = false, + val whenIDeleteAMessage: SelectOption, + val eraseDeletedMessageOnServer: SelectOption, + val maxFolderToCheckWithPush: SelectOption, + val refreshIdleConnection: SelectOption, + ) + + sealed interface Event { + data object OnBackPressed : Event + data class OnLocalFolderSizeChange(val localFolderSize: SelectOption) : Event + data class OnSyncMessageFromChange(val syncMessageFrom: SelectOption) : Event + data class OnFetchMessageUpToChange(val fetchMessageUpTo: SelectOption) : Event + data class OnFolderPollFrequencyChange(val folderPollFrequency: SelectOption) : Event + data class OnSyncServerDeletionsToggle( + val syncServerDeletions: Boolean, + ) : Event + + data class OnMarkAsReadWhenDeletedToggle( + val markAsReadWhenDeleted: Boolean, + ) : Event + + data class OnWhenIDeleteAMessageChange(val whenIDeleteAMessage: SelectOption) : Event + data class OnEraseDeletedMessageOnServerChange(val eraseDeletedMessageOnServer: SelectOption) : Event + data object OnInComingServerClick : Event + data object OnAdvanceClick : Event + + data class OnMaxFolderToCheckWithPushChange(val maxFolderToCheckWithPushChanges: SelectOption) : Event + data class OnRefreshIdleConnectionFrequencyChange(val refreshIdleConnectionFrequency: SelectOption) : Event + } + + sealed interface Effect { + object NavigateBack : Effect + object NavigateToIncomingServerSettings : Effect + object NavigateToAdvancedFetchingMailSettings : Effect + } + + interface SettingsBuilder { + fun buildCoreFetchingMailSettings( + state: State, + onEvent: (Event) -> Unit, + ): Settings + + fun buildAdvancedFetchingMailSettings( + state: State, + onEvent: (Event) -> Unit, + ): Settings + } +} diff --git a/feature/account/settings/impl/src/main/kotlin/net/thunderbird/feature/account/settings/impl/ui/fetchingMail/FetchingMailSettingsId.kt b/feature/account/settings/impl/src/main/kotlin/net/thunderbird/feature/account/settings/impl/ui/fetchingMail/FetchingMailSettingsId.kt new file mode 100644 index 00000000000..c27209484f0 --- /dev/null +++ b/feature/account/settings/impl/src/main/kotlin/net/thunderbird/feature/account/settings/impl/ui/fetchingMail/FetchingMailSettingsId.kt @@ -0,0 +1,16 @@ +package net.thunderbird.feature.account.settings.impl.ui.fetchingMail + +object FetchingMailSettingsId { + const val LOCAL_FOLDER_SIZE = "local_folder_size" + const val SYNC_MESSAGE_FROM = "sync_message_from" + const val FETCH_MESSAGE_UP_TO = "fetch_message_up_to" + const val FOLDER_POLL_FREQUENCY = "folder_poll_frequency" + const val SYNC_SERVER_DELETIONS = "sync_server_deletions" + const val MARK_AS_READ_WHEN_DELETED = "mark_as_read_when_deleted" + const val WHEN_I_DELETE_A_MESSAGE = "when_i_delete_a_message" + const val ERASE_DELETED_MESSAGE_ON_SERVER = "erase_deleted_message_on_server" + const val IN_COMING_SERVER = "in_coming_server" + const val ADVANCE = "advance" + const val MAX_FOLDER_TO_CHECK_WITH_PUSH = "max_folder_to_check_with_push" + const val REFRESH_IDLE_CONNECTION = "refresh_idle_connection" +} diff --git a/feature/account/settings/impl/src/main/kotlin/net/thunderbird/feature/account/settings/impl/ui/fetchingMail/FetchingMailSettingsScreen.kt b/feature/account/settings/impl/src/main/kotlin/net/thunderbird/feature/account/settings/impl/ui/fetchingMail/FetchingMailSettingsScreen.kt new file mode 100644 index 00000000000..ac0efdd8ee9 --- /dev/null +++ b/feature/account/settings/impl/src/main/kotlin/net/thunderbird/feature/account/settings/impl/ui/fetchingMail/FetchingMailSettingsScreen.kt @@ -0,0 +1,71 @@ +package net.thunderbird.feature.account.settings.impl.ui.fetchingMail + +import android.annotation.SuppressLint +import android.app.Activity +import androidx.activity.ComponentActivity +import androidx.activity.compose.BackHandler +import androidx.activity.compose.LocalActivity +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalContext +import app.k9mail.feature.launcher.FeatureLauncherActivity +import app.k9mail.feature.launcher.FeatureLauncherTarget +import kotlin.uuid.ExperimentalUuidApi +import net.thunderbird.core.common.provider.AppNameProvider +import net.thunderbird.core.ui.contract.mvi.observe +import net.thunderbird.core.ui.setting.SettingViewProvider +import net.thunderbird.feature.account.AccountId +import net.thunderbird.feature.account.settings.impl.ui.fetchingMail.FetchingMailSettingsContract.Effect +import org.koin.androidx.compose.koinViewModel +import org.koin.compose.koinInject +import org.koin.core.parameter.parametersOf + +@OptIn(ExperimentalUuidApi::class) +@SuppressLint("ContextCastToActivity") +@Composable +internal fun FetchingMailSettingsScreen( + accountId: AccountId, + onBack: () -> Unit, + viewModel: FetchingMailSettingsContract.ViewModel = koinViewModel { + parametersOf(accountId) + }, + provider: SettingViewProvider = koinInject(), + builder: FetchingMailSettingsContract.SettingsBuilder = koinInject(), + appNameProvider: AppNameProvider = koinInject(), +) { + val context = LocalContext.current + val (state, dispatch) = viewModel.observe { effect -> + when (effect) { + is Effect.NavigateBack -> { + onBack() + } + is Effect.NavigateToIncomingServerSettings -> { + FeatureLauncherActivity.launch( + context = context, + target = FeatureLauncherTarget.AccountEditIncomingSettings(accountUuid = "${accountId.value}"), + ) + } + is Effect.NavigateToAdvancedFetchingMailSettings -> { + FeatureLauncherActivity.launch( + context = context, + target = FeatureLauncherTarget.AccountAdvancedFetchingMailSettings( + accountUuid = "${accountId.value}", + ), + ) + } + } + } + val activity = LocalActivity.current as ComponentActivity + BackHandler(onBack = onBack) + + FetchingMailSettingsContent( + state = state.value, + onEvent = { dispatch(it) }, + provider = provider, + builder = builder, + appNameProvider = appNameProvider, + onAccountRemove = { + activity.setResult(Activity.RESULT_OK) + activity.finish() + }, + ) +} diff --git a/feature/account/settings/impl/src/main/kotlin/net/thunderbird/feature/account/settings/impl/ui/fetchingMail/FetchingMailSettingsViewModel.kt b/feature/account/settings/impl/src/main/kotlin/net/thunderbird/feature/account/settings/impl/ui/fetchingMail/FetchingMailSettingsViewModel.kt new file mode 100644 index 00000000000..f753eb2b37f --- /dev/null +++ b/feature/account/settings/impl/src/main/kotlin/net/thunderbird/feature/account/settings/impl/ui/fetchingMail/FetchingMailSettingsViewModel.kt @@ -0,0 +1,769 @@ +package net.thunderbird.feature.account.settings.impl.ui.fetchingMail + +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch +import net.thunderbird.core.android.account.DeletePolicy +import net.thunderbird.core.android.account.Expunge +import net.thunderbird.core.common.resources.StringsResourceManager +import net.thunderbird.core.logging.Logger +import net.thunderbird.core.outcome.handle +import net.thunderbird.core.ui.contract.mvi.BaseViewModel +import net.thunderbird.core.ui.setting.SettingValue.Select.SelectOption +import net.thunderbird.feature.account.AccountId +import net.thunderbird.feature.account.settings.R +import net.thunderbird.feature.account.settings.impl.domain.AccountSettingsDomainContract +import net.thunderbird.feature.account.settings.impl.domain.AccountSettingsDomainContract.AccountSettingError +import net.thunderbird.feature.account.settings.impl.domain.AccountSettingsDomainContract.UseCase +import net.thunderbird.feature.account.settings.impl.ui.fetchingMail.FetchingMailSettingsContract.Effect +import net.thunderbird.feature.account.settings.impl.ui.fetchingMail.FetchingMailSettingsContract.Event +import net.thunderbird.feature.account.settings.impl.ui.fetchingMail.FetchingMailSettingsContract.State + +private const val TAG = "FetchingMailSettingsViewModel" + +@Suppress("LargeClass") +internal class FetchingMailSettingsViewModel( + private val accountId: AccountId, + private val logger: Logger, + private val resources: StringsResourceManager, + private val getAccountName: UseCase.GetAccountName, + private val getLegacyAccount: UseCase.GetLegacyAccount, + private val updateFetchingMailSettings: UseCase.UpdateFetchingMailSettings, + initialState: State = State( + localFolderSize = SelectOption(10.toString()) { + resources.stringResource(R.string.account_settings_options_mail_display_count_10) + }, + syncMessageFrom = SelectOption("-1") { + resources.stringResource(R.string.account_settings_message_age_any) + }, + fetchMessageUpTo = SelectOption("1024") { + resources.stringResource(R.string.account_settings_autodownload_message_size_1) + }, + folderPollFrequency = SelectOption("-1") { + resources.stringResource(R.string.account_settings_options_mail_check_frequency_never) + }, + syncServerDeletions = false, + markAsReadWhenDeleted = false, + whenIDeleteAMessage = SelectOption("-1") { + resources.stringResource(R.string.account_settings_incoming_delete_policy_never_label) + }, + eraseDeletedMessageOnServer = SelectOption("0") { + resources.stringResource(R.string.account_settings_expunge_policy_immediately) + }, + maxFolderToCheckWithPush = SelectOption("5") { + resources.stringResource(R.string.account_settings_push_limit_5) + }, + refreshIdleConnection = SelectOption("15") { + resources.stringResource(R.string.account_settings_options_mail_check_frequency_15min) + }, + + ), +) : BaseViewModel(initialState = initialState), FetchingMailSettingsContract.ViewModel { + + init { + observeFetchingMailSettings() + observeAccountName() + } + + @Suppress("LongMethod", "CyclomaticComplexMethod") + override fun event(event: Event) { + when (event) { + is Event.OnBackPressed -> { + emitEffect(Effect.NavigateBack) + } + + is Event.OnLocalFolderSizeChange -> { + viewModelScope.launch { + updateFetchingMailSettings( + accountId = accountId, + command = AccountSettingsDomainContract + .UpdateFetchingMailSettingsCommand.UpdateLocalFolderSize( + event.localFolderSize.id.toInt(), + ), + ).handle( + onSuccess = { + updateState { state -> state.copy(localFolderSize = event.localFolderSize) } + }, + onFailure = { + handleError(it) + }, + ) + } + } + + is Event.OnSyncMessageFromChange -> { + viewModelScope.launch { + updateFetchingMailSettings( + accountId = accountId, + command = AccountSettingsDomainContract + .UpdateFetchingMailSettingsCommand.UpdateSyncMessageFrom( + event.syncMessageFrom.id.toInt(), + ), + ).handle( + onSuccess = { + updateState { state -> state.copy(syncMessageFrom = event.syncMessageFrom) } + }, + onFailure = { + handleError(it) + }, + ) + } + } + + is Event.OnFetchMessageUpToChange -> { + viewModelScope.launch { + updateFetchingMailSettings( + accountId = accountId, + command = AccountSettingsDomainContract + .UpdateFetchingMailSettingsCommand.UpdateFetchMessageUpTo( + event.fetchMessageUpTo.id.toInt(), + ), + ).handle( + onSuccess = { + updateState { state -> state.copy(fetchMessageUpTo = event.fetchMessageUpTo) } + }, + onFailure = { + handleError(it) + }, + ) + } + } + + is Event.OnFolderPollFrequencyChange -> { + viewModelScope.launch { + updateFetchingMailSettings( + accountId = accountId, + command = AccountSettingsDomainContract + .UpdateFetchingMailSettingsCommand.UpdateFolderPollFrequency( + event.folderPollFrequency.id.toInt(), + ), + ).handle( + onSuccess = { + updateState { state -> state.copy(folderPollFrequency = event.folderPollFrequency) } + }, + onFailure = { + handleError(it) + }, + ) + } + } + + is Event.OnSyncServerDeletionsToggle -> { + viewModelScope.launch { + updateFetchingMailSettings( + accountId = accountId, + command = AccountSettingsDomainContract + .UpdateFetchingMailSettingsCommand.UpdateSyncServerDeletions( + event.syncServerDeletions, + ), + ).handle( + onSuccess = { + updateState { state -> state.copy(syncServerDeletions = event.syncServerDeletions) } + }, + onFailure = { + handleError(it) + }, + ) + } + } + + is Event.OnMarkAsReadWhenDeletedToggle -> { + viewModelScope.launch { + updateFetchingMailSettings( + accountId = accountId, + command = AccountSettingsDomainContract + .UpdateFetchingMailSettingsCommand.UpdateMarkAsReadWhenDeleted( + event.markAsReadWhenDeleted, + ), + ).handle( + onSuccess = { + updateState { state -> state.copy(markAsReadWhenDeleted = event.markAsReadWhenDeleted) } + }, + onFailure = { + handleError(it) + }, + ) + } + } + + is Event.OnWhenIDeleteAMessageChange -> { + viewModelScope.launch { + updateFetchingMailSettings( + accountId = accountId, + command = AccountSettingsDomainContract + .UpdateFetchingMailSettingsCommand.UpdateWhenIDeleteAMessage( + event.whenIDeleteAMessage.id, + ), + ).handle( + onSuccess = { + updateState { state -> state.copy(whenIDeleteAMessage = event.whenIDeleteAMessage) } + }, + onFailure = { + handleError(it) + }, + ) + } + } + + is Event.OnEraseDeletedMessageOnServerChange -> { + viewModelScope.launch { + updateFetchingMailSettings( + accountId = accountId, + command = AccountSettingsDomainContract + .UpdateFetchingMailSettingsCommand.UpdateEraseDeletedMessageOnServer( + event.eraseDeletedMessageOnServer.id, + ), + ).handle( + onSuccess = { + updateState { state -> + state.copy(eraseDeletedMessageOnServer = event.eraseDeletedMessageOnServer) + } + }, + onFailure = { + handleError(it) + }, + ) + } + } + + is Event.OnInComingServerClick -> { + emitEffect(Effect.NavigateToIncomingServerSettings) + } + + is Event.OnAdvanceClick -> { + emitEffect(Effect.NavigateToAdvancedFetchingMailSettings) + } + + is Event.OnMaxFolderToCheckWithPushChange -> { + viewModelScope.launch { + updateFetchingMailSettings( + accountId = accountId, + command = AccountSettingsDomainContract + .UpdateFetchingMailSettingsCommand.UpdateOnMaxFolderToCheckWithPushChange( + event.maxFolderToCheckWithPushChanges.id.toInt(), + ), + ).handle( + onSuccess = { + updateState { state -> + state.copy(maxFolderToCheckWithPush = event.maxFolderToCheckWithPushChanges) + } + }, + onFailure = { + handleError(it) + }, + ) + } + } + + is Event.OnRefreshIdleConnectionFrequencyChange -> { + viewModelScope.launch { + updateFetchingMailSettings( + accountId = accountId, + command = AccountSettingsDomainContract + .UpdateFetchingMailSettingsCommand.UpdateRefreshIdleConnectionFrequencyChange( + event.refreshIdleConnectionFrequency.id.toInt(), + ), + ).handle( + onSuccess = { + updateState { state -> + state.copy(refreshIdleConnection = event.refreshIdleConnectionFrequency) + } + }, + onFailure = { + handleError(it) + }, + ) + } + } + } + } + + @Suppress("LongMethod", "CyclomaticComplexMethod") + private fun observeFetchingMailSettings() { + viewModelScope.launch { + getLegacyAccount(accountId).handle( + onSuccess = { + val localFolderSize = when (it.displayCount) { + 10 -> { + SelectOption(10.toString()) { + resources.stringResource(R.string.account_settings_options_mail_display_count_10) + } + } + + 25 -> { + SelectOption(25.toString()) { + resources.stringResource(R.string.account_settings_options_mail_display_count_25) + } + } + + 50 -> { + SelectOption(50.toString()) { + resources.stringResource(R.string.account_settings_options_mail_display_count_50) + } + } + + 100 -> { + SelectOption(100.toString()) { + resources.stringResource(R.string.account_settings_options_mail_display_count_100) + } + } + + 250 -> { + SelectOption(250.toString()) { + resources.stringResource(R.string.account_settings_options_mail_display_count_250) + } + } + + 500 -> { + SelectOption(500.toString()) { + resources.stringResource(R.string.account_settings_options_mail_display_count_500) + } + } + + 1000 -> { + SelectOption(1000.toString()) { + resources.stringResource(R.string.account_settings_options_mail_display_count_1000) + } + } + + 2500 -> { + SelectOption(2500.toString()) { + resources.stringResource(R.string.account_settings_options_mail_display_count_2500) + } + } + + 5000 -> { + SelectOption(5000.toString()) { + resources.stringResource(R.string.account_settings_options_mail_display_count_5000) + } + } + + 10000 -> { + SelectOption(10000.toString()) { + resources.stringResource(R.string.account_settings_options_mail_display_count_10000) + } + } + + else -> { + SelectOption("all") { + resources.stringResource(R.string.account_settings_options_mail_display_count_all) + } + } + } + val fetchMessageUpTo = when (it.maximumAutoDownloadMessageSize) { + 1024 -> { + SelectOption(1024.toString()) { + resources.stringResource(R.string.account_settings_autodownload_message_size_1) + } + } + + 2048 -> { + SelectOption(2048.toString()) { + resources.stringResource(R.string.account_settings_autodownload_message_size_2) + } + } + + 4096 -> { + SelectOption(4096.toString()) { + resources.stringResource(R.string.account_settings_autodownload_message_size_4) + } + } + + 8192 -> { + SelectOption(8192.toString()) { + resources.stringResource(R.string.account_settings_autodownload_message_size_8) + } + } + + 16384 -> { + SelectOption(16384.toString()) { + resources.stringResource(R.string.account_settings_autodownload_message_size_16) + } + } + + 32768 -> { + SelectOption(32768.toString()) { + resources.stringResource(R.string.account_settings_autodownload_message_size_32) + } + } + + 65536 -> { + SelectOption(65536.toString()) { + resources.stringResource(R.string.account_settings_autodownload_message_size_64) + } + } + + 131072 -> { + SelectOption(131072.toString()) { + resources.stringResource(R.string.account_settings_autodownload_message_size_128) + } + } + + 262144 -> { + SelectOption(262144.toString()) { + resources.stringResource(R.string.account_settings_autodownload_message_size_256) + } + } + + 524288 -> { + SelectOption(524288.toString()) { + resources.stringResource(R.string.account_settings_autodownload_message_size_512) + } + } + + 1048576 -> { + SelectOption(1048576.toString()) { + resources.stringResource(R.string.account_settings_autodownload_message_size_1024) + } + } + + 2097152 -> { + SelectOption(2097152.toString()) { + resources.stringResource(R.string.account_settings_autodownload_message_size_2048) + } + } + + 5242880 -> { + SelectOption(5242880.toString()) { + resources.stringResource(R.string.account_settings_autodownload_message_size_5120) + } + } + + 10485760 -> { + SelectOption(10485760.toString()) { + resources.stringResource(R.string.account_settings_autodownload_message_size_10240) + } + } + + else -> { + SelectOption(0.toString()) { + resources.stringResource(R.string.account_settings_options_mail_display_count_all) + } + } + } + val syncMessageFrom = when (it.maximumPolledMessageAge) { + 0 -> { + SelectOption("0") { + resources.stringResource(R.string.account_settings_message_age_0) + } + } + + 1 -> { + SelectOption("1") { + resources.stringResource(R.string.account_settings_message_age_1) + } + } + + 2 -> { + SelectOption("2") { + resources.stringResource(R.string.account_settings_message_age_2) + } + } + + 7 -> { + SelectOption("7") { + resources.stringResource(R.string.account_settings_message_age_7) + } + } + + 14 -> { + SelectOption("14") { + resources.stringResource(R.string.account_settings_message_age_14) + } + } + + 21 -> { + SelectOption("21") { + resources.stringResource(R.string.account_settings_message_age_21) + } + } + + 28 -> { + SelectOption("28") { + resources.stringResource(R.string.account_settings_message_age_1_month) + } + } + + 56 -> { + SelectOption("56") { + resources.stringResource(R.string.account_settings_message_age_2_months) + } + } + + 84 -> { + SelectOption("84") { + resources.stringResource(R.string.account_settings_message_age_3_months) + } + } + + 168 -> { + SelectOption("168") { + resources.stringResource(R.string.account_settings_message_age_6_months) + } + } + + 365 -> { + SelectOption("365") { + resources.stringResource(R.string.account_settings_message_age_1_year) + } + } + + else -> { + SelectOption("-1") { + resources.stringResource(R.string.account_settings_message_age_any) + } + } + } + val folderPollFrequency = when (it.automaticCheckIntervalMinutes) { + 15 -> { + SelectOption("15") { + resources.stringResource(R.string.account_settings_options_mail_check_frequency_15min) + } + } + + 30 -> { + SelectOption("30") { + resources.stringResource(R.string.account_settings_options_mail_check_frequency_30min) + } + } + + 60 -> { + SelectOption("60") { + resources.stringResource(R.string.account_settings_options_mail_check_frequency_1hour) + } + } + + 120 -> { + SelectOption("120") { + resources.stringResource(R.string.account_settings_options_mail_check_frequency_2hour) + } + } + + 180 -> { + SelectOption("180") { + resources.stringResource(R.string.account_settings_options_mail_check_frequency_3hour) + } + } + + 360 -> { + SelectOption("360") { + resources.stringResource(R.string.account_settings_options_mail_check_frequency_6hour) + } + } + + 720 -> { + SelectOption("720") { + resources.stringResource(R.string.account_settings_options_mail_check_frequency_12hour) + } + } + + 1440 -> { + SelectOption("1440") { + resources.stringResource(R.string.account_settings_options_mail_check_frequency_24hour) + } + } + + else -> { + SelectOption("-1") { + resources.stringResource(R.string.account_settings_options_mail_check_frequency_never) + } + } + } + val syncServerDeletions = it.isSyncRemoteDeletions + val markReadWhenDeleted = it.isMarkMessageAsReadOnDelete + val whenIDeleteAMessage = when (it.deletePolicy.name) { + "NEVER" -> { + SelectOption(DeletePolicy.NEVER.name) { + resources.stringResource(R.string.account_settings_incoming_delete_policy_never_label) + } + } + + "ON_DELETE" -> { + SelectOption(DeletePolicy.ON_DELETE.name) { + resources.stringResource(R.string.account_settings_incoming_delete_policy_delete_label) + } + } + + "MARK_AS_READ" -> { + SelectOption(DeletePolicy.MARK_AS_READ.name) { + resources.stringResource( + R.string.account_settings_incoming_delete_policy_markread_label, + ) + } + } + + else -> { + error("Invalid message delete policy") + } + } + val eraseDeletedMessageOnServer = when (it.expungePolicy.name) { + "EXPUNGE_IMMEDIATELY" -> { + SelectOption(Expunge.EXPUNGE_IMMEDIATELY.name) { + resources.stringResource(R.string.account_settings_expunge_policy_immediately) + } + } + + "EXPUNGE_ON_POLL" -> { + SelectOption(Expunge.EXPUNGE_ON_POLL.name) { + resources.stringResource(R.string.account_settings_expunge_policy_on_poll) + } + } + + "EXPUNGE_MANUALLY" -> { + SelectOption(Expunge.EXPUNGE_MANUALLY.name) { + resources.stringResource(R.string.account_settings_expunge_policy_manual) + } + } + + else -> { + error("Invalid message expungePolicy") + } + } + val maxFolderToCheckWithPush = when (it.maxPushFolders) { + 5 -> { + SelectOption("5") { + resources.stringResource(R.string.account_settings_push_limit_5) + } + } + + 10 -> { + SelectOption("10") { + resources.stringResource(R.string.account_settings_push_limit_10) + } + } + + 25 -> { + SelectOption("25") { + resources.stringResource(R.string.account_settings_push_limit_25) + } + } + + 50 -> { + SelectOption("50") { + resources.stringResource(R.string.account_settings_push_limit_50) + } + } + + 100 -> { + SelectOption("100") { + resources.stringResource(R.string.account_settings_push_limit_100) + } + } + + 250 -> { + SelectOption("250") { + resources.stringResource(R.string.account_settings_push_limit_250) + } + } + + 500 -> { + SelectOption("500") { + resources.stringResource(R.string.account_settings_push_limit_500) + } + } + + 1000 -> { + SelectOption("1000") { + resources.stringResource(R.string.account_settings_push_limit_1000) + } + } + + else -> { + error("Invalid push limit") + } + } + val refreshIdleConnection = when (it.idleRefreshMinutes) { + 2 -> { + SelectOption("2") { + resources.stringResource(R.string.account_settings_idle_refresh_period_2min) + } + } + + 3 -> { + SelectOption("3") { + resources.stringResource(R.string.account_settings_idle_refresh_period_3min) + } + } + + 6 -> { + SelectOption("6") { + resources.stringResource(R.string.account_settings_idle_refresh_period_6min) + } + } + + 12 -> { + SelectOption("12") { + resources.stringResource(R.string.account_settings_idle_refresh_period_12min) + } + } + + 24 -> { + SelectOption("24") { + resources.stringResource(R.string.account_settings_idle_refresh_period_24min) + } + } + + 36 -> { + SelectOption("36") { + resources.stringResource(R.string.account_settings_idle_refresh_period_36min) + } + } + + 48 -> { + SelectOption("48") { + resources.stringResource(R.string.account_settings_idle_refresh_period_48min) + } + } + + 60 -> { + SelectOption("60") { + resources.stringResource(R.string.account_settings_idle_refresh_period_60min) + } + } + + else -> { + error("Invalid idle refresh period") + } + } + + updateState { state -> + state.copy( + localFolderSize = localFolderSize, + syncMessageFrom = syncMessageFrom, + fetchMessageUpTo = fetchMessageUpTo, + folderPollFrequency = folderPollFrequency, + syncServerDeletions = syncServerDeletions, + markAsReadWhenDeleted = markReadWhenDeleted, + whenIDeleteAMessage = whenIDeleteAMessage, + eraseDeletedMessageOnServer = eraseDeletedMessageOnServer, + maxFolderToCheckWithPush = maxFolderToCheckWithPush, + refreshIdleConnection = refreshIdleConnection, + ) + } + }, + onFailure = { + handleError(it) + }, + ) + } + } + + private fun observeAccountName() { + getAccountName(accountId) + .onEach { outcome -> + outcome.handle( + onSuccess = { updateState { state -> state.copy(subtitle = it) } }, + onFailure = { handleError(it) }, + ) + }.launchIn(viewModelScope) + } + + private fun handleError(error: AccountSettingError) { + when (error) { + is AccountSettingError.NotFound -> logger.error(tag = TAG, message = { error.message }) + is AccountSettingError.StorageError -> logger.error(tag = TAG, message = { error.message }) + is AccountSettingError.UnsupportedFormat -> logger.error(tag = TAG, message = { error.message }) + } + } +} diff --git a/feature/account/settings/impl/src/main/kotlin/net/thunderbird/feature/account/settings/impl/ui/fetchingMail/advanced/AdvancedFetchingMailSettingsContent.kt b/feature/account/settings/impl/src/main/kotlin/net/thunderbird/feature/account/settings/impl/ui/fetchingMail/advanced/AdvancedFetchingMailSettingsContent.kt new file mode 100644 index 00000000000..111f9b3fdd5 --- /dev/null +++ b/feature/account/settings/impl/src/main/kotlin/net/thunderbird/feature/account/settings/impl/ui/fetchingMail/advanced/AdvancedFetchingMailSettingsContent.kt @@ -0,0 +1,125 @@ +package net.thunderbird.feature.account.settings.impl.ui.fetchingMail.advanced + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import app.k9mail.core.ui.compose.designsystem.atom.DropdownMenuBox +import app.k9mail.core.ui.compose.designsystem.atom.button.ButtonIcon +import app.k9mail.core.ui.compose.designsystem.organism.AlertDialog +import kotlinx.collections.immutable.persistentListOf +import net.thunderbird.core.common.provider.AppNameProvider +import net.thunderbird.core.ui.compose.designsystem.atom.icon.Icons +import net.thunderbird.core.ui.setting.Setting +import net.thunderbird.core.ui.setting.SettingValue +import net.thunderbird.core.ui.setting.SettingViewProvider +import net.thunderbird.feature.account.settings.R +import net.thunderbird.feature.account.settings.impl.ui.fetchingMail.FetchingMailSettingsContract.Event +import net.thunderbird.feature.account.settings.impl.ui.fetchingMail.FetchingMailSettingsContract.SettingsBuilder +import net.thunderbird.feature.account.settings.impl.ui.fetchingMail.FetchingMailSettingsContract.State +import net.thunderbird.feature.account.settings.impl.ui.fetchingMail.FetchingMailSettingsId + +@Suppress("LongMethod") +@Composable +internal fun AdvancedFetchingMailSettingsContent( + state: State, + onEvent: (Event) -> Unit, + onAccountRemove: () -> Unit, + provider: SettingViewProvider, + builder: SettingsBuilder, + appNameProvider: AppNameProvider, + modifier: Modifier = Modifier, +) { + val settings = remember(state, builder, onEvent) { + builder.buildAdvancedFetchingMailSettings(state = state, onEvent = onEvent) + } + + var showDialog by remember { mutableStateOf(false) } + + provider.SettingView( + title = stringResource(R.string.account_settings_push_advanced_title), + subtitle = state.subtitle, + settings = settings, + onSettingValueChange = { setting -> + handleSettingChange(setting, onEvent) + }, + onBack = { onEvent(Event.OnBackPressed) }, + modifier = modifier, + actions = { + var expanded by remember { mutableStateOf(false) } + + DropdownMenuBox( + expanded = expanded, + onExpandedChange = { shouldExpand -> + expanded = shouldExpand + }, + options = persistentListOf( + stringResource(R.string.account_settings_remove_account_action), + ), + onItemSelected = { + showDialog = true + expanded = false + }, + ) { + ButtonIcon( + onClick = { expanded = true }, + imageVector = Icons.Outlined.MoreVert, + ) + } + }, + ) + + if (showDialog) { + AlertDialog( + title = stringResource(R.string.account_settings_account_delete_dlg_title), + text = stringResource( + R.string.account_settings_account_delete_dlg_instructions_fmt, + state.subtitle.toString(), + appNameProvider.appName, + ), + confirmText = stringResource(R.string.account_settings_okay_action), + dismissText = stringResource(R.string.account_settings_cancel_action), + onConfirmClick = { + showDialog = false + onAccountRemove() + }, + onDismissClick = { showDialog = false }, + onDismissRequest = { showDialog = false }, + ) + } +} + +private fun handleSettingChange( + setting: Setting, + onEvent: (Event) -> Unit, +) { + when (setting) { + is SettingValue.Switch -> { + when (setting.id) { + FetchingMailSettingsId.SYNC_SERVER_DELETIONS -> onEvent( + Event.OnSyncServerDeletionsToggle(setting.value), + ) + FetchingMailSettingsId.MARK_AS_READ_WHEN_DELETED -> onEvent( + Event.OnMarkAsReadWhenDeletedToggle(setting.value), + ) + else -> Unit + } + } + is SettingValue.Select -> { + when (setting.id) { + FetchingMailSettingsId.MAX_FOLDER_TO_CHECK_WITH_PUSH -> { + onEvent(Event.OnMaxFolderToCheckWithPushChange(setting.value)) + } + FetchingMailSettingsId.REFRESH_IDLE_CONNECTION -> { + onEvent(Event.OnRefreshIdleConnectionFrequencyChange(setting.value)) + } + else -> Unit + } + } + + else -> Unit + } +} diff --git a/feature/account/settings/impl/src/main/kotlin/net/thunderbird/feature/account/settings/impl/ui/fetchingMail/advanced/AdvancedFetchingMailSettingsScreen.kt b/feature/account/settings/impl/src/main/kotlin/net/thunderbird/feature/account/settings/impl/ui/fetchingMail/advanced/AdvancedFetchingMailSettingsScreen.kt new file mode 100644 index 00000000000..b9388acd0fd --- /dev/null +++ b/feature/account/settings/impl/src/main/kotlin/net/thunderbird/feature/account/settings/impl/ui/fetchingMail/advanced/AdvancedFetchingMailSettingsScreen.kt @@ -0,0 +1,59 @@ +package net.thunderbird.feature.account.settings.impl.ui.fetchingMail.advanced + +import android.annotation.SuppressLint +import android.app.Activity +import androidx.activity.ComponentActivity +import androidx.activity.compose.BackHandler +import androidx.activity.compose.LocalActivity +import androidx.compose.runtime.Composable +import androidx.compose.ui.viewinterop.NoOpUpdate +import kotlin.uuid.ExperimentalUuidApi +import net.thunderbird.core.common.provider.AppNameProvider +import net.thunderbird.core.ui.contract.mvi.observe +import net.thunderbird.core.ui.setting.SettingViewProvider +import net.thunderbird.feature.account.AccountId +import net.thunderbird.feature.account.settings.impl.ui.fetchingMail.FetchingMailSettingsContract +import net.thunderbird.feature.account.settings.impl.ui.fetchingMail.FetchingMailSettingsContract.Effect +import net.thunderbird.feature.account.settings.impl.ui.fetchingMail.FetchingMailSettingsViewModel +import org.koin.androidx.compose.koinViewModel +import org.koin.compose.koinInject +import org.koin.core.parameter.parametersOf + +@OptIn(ExperimentalUuidApi::class) +@SuppressLint("ContextCastToActivity") +@Composable +internal fun AdvancedFetchingMailSettingsScreen( + accountId: AccountId, + onBack: () -> Unit, + viewModel: FetchingMailSettingsContract.ViewModel = koinViewModel { + parametersOf(accountId) + }, + provider: SettingViewProvider = koinInject(), + builder: FetchingMailSettingsContract.SettingsBuilder = koinInject(), + appNameProvider: AppNameProvider = koinInject(), +) { + val (state, dispatch) = viewModel.observe { effect -> + when (effect) { + is Effect.NavigateBack -> { + onBack() + } + else -> { + NoOpUpdate + } + } + } + val activity = LocalActivity.current as ComponentActivity + BackHandler(onBack = onBack) + + AdvancedFetchingMailSettingsContent( + state = state.value, + onEvent = { dispatch(it) }, + provider = provider, + builder = builder, + appNameProvider = appNameProvider, + onAccountRemove = { + activity.setResult(Activity.RESULT_OK) + activity.finish() + }, + ) +} diff --git a/feature/account/settings/impl/src/main/res/values/strings.xml b/feature/account/settings/impl/src/main/res/values/strings.xml index 6d190ed37ec..4f1c968f6c3 100644 --- a/feature/account/settings/impl/src/main/res/values/strings.xml +++ b/feature/account/settings/impl/src/main/res/values/strings.xml @@ -34,6 +34,7 @@ Always show images Reading mail + Fetching mail Mark as read when opened Mark a message as read when it is opened for viewing No @@ -55,4 +56,102 @@ 500 1000 Server search limit + + Local folder size + + 10 messages + 25 messages + 50 messages + 100 messages + 250 messages + 500 messages + 1000 messages + 2500 messages + 5000 messages + 10000 messages + all messages + + Sync messages from + any time (no limit) + today + the last 2 days + the last 3 days + the last week + the last 2 weeks + the last 3 weeks + the last month + the last 2 months + the last 3 months + the last 6 months + the last year + + Fetch messages up to + 1 KiB + 2 KiB + 4 KiB + 8 KiB + 16 KiB + 32 KiB + 64 KiB + 128 KiB + 256 KiB + 512 KiB + 1 MiB + 2 MiB + 5 MiB + 10 MiB + any size (no limit) + + Folder poll frequency + Never + Every 15 minutes + Every 30 minutes + Every hour + Every 2 hours + Every 3 hours + Every 6 hours + Every 12 hours + Every 24 hours + + Sync server deletions + Remove messages when deleted on server + + Mark as read when deleted + Mark a message as read when it is deleted + + When I delete a message + Do not delete on server + Delete from server + Mark as read on server + + Erase deleted messages on server + Immediately + When polling + Manually + + Incoming server + Configure the incoming mail server + + Advanced + + Max folders to check with push + 5 folders + 10 folders + 25 folders + 50 folders + 100 folders + 250 folders + 500 folders + 1000 folders + + Refresh IDLE connection + Every 2 minutes + Every 3 minutes + Every 6 minutes + Every 12 minutes + Every 24 minutes + Every 36 minutes + Every 48 minutes + Every 60 minutes + diff --git a/feature/account/settings/impl/src/test/kotlin/net/thunderbird/feature/account/settings/impl/domain/usecase/UpdateFetchingMailSettingsTest.kt b/feature/account/settings/impl/src/test/kotlin/net/thunderbird/feature/account/settings/impl/domain/usecase/UpdateFetchingMailSettingsTest.kt new file mode 100644 index 00000000000..c0905c43781 --- /dev/null +++ b/feature/account/settings/impl/src/test/kotlin/net/thunderbird/feature/account/settings/impl/domain/usecase/UpdateFetchingMailSettingsTest.kt @@ -0,0 +1,287 @@ +package net.thunderbird.feature.account.settings.impl.domain.usecase + +import androidx.compose.ui.viewinterop.NoOpUpdate +import assertk.assertThat +import assertk.assertions.isEqualTo +import assertk.assertions.isInstanceOf +import com.fsck.k9.mail.AuthType +import com.fsck.k9.mail.ConnectionSecurity +import com.fsck.k9.mail.ServerSettings +import kotlin.test.Test +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.runTest +import net.thunderbird.core.android.account.DeletePolicy +import net.thunderbird.core.android.account.Expunge +import net.thunderbird.core.android.account.Identity +import net.thunderbird.core.android.account.LegacyAccount +import net.thunderbird.core.android.account.LegacyAccountRepository +import net.thunderbird.core.outcome.Outcome +import net.thunderbird.feature.account.AccountId +import net.thunderbird.feature.account.AccountIdFactory +import net.thunderbird.feature.account.settings.impl.domain.AccountSettingsDomainContract +import net.thunderbird.feature.account.storage.profile.AvatarDto +import net.thunderbird.feature.account.storage.profile.AvatarTypeDto +import net.thunderbird.feature.account.storage.profile.ProfileDto + +internal class UpdateFetchingMailSettingsTest { + + private fun createAccount(): LegacyAccount { + val id = AccountIdFactory.create() + + return LegacyAccount( + isSensitiveDebugLoggingEnabled = { true }, + id = id, + name = "Test Account", + email = "test@example.com", + + profile = ProfileDto( + id = id, + name = "Test Account", + color = 0xFF0000, + avatar = AvatarDto( + avatarType = AvatarTypeDto.ICON, + avatarMonogram = null, + avatarImageUri = null, + avatarIconName = "star", + ), + ), + + identities = listOf( + Identity( + signatureUse = false, + description = "Test Identity", + ), + ), + + incomingServerSettings = ServerSettings( + type = "imap", + host = "imap.test.com", + port = 993, + connectionSecurity = ConnectionSecurity.SSL_TLS_REQUIRED, + authenticationType = AuthType.PLAIN, + username = "user", + password = "pass", + clientCertificateAlias = null, + ), + + outgoingServerSettings = ServerSettings( + type = "smtp", + host = "smtp.test.com", + port = 465, + connectionSecurity = ConnectionSecurity.SSL_TLS_REQUIRED, + authenticationType = AuthType.PLAIN, + username = "user", + password = "pass", + clientCertificateAlias = null, + ), + + displayCount = 10, + maximumPolledMessageAge = 7, + maximumAutoDownloadMessageSize = 1024, + automaticCheckIntervalMinutes = 15, + isSyncRemoteDeletions = false, + isMarkMessageAsReadOnDelete = false, + deletePolicy = DeletePolicy.NEVER, + expungePolicy = Expunge.EXPUNGE_IMMEDIATELY, + maxPushFolders = 5, + idleRefreshMinutes = 15, + ) + } + + private fun repoWith(account: LegacyAccount) = object : LegacyAccountRepository { + var updated: LegacyAccount? = null + + override fun getById(id: AccountId): Flow = flowOf(account) + + override suspend fun update(account: LegacyAccount) { + updated = account + } + } + + @Test + fun `should return NotFound when account missing`() = runTest { + val repo = object : LegacyAccountRepository { + override fun getById(id: AccountId): Flow = emptyFlow() + override suspend fun update(account: LegacyAccount) { + NoOpUpdate + } + } + + val useCase = UpdateFetchingMailSettings(repo) + + val result = useCase( + AccountIdFactory.create(), + AccountSettingsDomainContract.UpdateFetchingMailSettingsCommand.UpdateLocalFolderSize(10), + ) + + assertThat(result).isInstanceOf(Outcome.Failure::class) + } + + @Test + fun `should update local folder size`() = runTest { + val account = createAccount() + val repo = repoWith(account) + + val useCase = UpdateFetchingMailSettings(repo) + + val result = useCase( + account.id, + AccountSettingsDomainContract.UpdateFetchingMailSettingsCommand.UpdateLocalFolderSize(25), + ) + + assertThat(result).isInstanceOf(Outcome.Success::class) + + val updated = requireNotNull(repo.updated) + assertThat(updated.displayCount).isEqualTo(25) + } + + @Test + fun `should update sync message age`() = runTest { + val account = createAccount() + val repo = repoWith(account) + + val useCase = UpdateFetchingMailSettings(repo) + + useCase( + account.id, + AccountSettingsDomainContract.UpdateFetchingMailSettingsCommand.UpdateSyncMessageFrom(14), + ) + + val updated = requireNotNull(repo.updated) + assertThat(updated.maximumPolledMessageAge).isEqualTo(14) + } + + @Test + fun `should update delete policy ON_DELETE`() = runTest { + val account = createAccount() + val repo = repoWith(account) + + val useCase = UpdateFetchingMailSettings(repo) + + useCase( + account.id, + AccountSettingsDomainContract.UpdateFetchingMailSettingsCommand.UpdateWhenIDeleteAMessage("ON_DELETE"), + ) + + val updated = requireNotNull(repo.updated) + assertThat(updated.deletePolicy).isEqualTo(DeletePolicy.ON_DELETE) + } + + @Test + fun `should update expunge policy MANUAL`() = runTest { + val account = createAccount() + val repo = repoWith(account) + + val useCase = UpdateFetchingMailSettings(repo) + + useCase( + account.id, + AccountSettingsDomainContract + .UpdateFetchingMailSettingsCommand.UpdateEraseDeletedMessageOnServer("EXPUNGE_MANUALLY"), + ) + + val updated = requireNotNull(repo.updated) + assertThat(updated.expungePolicy).isEqualTo(Expunge.EXPUNGE_MANUALLY) + } + + @Test + fun `should update push folder limit`() = runTest { + val account = createAccount() + val repo = repoWith(account) + + val useCase = UpdateFetchingMailSettings(repo) + + useCase( + account.id, + AccountSettingsDomainContract.UpdateFetchingMailSettingsCommand.UpdateOnMaxFolderToCheckWithPushChange(250), + ) + + val updated = requireNotNull(repo.updated) + assertThat(updated.maxPushFolders).isEqualTo(250) + } + + @Test + fun `should update idle refresh frequency`() = runTest { + val account = createAccount() + val repo = repoWith(account) + + val useCase = UpdateFetchingMailSettings(repo) + + useCase( + account.id, + AccountSettingsDomainContract + .UpdateFetchingMailSettingsCommand.UpdateRefreshIdleConnectionFrequencyChange( + 60, + ), + ) + + val updated = requireNotNull(repo.updated) + assertThat(updated.idleRefreshMinutes).isEqualTo(60) + } + + @Test + fun `should update sync server deletions`() = runTest { + val account = createAccount() + val repo = repoWith(account) + + val useCase = UpdateFetchingMailSettings(repo) + + useCase( + account.id, + AccountSettingsDomainContract.UpdateFetchingMailSettingsCommand.UpdateSyncServerDeletions(true), + ) + + val updated = requireNotNull(repo.updated) + assertThat(updated.isSyncRemoteDeletions).isEqualTo(true) + } + + @Test + fun `should update mark as read when deleted`() = runTest { + val account = createAccount() + val repo = repoWith(account) + + val useCase = UpdateFetchingMailSettings(repo) + + useCase( + account.id, + AccountSettingsDomainContract.UpdateFetchingMailSettingsCommand.UpdateMarkAsReadWhenDeleted(true), + ) + + val updated = requireNotNull(repo.updated) + assertThat(updated.isMarkMessageAsReadOnDelete).isEqualTo(true) + } + + @Test + fun `should update fetch message up to`() = runTest { + val account = createAccount() + val repo = repoWith(account) + + val useCase = UpdateFetchingMailSettings(repo) + + useCase( + account.id, + AccountSettingsDomainContract.UpdateFetchingMailSettingsCommand.UpdateFetchMessageUpTo(4096), + ) + + val updated = requireNotNull(repo.updated) + assertThat(updated.maximumAutoDownloadMessageSize).isEqualTo(4096) + } + + @Test + fun `should update folder poll frequency`() = runTest { + val account = createAccount() + val repo = repoWith(account) + + val useCase = UpdateFetchingMailSettings(repo) + + useCase( + account.id, + AccountSettingsDomainContract.UpdateFetchingMailSettingsCommand.UpdateFolderPollFrequency(60), + ) + + val updated = requireNotNull(repo.updated) + assertThat(updated.automaticCheckIntervalMinutes).isEqualTo(60) + } +} diff --git a/feature/account/settings/impl/src/test/kotlin/net/thunderbird/feature/account/settings/impl/ui/fetchingMail/FetchingMailSettingsBuilderTest.kt b/feature/account/settings/impl/src/test/kotlin/net/thunderbird/feature/account/settings/impl/ui/fetchingMail/FetchingMailSettingsBuilderTest.kt new file mode 100644 index 00000000000..003d0c37a95 --- /dev/null +++ b/feature/account/settings/impl/src/test/kotlin/net/thunderbird/feature/account/settings/impl/ui/fetchingMail/FetchingMailSettingsBuilderTest.kt @@ -0,0 +1,265 @@ +package net.thunderbird.feature.account.settings.impl.ui.fetchingMail + +import assertk.all +import assertk.assertThat +import assertk.assertions.containsExactly +import assertk.assertions.isEqualTo +import assertk.assertions.isInstanceOf +import assertk.assertions.isTrue +import assertk.assertions.prop +import kotlin.test.Test +import net.thunderbird.core.android.account.DeletePolicy +import net.thunderbird.core.android.account.Expunge +import net.thunderbird.core.common.resources.StringsResourceManager +import net.thunderbird.core.ui.setting.Setting +import net.thunderbird.core.ui.setting.SettingValue +import net.thunderbird.core.ui.setting.SettingValue.Select.SelectOption +import net.thunderbird.feature.account.settings.R + +internal class FetchingMailSettingsBuilderTest { + + private val resources = object : StringsResourceManager { + override fun stringResource(resourceId: Int): String = + "String for $resourceId" + + override fun stringResource(resourceId: Int, vararg formatArgs: Any?): String = + stringResource(resourceId) + } + + private val builder = FetchingMailSettingsBuilder(resources) + + private fun createState( + localFolderSize: String = "10", + syncMessageFrom: String = "-1", + fetchMessageUpTo: String = "1024", + folderPollFrequency: String = "15", + syncServerDeletions: Boolean = false, + markAsReadWhenDeleted: Boolean = false, + deletePolicy: DeletePolicy = DeletePolicy.NEVER, + expunge: Expunge = Expunge.EXPUNGE_IMMEDIATELY, + maxFolderPush: String = "5", + refreshIdle: String = "2", + ): FetchingMailSettingsContract.State { + return FetchingMailSettingsContract.State( + localFolderSize = SelectOption(localFolderSize) { "" }, + syncMessageFrom = SelectOption(syncMessageFrom) { "" }, + fetchMessageUpTo = SelectOption(fetchMessageUpTo) { "" }, + folderPollFrequency = SelectOption(folderPollFrequency) { "" }, + syncServerDeletions = syncServerDeletions, + markAsReadWhenDeleted = markAsReadWhenDeleted, + whenIDeleteAMessage = SelectOption(deletePolicy.name) { "" }, + eraseDeletedMessageOnServer = SelectOption(expunge.name) { "" }, + maxFolderToCheckWithPush = SelectOption(maxFolderPush) { "" }, + refreshIdleConnection = SelectOption(refreshIdle) { "" }, + ) + } + + @Test + fun `buildCoreFetchingMailSettings should build settings in correct order`() { + val settings = builder.buildCoreFetchingMailSettings(createState()) {} + + assertThat(settings.map { it.id }).containsExactly( + FetchingMailSettingsId.LOCAL_FOLDER_SIZE, + FetchingMailSettingsId.SYNC_MESSAGE_FROM, + FetchingMailSettingsId.FETCH_MESSAGE_UP_TO, + FetchingMailSettingsId.FOLDER_POLL_FREQUENCY, + FetchingMailSettingsId.SYNC_SERVER_DELETIONS, + FetchingMailSettingsId.MARK_AS_READ_WHEN_DELETED, + FetchingMailSettingsId.WHEN_I_DELETE_A_MESSAGE, + FetchingMailSettingsId.ERASE_DELETED_MESSAGE_ON_SERVER, + FetchingMailSettingsId.IN_COMING_SERVER, + FetchingMailSettingsId.ADVANCE, + ) + } + + @Test + fun `buildAdvancedFetchingMailSettings should build settings in correct order`() { + val settings = builder.buildAdvancedFetchingMailSettings(createState()) {} + + assertThat(settings.map { it.id }).containsExactly( + FetchingMailSettingsId.MAX_FOLDER_TO_CHECK_WITH_PUSH, + FetchingMailSettingsId.REFRESH_IDLE_CONNECTION, + ) + } + + @Test + fun `core settings should have correct types`() { + val settings = builder.buildCoreFetchingMailSettings(createState()) {} + + assertThat(settings[0]).all { + isInstanceOf() + prop(Setting::id).isEqualTo(FetchingMailSettingsId.LOCAL_FOLDER_SIZE) + } + + assertThat(settings[4]).all { + isInstanceOf() + prop(Setting::id).isEqualTo(FetchingMailSettingsId.SYNC_SERVER_DELETIONS) + } + + assertThat(settings[8]).all { + isInstanceOf() + prop(Setting::id).isEqualTo(FetchingMailSettingsId.IN_COMING_SERVER) + } + } + + @Test + fun `advanced settings should have correct types`() { + val settings = builder.buildAdvancedFetchingMailSettings(createState()) {} + + settings.forEach { + assertThat(it).isInstanceOf() + } + } + + @Test + fun `select settings should preserve selected values`() { + val state = createState( + localFolderSize = "500", + syncMessageFrom = "365", + fetchMessageUpTo = "5242880", + folderPollFrequency = "720", + ) + + val settings = builder.buildCoreFetchingMailSettings(state) {} + + assertThat((settings[0] as SettingValue.Select).value.id) + .isEqualTo("500") + + assertThat((settings[1] as SettingValue.Select).value.id) + .isEqualTo("365") + + assertThat((settings[2] as SettingValue.Select).value.id) + .isEqualTo("5242880") + + assertThat((settings[3] as SettingValue.Select).value.id) + .isEqualTo("720") + } + + @Test + fun `advanced select settings should preserve selected values`() { + val state = createState( + maxFolderPush = "250", + refreshIdle = "48", + ) + + val settings = builder.buildAdvancedFetchingMailSettings(state) {} + + assertThat((settings[0] as SettingValue.Select).value.id) + .isEqualTo("250") + + assertThat((settings[1] as SettingValue.Select).value.id) + .isEqualTo("48") + } + + @Test + fun `switch settings should preserve values`() { + val state = createState( + syncServerDeletions = true, + markAsReadWhenDeleted = true, + ) + + val settings = builder.buildCoreFetchingMailSettings(state) {} + + assertThat((settings[4] as SettingValue.Switch).value).isTrue() + + assertThat((settings[5] as SettingValue.Switch).value).isTrue() + } + + @Test + fun `incoming server action should dispatch event`() { + var event: FetchingMailSettingsContract.Event? = null + + val settings = builder.buildCoreFetchingMailSettings(createState()) { + event = it + } + + val action = settings[8] as SettingValue.ActionText + + action.onClick() + + assertThat(event) + .isEqualTo(FetchingMailSettingsContract.Event.OnInComingServerClick) + } + + @Test + fun `advanced action should dispatch event`() { + var event: FetchingMailSettingsContract.Event? = null + + val settings = builder.buildCoreFetchingMailSettings(createState()) { + event = it + } + + val action = settings[9] as SettingValue.ActionText + + action.onClick() + + assertThat(event) + .isEqualTo(FetchingMailSettingsContract.Event.OnAdvanceClick) + } + + @Test + fun `delete policy options should contain all enum values`() { + val settings = builder.buildCoreFetchingMailSettings(createState()) {} + + val select = settings[6] as SettingValue.Select + + assertThat(select.options.map { it.id }).containsExactly( + DeletePolicy.NEVER.name, + DeletePolicy.ON_DELETE.name, + DeletePolicy.MARK_AS_READ.name, + ) + } + + @Test + fun `expunge policy options should contain all enum values`() { + val settings = builder.buildCoreFetchingMailSettings(createState()) {} + + val select = settings[7] as SettingValue.Select + + assertThat(select.options.map { it.id }).containsExactly( + Expunge.EXPUNGE_IMMEDIATELY.name, + Expunge.EXPUNGE_ON_POLL.name, + Expunge.EXPUNGE_MANUALLY.name, + ) + } + + @Test + fun `local folder size should use correct title`() { + val settings = builder.buildCoreFetchingMailSettings(createState()) {} + + val select = settings[0] as SettingValue.Select + + assertThat(select.title()).isEqualTo( + resources.stringResource( + R.string.account_settings_mail_display_count_label, + ), + ) + } + + @Test + fun `select option titles should come from resource manager`() { + val settings = builder.buildCoreFetchingMailSettings(createState()) {} + + val select = settings[0] as SettingValue.Select + + val title = select.options.first { it.id == "10" }.title() + + assertThat(title).isEqualTo( + resources.stringResource( + R.string.account_settings_options_mail_display_count_10, + ), + ) + } + + @Test + fun `all select settings should display value as secondary text`() { + val coreSettings = builder.buildCoreFetchingMailSettings(createState()) {} + val advancedSettings = builder.buildAdvancedFetchingMailSettings(createState()) {} + + (coreSettings + advancedSettings) + .filterIsInstance() + .forEach { + assertThat(it.displayValueAsSecondaryText).isTrue() + } + } +} diff --git a/feature/account/settings/impl/src/test/kotlin/net/thunderbird/feature/account/settings/impl/ui/fetchingMail/FetchingMailSettingsViewModelTest.kt b/feature/account/settings/impl/src/test/kotlin/net/thunderbird/feature/account/settings/impl/ui/fetchingMail/FetchingMailSettingsViewModelTest.kt new file mode 100644 index 00000000000..f8be9a39fe3 --- /dev/null +++ b/feature/account/settings/impl/src/test/kotlin/net/thunderbird/feature/account/settings/impl/ui/fetchingMail/FetchingMailSettingsViewModelTest.kt @@ -0,0 +1,646 @@ +package net.thunderbird.feature.account.settings.impl.ui.fetchingMail + +import assertk.assertThat +import assertk.assertions.isEqualTo +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import net.thunderbird.core.android.account.DeletePolicy +import net.thunderbird.core.android.account.Expunge +import net.thunderbird.core.android.account.LegacyAccount +import net.thunderbird.core.logging.testing.TestLogger +import net.thunderbird.core.outcome.Outcome +import net.thunderbird.core.testing.coroutines.MainDispatcherHelper +import net.thunderbird.core.ui.setting.SettingValue.Select.SelectOption +import net.thunderbird.feature.account.AccountId +import net.thunderbird.feature.account.AccountIdFactory +import net.thunderbird.feature.account.settings.impl.domain.AccountSettingsDomainContract + +@OptIn(ExperimentalCoroutinesApi::class) +internal class FetchingMailSettingsViewModelTest { + + private val mainDispatcher = MainDispatcherHelper() + + @BeforeTest + fun setUp() { + mainDispatcher.setUp() + } + + @AfterTest + fun tearDown() { + mainDispatcher.tearDown() + } + + private val resources = object : net.thunderbird.core.common.resources.StringsResourceManager { + override fun stringResource(resourceId: Int): String = "string_$resourceId" + + override fun stringResource(resourceId: Int, vararg formatArgs: Any?): String { + return stringResource(resourceId) + } + } + + private fun defaultState() = FetchingMailSettingsContract.State( + subtitle = null, + localFolderSize = SelectOption("10") { "" }, + syncMessageFrom = SelectOption("-1") { "" }, + fetchMessageUpTo = SelectOption("1024") { "" }, + folderPollFrequency = SelectOption("-1") { "" }, + syncServerDeletions = false, + markAsReadWhenDeleted = false, + whenIDeleteAMessage = SelectOption(DeletePolicy.NEVER.name) { "" }, + eraseDeletedMessageOnServer = SelectOption(Expunge.EXPUNGE_IMMEDIATELY.name) { "" }, + maxFolderToCheckWithPush = SelectOption("5") { "" }, + refreshIdleConnection = SelectOption("2") { "" }, + ) + + private fun dummyLegacyAccount( + accountId: AccountId, + ): LegacyAccount { + return LegacyAccount( + id = accountId, + name = "Demo", + email = "demo@example.com", + displayCount = 500, + maximumPolledMessageAge = 365, + maximumAutoDownloadMessageSize = 5242880, + automaticCheckIntervalMinutes = 720, + isSyncRemoteDeletions = true, + isMarkMessageAsReadOnDelete = true, + deletePolicy = DeletePolicy.MARK_AS_READ, + expungePolicy = Expunge.EXPUNGE_MANUALLY, + maxPushFolders = 250, + idleRefreshMinutes = 48, + isSensitiveDebugLoggingEnabled = { true }, + profile = net.thunderbird.feature.account.storage.profile.ProfileDto( + id = accountId, + name = "Demo", + color = 0xFF0000, + avatar = net.thunderbird.feature.account.storage.profile.AvatarDto( + avatarType = net.thunderbird.feature.account.storage.profile.AvatarTypeDto.ICON, + avatarMonogram = null, + avatarImageUri = null, + avatarIconName = "star", + ), + ), + identities = listOf( + net.thunderbird.core.android.account.Identity( + signatureUse = false, + description = "Test Identity", + ), + ), + incomingServerSettings = com.fsck.k9.mail.ServerSettings( + type = "imap", + host = "imap.example.com", + port = 993, + connectionSecurity = com.fsck.k9.mail.ConnectionSecurity.SSL_TLS_REQUIRED, + authenticationType = com.fsck.k9.mail.AuthType.PLAIN, + username = "test", + password = "pass", + clientCertificateAlias = null, + ), + outgoingServerSettings = com.fsck.k9.mail.ServerSettings( + type = "smtp", + host = "smtp.example.com", + port = 465, + connectionSecurity = com.fsck.k9.mail.ConnectionSecurity.SSL_TLS_REQUIRED, + authenticationType = com.fsck.k9.mail.AuthType.PLAIN, + username = "test", + password = "pass", + clientCertificateAlias = null, + ), + ) + } + + private fun createViewModel( + accountId: AccountId, + initialState: FetchingMailSettingsContract.State = defaultState(), + getLegacyAccount: suspend ( + AccountId, + ) -> Outcome = { + Outcome.success(dummyLegacyAccount(it)) + }, + updateFetchingMailSettings: suspend ( + AccountId, + AccountSettingsDomainContract.UpdateFetchingMailSettingsCommand, + ) -> Outcome = { _, _ -> + Outcome.success(Unit) + }, + ) = FetchingMailSettingsViewModel( + accountId = accountId, + logger = TestLogger(), + resources = resources, + getAccountName = { flowOf(Outcome.success("Subtitle")) }, + getLegacyAccount = getLegacyAccount, + updateFetchingMailSettings = updateFetchingMailSettings, + initialState = initialState, + ) + + @Test + fun `should navigate back when back pressed`() = runTest { + val vm = createViewModel(AccountIdFactory.create()) + + val effects = mutableListOf() + + val job = launch { + vm.effect.collect { + effects.add(it) + } + } + + vm.event(FetchingMailSettingsContract.Event.OnBackPressed) + + advanceUntilIdle() + + assertThat(effects.first()) + .isEqualTo(FetchingMailSettingsContract.Effect.NavigateBack) + + job.cancel() + } + + @Test + fun `should navigate to incoming server settings`() = runTest { + val vm = createViewModel(AccountIdFactory.create()) + + val effects = mutableListOf() + + val job = launch { + vm.effect.collect { + effects.add(it) + } + } + + vm.event(FetchingMailSettingsContract.Event.OnInComingServerClick) + + advanceUntilIdle() + + assertThat(effects.first()) + .isEqualTo( + FetchingMailSettingsContract.Effect.NavigateToIncomingServerSettings, + ) + + job.cancel() + } + + @Test + fun `should navigate to advanced fetching mail settings`() = runTest { + val vm = createViewModel(AccountIdFactory.create()) + + val effects = mutableListOf() + + val job = launch { + vm.effect.collect { + effects.add(it) + } + } + + vm.event(FetchingMailSettingsContract.Event.OnAdvanceClick) + + advanceUntilIdle() + + assertThat(effects.first()) + .isEqualTo( + FetchingMailSettingsContract.Effect.NavigateToAdvancedFetchingMailSettings, + ) + + job.cancel() + } + + @Test + fun `should initialize state from legacy account`() = runTest { + val accountId = AccountIdFactory.create() + + val vm = createViewModel(accountId) + + advanceUntilIdle() + + with(vm.state.value) { + assertThat(localFolderSize.id).isEqualTo("500") + assertThat(syncMessageFrom.id).isEqualTo("365") + assertThat(fetchMessageUpTo.id).isEqualTo("5242880") + assertThat(folderPollFrequency.id).isEqualTo("720") + assertThat(syncServerDeletions).isEqualTo(true) + assertThat(markAsReadWhenDeleted).isEqualTo(true) + assertThat(whenIDeleteAMessage.id) + .isEqualTo(DeletePolicy.MARK_AS_READ.name) + + assertThat(eraseDeletedMessageOnServer.id) + .isEqualTo(Expunge.EXPUNGE_MANUALLY.name) + + assertThat(maxFolderToCheckWithPush.id).isEqualTo("250") + assertThat(refreshIdleConnection.id).isEqualTo("48") + } + } + + @Test + fun `should update subtitle when account name is loaded`() = runTest { + val accountId = AccountIdFactory.create() + + val vm = FetchingMailSettingsViewModel( + accountId = accountId, + logger = TestLogger(), + resources = resources, + getAccountName = { + flowOf(Outcome.success("My Account")) + }, + getLegacyAccount = { + Outcome.success(dummyLegacyAccount(accountId)) + }, + updateFetchingMailSettings = { _, _ -> + Outcome.success(Unit) + }, + initialState = defaultState(), + ) + + advanceUntilIdle() + + assertThat(vm.state.value.subtitle) + .isEqualTo("My Account") + } + + @Test + fun `should update local folder size`() = runTest { + val accountId = AccountIdFactory.create() + + var command: + AccountSettingsDomainContract.UpdateFetchingMailSettingsCommand? = null + + val vm = createViewModel( + accountId = accountId, + updateFetchingMailSettings = { _, updateCommand -> + command = updateCommand + Outcome.success(Unit) + }, + ) + + val option = SelectOption("1000") { "1000" } + + vm.event( + FetchingMailSettingsContract.Event.OnLocalFolderSizeChange(option), + ) + + advanceUntilIdle() + + assertThat(command).isEqualTo( + AccountSettingsDomainContract + .UpdateFetchingMailSettingsCommand + .UpdateLocalFolderSize(1000), + ) + + assertThat(vm.state.value.localFolderSize.id) + .isEqualTo("1000") + } + + @Test + fun `should update sync message from`() = runTest { + val accountId = AccountIdFactory.create() + + var command: + AccountSettingsDomainContract.UpdateFetchingMailSettingsCommand? = null + + val vm = createViewModel( + accountId = accountId, + updateFetchingMailSettings = { _, updateCommand -> + command = updateCommand + Outcome.success(Unit) + }, + ) + + val option = SelectOption("28") { "28" } + + vm.event( + FetchingMailSettingsContract.Event.OnSyncMessageFromChange(option), + ) + + advanceUntilIdle() + + assertThat(command).isEqualTo( + AccountSettingsDomainContract + .UpdateFetchingMailSettingsCommand + .UpdateSyncMessageFrom(28), + ) + + assertThat(vm.state.value.syncMessageFrom.id) + .isEqualTo("28") + } + + @Test + fun `should update fetch message up to`() = runTest { + val accountId = AccountIdFactory.create() + + var command: + AccountSettingsDomainContract.UpdateFetchingMailSettingsCommand? = null + + val vm = createViewModel( + accountId = accountId, + updateFetchingMailSettings = { _, updateCommand -> + command = updateCommand + Outcome.success(Unit) + }, + ) + + val option = SelectOption("2097152") { "2097152" } + + vm.event( + FetchingMailSettingsContract.Event.OnFetchMessageUpToChange(option), + ) + + advanceUntilIdle() + + assertThat(command).isEqualTo( + AccountSettingsDomainContract + .UpdateFetchingMailSettingsCommand + .UpdateFetchMessageUpTo(2097152), + ) + + assertThat(vm.state.value.fetchMessageUpTo.id) + .isEqualTo("2097152") + } + + @Test + fun `should update folder poll frequency`() = runTest { + val accountId = AccountIdFactory.create() + + var command: + AccountSettingsDomainContract.UpdateFetchingMailSettingsCommand? = null + + val vm = createViewModel( + accountId = accountId, + updateFetchingMailSettings = { _, updateCommand -> + command = updateCommand + Outcome.success(Unit) + }, + ) + + val option = SelectOption("360") { "360" } + + vm.event( + FetchingMailSettingsContract.Event.OnFolderPollFrequencyChange(option), + ) + + advanceUntilIdle() + + assertThat(command).isEqualTo( + AccountSettingsDomainContract + .UpdateFetchingMailSettingsCommand + .UpdateFolderPollFrequency(360), + ) + + assertThat(vm.state.value.folderPollFrequency.id) + .isEqualTo("360") + } + + @Test + fun `should update sync server deletions`() = runTest { + val accountId = AccountIdFactory.create() + + var command: + AccountSettingsDomainContract.UpdateFetchingMailSettingsCommand? = null + + val vm = createViewModel( + accountId = accountId, + updateFetchingMailSettings = { _, updateCommand -> + command = updateCommand + Outcome.success(Unit) + }, + ) + + vm.event( + FetchingMailSettingsContract.Event.OnSyncServerDeletionsToggle(true), + ) + + advanceUntilIdle() + + assertThat(command).isEqualTo( + AccountSettingsDomainContract + .UpdateFetchingMailSettingsCommand + .UpdateSyncServerDeletions(true), + ) + + assertThat(vm.state.value.syncServerDeletions) + .isEqualTo(true) + } + + @Test + fun `should update mark as read when deleted`() = runTest { + val accountId = AccountIdFactory.create() + + var command: + AccountSettingsDomainContract.UpdateFetchingMailSettingsCommand? = null + + val vm = createViewModel( + accountId = accountId, + updateFetchingMailSettings = { _, updateCommand -> + command = updateCommand + Outcome.success(Unit) + }, + ) + + vm.event( + FetchingMailSettingsContract.Event.OnMarkAsReadWhenDeletedToggle(true), + ) + + advanceUntilIdle() + + assertThat(command).isEqualTo( + AccountSettingsDomainContract + .UpdateFetchingMailSettingsCommand + .UpdateMarkAsReadWhenDeleted(true), + ) + + assertThat(vm.state.value.markAsReadWhenDeleted) + .isEqualTo(true) + } + + @Test + fun `should update delete policy`() = runTest { + val accountId = AccountIdFactory.create() + + var command: + AccountSettingsDomainContract.UpdateFetchingMailSettingsCommand? = null + + val vm = createViewModel( + accountId = accountId, + updateFetchingMailSettings = { _, updateCommand -> + command = updateCommand + Outcome.success(Unit) + }, + ) + + val option = SelectOption(DeletePolicy.ON_DELETE.name) { "" } + + vm.event( + FetchingMailSettingsContract.Event.OnWhenIDeleteAMessageChange(option), + ) + + advanceUntilIdle() + + assertThat(command).isEqualTo( + AccountSettingsDomainContract + .UpdateFetchingMailSettingsCommand + .UpdateWhenIDeleteAMessage(DeletePolicy.ON_DELETE.name), + ) + + assertThat(vm.state.value.whenIDeleteAMessage.id) + .isEqualTo(DeletePolicy.ON_DELETE.name) + } + + @Test + fun `should update expunge policy`() = runTest { + val accountId = AccountIdFactory.create() + + var command: + AccountSettingsDomainContract.UpdateFetchingMailSettingsCommand? = null + + val vm = createViewModel( + accountId = accountId, + updateFetchingMailSettings = { _, updateCommand -> + command = updateCommand + Outcome.success(Unit) + }, + ) + + val option = SelectOption(Expunge.EXPUNGE_ON_POLL.name) { "" } + + vm.event( + FetchingMailSettingsContract.Event + .OnEraseDeletedMessageOnServerChange(option), + ) + + advanceUntilIdle() + + assertThat(command).isEqualTo( + AccountSettingsDomainContract + .UpdateFetchingMailSettingsCommand + .UpdateEraseDeletedMessageOnServer( + Expunge.EXPUNGE_ON_POLL.name, + ), + ) + + assertThat(vm.state.value.eraseDeletedMessageOnServer.id) + .isEqualTo(Expunge.EXPUNGE_ON_POLL.name) + } + + @Test + fun `should update max folder to check with push`() = runTest { + val accountId = AccountIdFactory.create() + + var command: + AccountSettingsDomainContract.UpdateFetchingMailSettingsCommand? = null + + val vm = createViewModel( + accountId = accountId, + updateFetchingMailSettings = { _, updateCommand -> + command = updateCommand + Outcome.success(Unit) + }, + ) + + val option = SelectOption("100") { "" } + + vm.event( + FetchingMailSettingsContract.Event + .OnMaxFolderToCheckWithPushChange(option), + ) + + advanceUntilIdle() + + assertThat(command).isEqualTo( + AccountSettingsDomainContract + .UpdateFetchingMailSettingsCommand + .UpdateOnMaxFolderToCheckWithPushChange(100), + ) + + assertThat(vm.state.value.maxFolderToCheckWithPush.id) + .isEqualTo("100") + } + + @Test + fun `should update refresh idle connection frequency`() = runTest { + val accountId = AccountIdFactory.create() + + var command: + AccountSettingsDomainContract.UpdateFetchingMailSettingsCommand? = null + + val vm = createViewModel( + accountId = accountId, + updateFetchingMailSettings = { _, updateCommand -> + command = updateCommand + Outcome.success(Unit) + }, + ) + + val option = SelectOption("24") { "" } + + vm.event( + FetchingMailSettingsContract.Event + .OnRefreshIdleConnectionFrequencyChange(option), + ) + + advanceUntilIdle() + + assertThat(command).isEqualTo( + AccountSettingsDomainContract + .UpdateFetchingMailSettingsCommand + .UpdateRefreshIdleConnectionFrequencyChange(24), + ) + + assertThat(vm.state.value.refreshIdleConnection.id) + .isEqualTo("24") + } + + @Test + fun `should keep default state when loading settings fails`() = runTest { + val accountId = AccountIdFactory.create() + + val vm = createViewModel( + accountId = accountId, + getLegacyAccount = { + Outcome.failure( + AccountSettingsDomainContract.AccountSettingError.StorageError( + "error", + ), + ) + }, + ) + + advanceUntilIdle() + + assertThat(vm.state.value.localFolderSize.id) + .isEqualTo("10") + + assertThat(vm.state.value.syncServerDeletions) + .isEqualTo(false) + } + + @Test + fun `should not update local folder size when update fails`() = runTest { + val accountId = AccountIdFactory.create() + + val vm = createViewModel( + accountId = accountId, + updateFetchingMailSettings = { _, _ -> + Outcome.failure( + AccountSettingsDomainContract.AccountSettingError.StorageError( + "error", + ), + ) + }, + ) + + vm.event( + FetchingMailSettingsContract.Event.OnLocalFolderSizeChange( + SelectOption("1000") { "" }, + ), + ) + + advanceUntilIdle() + + assertThat(vm.state.value.localFolderSize.id) + .isEqualTo("500") + } +} diff --git a/feature/launcher/src/main/kotlin/app/k9mail/feature/launcher/FeatureLauncherTarget.kt b/feature/launcher/src/main/kotlin/app/k9mail/feature/launcher/FeatureLauncherTarget.kt index c8d2b459bd3..00e5222c8fa 100644 --- a/feature/launcher/src/main/kotlin/app/k9mail/feature/launcher/FeatureLauncherTarget.kt +++ b/feature/launcher/src/main/kotlin/app/k9mail/feature/launcher/FeatureLauncherTarget.kt @@ -34,6 +34,14 @@ sealed class FeatureLauncherTarget( deepLinkUri = AccountSettingsRoute.ReadingMailSettings(accountUuid).route().toUri(), ) + data class AccountFetchingMailSettings(val accountUuid: String) : FeatureLauncherTarget( + deepLinkUri = AccountSettingsRoute.FetchingMailSettings(accountUuid).route().toUri(), + ) + + data class AccountAdvancedFetchingMailSettings(val accountUuid: String) : FeatureLauncherTarget( + deepLinkUri = AccountSettingsRoute.AdvancedFetchingMailSettings(accountUuid).route().toUri(), + ) + data class AccountSearchSettings(val accountUuid: String) : FeatureLauncherTarget( deepLinkUri = AccountSettingsRoute.SearchSettings(accountUuid).route().toUri(), ) diff --git a/legacy/ui/legacy/src/main/java/com/fsck/k9/ui/settings/account/AccountSettingsFragment.kt b/legacy/ui/legacy/src/main/java/com/fsck/k9/ui/settings/account/AccountSettingsFragment.kt index 19591306ba6..b236b221c94 100644 --- a/legacy/ui/legacy/src/main/java/com/fsck/k9/ui/settings/account/AccountSettingsFragment.kt +++ b/legacy/ui/legacy/src/main/java/com/fsck/k9/ui/settings/account/AccountSettingsFragment.kt @@ -95,6 +95,7 @@ class AccountSettingsFragment : PreferenceFragmentCompat(), ConfirmationDialogFr initializeGeneralSettings() initializeReadingMail() + initializeFetchingMail() initializeSearch() initializeIncomingServer() initializeComposition() @@ -179,6 +180,16 @@ class AccountSettingsFragment : PreferenceFragmentCompat(), ConfirmationDialogFr } } + private fun initializeFetchingMail() { + findPreference(PREFERENCE_FETCHING_MAIL)?.onClick { + FeatureLauncherActivity.launch( + context = requireActivity(), + target = FeatureLauncherTarget.AccountFetchingMailSettings(accountUuid), + launcher = launcherForActivityResult, + ) + } + } + private fun initializeSearch() { findPreference(PREFERENCE_SEARCH)?.onClick { FeatureLauncherActivity.launch( @@ -511,6 +522,7 @@ class AccountSettingsFragment : PreferenceFragmentCompat(), ConfirmationDialogFr private const val PREFERENCE_GENERAL = "general" private const val PREFERENCE_READING_MAIL = "reading_mail" + private const val PREFERENCE_FETCHING_MAIL = "fetching_mail" private const val PREFERENCE_SEARCH = "search" private const val PREFERENCE_INCOMING_SERVER = "incoming" private const val PREFERENCE_COMPOSITION = "composition" diff --git a/legacy/ui/legacy/src/main/res/xml/account_settings.xml b/legacy/ui/legacy/src/main/res/xml/account_settings.xml index e3de53a2d0c..0f4d1c72b38 100644 --- a/legacy/ui/legacy/src/main/res/xml/account_settings.xml +++ b/legacy/ui/legacy/src/main/res/xml/account_settings.xml @@ -20,109 +20,12 @@ android:title="@string/account_settings_reading_mail" /> - - - - - - - - - - - - - - - - - - - - - - - - - - - + /> -