Add DisplayOptionsScreen

This commit is contained in:
Wolf-Martell Montwé 2024-01-09 14:50:58 +01:00
parent b9a7d17bba
commit 6dc1405616
No known key found for this signature in database
GPG key ID: 6D45B21512ACBF72
27 changed files with 815 additions and 14 deletions

View file

@ -1,6 +1,7 @@
package app.k9mail.feature.account.common.data
import app.k9mail.feature.account.common.domain.AccountDomainContract
import app.k9mail.feature.account.common.domain.entity.AccountDisplayOptions
import app.k9mail.feature.account.common.domain.entity.AccountOptions
import app.k9mail.feature.account.common.domain.entity.AccountState
import app.k9mail.feature.account.common.domain.entity.AuthorizationState
@ -45,6 +46,10 @@ class InMemoryAccountStateRepository(
state = state.copy(options = options)
}
override fun setDisplayOptions(displayOptions: AccountDisplayOptions) {
state = state.copy(displayOptions = displayOptions)
}
override fun clear() {
state = AccountState()
}

View file

@ -1,5 +1,6 @@
package app.k9mail.feature.account.common.domain
import app.k9mail.feature.account.common.domain.entity.AccountDisplayOptions
import app.k9mail.feature.account.common.domain.entity.AccountOptions
import app.k9mail.feature.account.common.domain.entity.AccountState
import app.k9mail.feature.account.common.domain.entity.AuthorizationState
@ -25,6 +26,8 @@ interface AccountDomainContract {
fun setOptions(options: AccountOptions)
fun setDisplayOptions(displayOptions: AccountDisplayOptions)
fun clear()
}
}

View file

@ -0,0 +1,7 @@
package app.k9mail.feature.account.common.domain.entity
data class AccountDisplayOptions(
val accountName: String,
val displayName: String,
val emailSignature: String?,
)

View file

@ -10,4 +10,5 @@ data class AccountState(
val authorizationState: AuthorizationState? = null,
val specialFolderSettings: SpecialFolderSettings? = null,
val options: AccountOptions? = null,
val displayOptions: AccountDisplayOptions? = null,
)

View file

@ -1,6 +1,7 @@
package app.k9mail.feature.account.common.ui.preview
import app.k9mail.feature.account.common.domain.AccountDomainContract
import app.k9mail.feature.account.common.domain.entity.AccountDisplayOptions
import app.k9mail.feature.account.common.domain.entity.AccountOptions
import app.k9mail.feature.account.common.domain.entity.AccountState
import app.k9mail.feature.account.common.domain.entity.AuthorizationState
@ -49,5 +50,7 @@ class PreviewAccountStateRepository : AccountDomainContract.AccountStateReposito
override fun setOptions(options: AccountOptions) = Unit
override fun setDisplayOptions(displayOptions: AccountDisplayOptions) = Unit
override fun clear() = Unit
}

View file

@ -3,6 +3,7 @@ package app.k9mail.feature.account.setup.domain
import app.k9mail.autodiscovery.api.AutoDiscoveryResult
import app.k9mail.core.common.domain.usecase.validation.ValidationError
import app.k9mail.core.common.domain.usecase.validation.ValidationResult
import app.k9mail.feature.account.common.domain.entity.AccountDisplayOptions
import app.k9mail.feature.account.common.domain.entity.AccountOptions
import app.k9mail.feature.account.common.domain.entity.SpecialFolderOptions
import app.k9mail.feature.account.common.domain.entity.SpecialFolderSettings
@ -24,6 +25,7 @@ interface DomainContract {
authorizationState: String?,
specialFolderSettings: SpecialFolderSettings?,
options: AccountOptions,
displayOptions: AccountDisplayOptions,
): AccountCreatorResult
}

View file

@ -1,6 +1,7 @@
package app.k9mail.feature.account.setup.domain.usecase
import app.k9mail.feature.account.common.domain.entity.Account
import app.k9mail.feature.account.common.domain.entity.AccountDisplayOptions
import app.k9mail.feature.account.common.domain.entity.AccountOptions
import app.k9mail.feature.account.common.domain.entity.SpecialFolderSettings
import app.k9mail.feature.account.setup.AccountSetupExternalContract.AccountCreator
@ -20,6 +21,7 @@ class CreateAccount(
authorizationState: String?,
specialFolderSettings: SpecialFolderSettings?,
options: AccountOptions,
displayOptions: AccountDisplayOptions,
): AccountCreatorResult {
val account = Account(
uuid = uuidGenerator(),
@ -28,9 +30,20 @@ class CreateAccount(
outgoingServerSettings = outgoingServerSettings,
authorizationState = authorizationState,
specialFolderSettings = specialFolderSettings,
options = options,
options = mapOptions(options, displayOptions),
)
return accountCreator.createAccount(account)
}
private fun mapOptions(
options: AccountOptions,
displayOptions: AccountDisplayOptions,
): AccountOptions {
return options.copy(
accountName = displayOptions.accountName,
displayName = displayOptions.displayName,
emailSignature = displayOptions.emailSignature,
)
}
}

View file

@ -22,6 +22,8 @@ import app.k9mail.feature.account.setup.ui.createaccount.CreateAccountScreen
import app.k9mail.feature.account.setup.ui.createaccount.CreateAccountViewModel
import app.k9mail.feature.account.setup.ui.options.AccountOptionsScreen
import app.k9mail.feature.account.setup.ui.options.AccountOptionsViewModel
import app.k9mail.feature.account.setup.ui.options.display.DisplayOptionsScreen
import app.k9mail.feature.account.setup.ui.options.display.DisplayOptionsViewModel
import app.k9mail.feature.account.setup.ui.specialfolders.SpecialFoldersScreen
import app.k9mail.feature.account.setup.ui.specialfolders.SpecialFoldersViewModel
import org.koin.androidx.compose.koinViewModel
@ -32,7 +34,8 @@ private const val NESTED_NAVIGATION_INCOMING_SERVER_VALIDATION = "incoming-serve
private const val NESTED_NAVIGATION_OUTGOING_SERVER_CONFIG = "outgoing-server/config"
private const val NESTED_NAVIGATION_OUTGOING_SERVER_VALIDATION = "outgoing-server/validation"
private const val NESTED_NAVIGATION_SPECIAL_FOLDERS = "special-folders"
private const val NESTED_NAVIGATION_ACCOUNT_OPTIONS = "account-options"
private const val NESTED_NAVIGATION_DISPLAY_OPTIONS = "display-options"
private const val NESTED_NAVIGATION_SYNC_OPTIONS = "sync-options"
private const val NESTED_NAVIGATION_CREATE_ACCOUNT = "create-account"
@Suppress("LongMethod")
@ -109,7 +112,7 @@ fun AccountSetupNavHost(
if (hasSpecialFolders) {
NESTED_NAVIGATION_SPECIAL_FOLDERS
} else {
NESTED_NAVIGATION_ACCOUNT_OPTIONS
NESTED_NAVIGATION_DISPLAY_OPTIONS
},
) {
if (isAutomaticConfig) {
@ -127,14 +130,22 @@ fun AccountSetupNavHost(
composable(route = NESTED_NAVIGATION_SPECIAL_FOLDERS) {
SpecialFoldersScreen(
onNext = {
navController.navigate(NESTED_NAVIGATION_ACCOUNT_OPTIONS)
navController.navigate(NESTED_NAVIGATION_DISPLAY_OPTIONS)
},
onBack = { navController.popBackStack() },
viewModel = koinViewModel<SpecialFoldersViewModel>(),
)
}
composable(route = NESTED_NAVIGATION_ACCOUNT_OPTIONS) {
composable(route = NESTED_NAVIGATION_DISPLAY_OPTIONS) {
DisplayOptionsScreen(
onNext = { navController.navigate(NESTED_NAVIGATION_SYNC_OPTIONS) },
onBack = { navController.popBackStack() },
viewModel = koinViewModel<DisplayOptionsViewModel>(),
)
}
composable(route = NESTED_NAVIGATION_SYNC_OPTIONS) {
AccountOptionsScreen(
onNext = { navController.navigate(NESTED_NAVIGATION_CREATE_ACCOUNT) },
onBack = { navController.popBackStack() },

View file

@ -73,7 +73,7 @@ internal fun AccountOptionsScreenK9Preview() {
onNext = {},
onBack = {},
viewModel = CreateAccountViewModel(
createAccount = { _, _, _, _, _, _ -> AccountCreatorResult.Success("irrelevant") },
createAccount = { _, _, _, _, _, _, _ -> AccountCreatorResult.Success("irrelevant") },
accountStateRepository = InMemoryAccountStateRepository(),
),
)

View file

@ -40,6 +40,7 @@ class CreateAccountViewModel(
authorizationState = accountState.authorizationState?.state,
specialFolderSettings = accountState.specialFolderSettings,
options = accountState.options!!,
displayOptions = accountState.displayOptions!!,
)
when (result) {

View file

@ -0,0 +1,128 @@
package app.k9mail.feature.account.setup.ui.options.display
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.requiredHeight
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import app.k9mail.core.ui.compose.designsystem.atom.text.TextOverline
import app.k9mail.core.ui.compose.designsystem.molecule.input.TextInput
import app.k9mail.core.ui.compose.designsystem.template.ResponsiveWidthContainer
import app.k9mail.core.ui.compose.theme.K9Theme
import app.k9mail.core.ui.compose.theme.MainTheme
import app.k9mail.core.ui.compose.theme.ThunderbirdTheme
import app.k9mail.feature.account.common.ui.item.defaultHeadlineItemPadding
import app.k9mail.feature.account.common.ui.item.defaultItemPadding
import app.k9mail.feature.account.setup.R
import app.k9mail.feature.account.setup.ui.options.display.DisplayOptionsContract.Event
import app.k9mail.feature.account.setup.ui.options.display.DisplayOptionsContract.State
@OptIn(ExperimentalLayoutApi::class)
@Suppress("LongMethod")
@Composable
internal fun DisplayOptionsContent(
state: State,
onEvent: (Event) -> Unit,
contentPadding: PaddingValues,
modifier: Modifier = Modifier,
) {
val resources = LocalContext.current.resources
ResponsiveWidthContainer(
modifier = Modifier
.testTag("DisplayOptionsContent")
.consumeWindowInsets(contentPadding)
.padding(contentPadding)
.then(modifier),
) {
LazyColumn(
modifier = Modifier
.fillMaxSize()
.imePadding(),
horizontalAlignment = Alignment.Start,
verticalArrangement = Arrangement.spacedBy(MainTheme.spacings.default),
) {
item {
TextOverline(
text = stringResource(id = R.string.account_setup_options_section_display_options),
modifier = Modifier
.fillMaxWidth()
.padding(defaultHeadlineItemPadding()),
)
}
item {
TextInput(
text = state.accountName.value,
errorMessage = state.accountName.error?.toResourceString(resources),
onTextChange = { onEvent(Event.OnAccountNameChanged(it)) },
label = stringResource(id = R.string.account_setup_options_account_name_label),
contentPadding = defaultItemPadding(),
)
}
item {
TextInput(
text = state.displayName.value,
errorMessage = state.displayName.error?.toResourceString(resources),
onTextChange = { onEvent(Event.OnDisplayNameChanged(it)) },
label = stringResource(id = R.string.account_setup_options_display_name_label),
contentPadding = defaultItemPadding(),
isRequired = true,
)
}
item {
TextInput(
text = state.emailSignature.value,
errorMessage = state.emailSignature.error?.toResourceString(resources),
onTextChange = { onEvent(Event.OnEmailSignatureChanged(it)) },
label = stringResource(id = R.string.account_setup_options_email_signature_label),
contentPadding = defaultItemPadding(),
isSingleLine = false,
)
}
item {
Spacer(modifier = Modifier.requiredHeight(MainTheme.sizes.smaller))
}
}
}
}
@Composable
@Preview(showBackground = true)
internal fun DisplayOptionsContentK9Preview() {
K9Theme {
DisplayOptionsContent(
state = State(),
onEvent = {},
contentPadding = PaddingValues(),
)
}
}
@Composable
@Preview(showBackground = true)
internal fun DisplayOptionsContentThunderbirdPreview() {
ThunderbirdTheme {
DisplayOptionsContent(
state = State(),
onEvent = {},
contentPadding = PaddingValues(),
)
}
}

View file

@ -0,0 +1,38 @@
package app.k9mail.feature.account.setup.ui.options.display
import app.k9mail.core.common.domain.usecase.validation.ValidationResult
import app.k9mail.core.ui.compose.common.mvi.UnidirectionalViewModel
import app.k9mail.feature.account.common.domain.input.StringInputField
interface DisplayOptionsContract {
interface ViewModel : UnidirectionalViewModel<State, Event, Effect>
data class State(
val accountName: StringInputField = StringInputField(),
val displayName: StringInputField = StringInputField(),
val emailSignature: StringInputField = StringInputField(),
)
sealed interface Event {
data class OnAccountNameChanged(val accountName: String) : Event
data class OnDisplayNameChanged(val displayName: String) : Event
data class OnEmailSignatureChanged(val emailSignature: String) : Event
data object LoadAccountState : Event
data object OnNextClicked : Event
data object OnBackClicked : Event
}
sealed interface Effect {
data object NavigateNext : Effect
data object NavigateBack : Effect
}
interface Validator {
fun validateAccountName(accountName: String): ValidationResult
fun validateDisplayName(displayName: String): ValidationResult
fun validateEmailSignature(emailSignature: String): ValidationResult
}
}

View file

@ -0,0 +1,93 @@
package app.k9mail.feature.account.setup.ui.options.display
import androidx.activity.compose.BackHandler
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import app.k9mail.core.ui.compose.common.mvi.observe
import app.k9mail.core.ui.compose.designsystem.template.Scaffold
import app.k9mail.core.ui.compose.theme.K9Theme
import app.k9mail.core.ui.compose.theme.ThunderbirdTheme
import app.k9mail.feature.account.common.ui.AccountTopAppBar
import app.k9mail.feature.account.common.ui.WizardNavigationBar
import app.k9mail.feature.account.common.ui.preview.PreviewAccountStateRepository
import app.k9mail.feature.account.setup.R.string
import app.k9mail.feature.account.setup.ui.options.display.DisplayOptionsContract.Effect
import app.k9mail.feature.account.setup.ui.options.display.DisplayOptionsContract.Event
import app.k9mail.feature.account.setup.ui.options.display.DisplayOptionsContract.ViewModel
@Composable
internal fun DisplayOptionsScreen(
onNext: () -> Unit,
onBack: () -> Unit,
viewModel: ViewModel,
modifier: Modifier = Modifier,
) {
val (state, dispatch) = viewModel.observe { effect ->
when (effect) {
Effect.NavigateBack -> onBack()
Effect.NavigateNext -> onNext()
}
}
LaunchedEffect(key1 = Unit) {
dispatch(Event.LoadAccountState)
}
BackHandler {
dispatch(Event.OnBackClicked)
}
Scaffold(
topBar = {
AccountTopAppBar(
title = stringResource(id = string.account_setup_options_section_display_options),
)
},
bottomBar = {
WizardNavigationBar(
onNextClick = { dispatch(Event.OnNextClicked) },
onBackClick = { dispatch(Event.OnBackClicked) },
)
},
modifier = modifier,
) { innerPadding ->
DisplayOptionsContent(
state = state.value,
onEvent = { dispatch(it) },
contentPadding = innerPadding,
)
}
}
@Composable
@Preview(showBackground = true)
internal fun DisplayOptionsScreenK9Preview() {
K9Theme {
DisplayOptionsScreen(
onNext = {},
onBack = {},
viewModel = DisplayOptionsViewModel(
validator = DisplayOptionsValidator(),
accountStateRepository = PreviewAccountStateRepository(),
),
)
}
}
@Composable
@Preview(showBackground = true)
internal fun DisplayOptionsScreenThunderbirdPreview() {
ThunderbirdTheme {
DisplayOptionsScreen(
onNext = {},
onBack = {},
viewModel = DisplayOptionsViewModel(
validator = DisplayOptionsValidator(),
accountStateRepository = PreviewAccountStateRepository(),
),
)
}
}

View file

@ -0,0 +1,31 @@
package app.k9mail.feature.account.setup.ui.options.display
import app.k9mail.feature.account.common.domain.entity.AccountDisplayOptions
import app.k9mail.feature.account.common.domain.entity.AccountState
import app.k9mail.feature.account.common.domain.input.StringInputField
import app.k9mail.feature.account.setup.ui.options.display.DisplayOptionsContract.State
internal fun AccountState.toAccountOptionsState(): State {
val options = options
return if (options == null) {
State(
accountName = StringInputField(emailAddress ?: ""),
// displayName = StringInputField(""),
// TODO: get display name from: preferences.defaultAccount?.senderName ?: ""
)
} else {
State(
accountName = StringInputField(options.accountName),
displayName = StringInputField(options.displayName),
emailSignature = StringInputField(options.emailSignature ?: ""),
)
}
}
internal fun State.toAccountDisplayOptions(): AccountDisplayOptions {
return AccountDisplayOptions(
accountName = accountName.value,
displayName = displayName.value,
emailSignature = emailSignature.value.takeIf { it.isNotEmpty() },
)
}

View file

@ -0,0 +1,38 @@
package app.k9mail.feature.account.setup.ui.options.display
import android.content.res.Resources
import app.k9mail.core.common.domain.usecase.validation.ValidationError
import app.k9mail.feature.account.setup.R
import app.k9mail.feature.account.setup.domain.usecase.ValidateAccountName.ValidateAccountNameError
import app.k9mail.feature.account.setup.domain.usecase.ValidateAccountName.ValidateAccountNameError.BlankAccountName
import app.k9mail.feature.account.setup.domain.usecase.ValidateDisplayName.ValidateDisplayNameError
import app.k9mail.feature.account.setup.domain.usecase.ValidateDisplayName.ValidateDisplayNameError.EmptyDisplayName
import app.k9mail.feature.account.setup.domain.usecase.ValidateEmailSignature.ValidateEmailSignatureError
import app.k9mail.feature.account.setup.domain.usecase.ValidateEmailSignature.ValidateEmailSignatureError.BlankEmailSignature
internal fun ValidationError.toResourceString(resources: Resources): String {
return when (this) {
is ValidateAccountNameError -> toAccountNameErrorString(resources)
is ValidateDisplayNameError -> toDisplayNameErrorString(resources)
is ValidateEmailSignatureError -> toEmailSignatureErrorString(resources)
else -> throw IllegalArgumentException("Unknown error: $this")
}
}
private fun ValidateAccountNameError.toAccountNameErrorString(resources: Resources): String {
return when (this) {
is BlankAccountName -> resources.getString(R.string.account_setup_options_account_name_error_blank)
}
}
private fun ValidateDisplayNameError.toDisplayNameErrorString(resources: Resources): String {
return when (this) {
is EmptyDisplayName -> resources.getString(R.string.account_setup_options_display_name_error_required)
}
}
private fun ValidateEmailSignatureError.toEmailSignatureErrorString(resources: Resources): String {
return when (this) {
is BlankEmailSignature -> resources.getString(R.string.account_setup_options_email_signature_error_blank)
}
}

View file

@ -0,0 +1,25 @@
package app.k9mail.feature.account.setup.ui.options.display
import app.k9mail.core.common.domain.usecase.validation.ValidationResult
import app.k9mail.feature.account.setup.domain.usecase.ValidateAccountName
import app.k9mail.feature.account.setup.domain.usecase.ValidateDisplayName
import app.k9mail.feature.account.setup.domain.usecase.ValidateEmailSignature
import app.k9mail.feature.account.setup.ui.options.display.DisplayOptionsContract.Validator
internal class DisplayOptionsValidator(
private val accountNameValidator: ValidateAccountName = ValidateAccountName(),
private val displayNameValidator: ValidateDisplayName = ValidateDisplayName(),
private val emailSignatureValidator: ValidateEmailSignature = ValidateEmailSignature(),
) : Validator {
override fun validateAccountName(accountName: String): ValidationResult {
return accountNameValidator.execute(accountName)
}
override fun validateDisplayName(displayName: String): ValidationResult {
return displayNameValidator.execute(displayName)
}
override fun validateEmailSignature(emailSignature: String): ValidationResult {
return emailSignatureValidator.execute(emailSignature)
}
}

View file

@ -0,0 +1,82 @@
package app.k9mail.feature.account.setup.ui.options.display
import app.k9mail.core.common.domain.usecase.validation.ValidationResult
import app.k9mail.core.ui.compose.common.mvi.BaseViewModel
import app.k9mail.feature.account.common.domain.AccountDomainContract
import app.k9mail.feature.account.setup.ui.options.display.DisplayOptionsContract.Effect
import app.k9mail.feature.account.setup.ui.options.display.DisplayOptionsContract.Event
import app.k9mail.feature.account.setup.ui.options.display.DisplayOptionsContract.State
import app.k9mail.feature.account.setup.ui.options.display.DisplayOptionsContract.Validator
import app.k9mail.feature.account.setup.ui.options.display.DisplayOptionsContract.ViewModel
internal class DisplayOptionsViewModel(
private val validator: Validator,
private val accountStateRepository: AccountDomainContract.AccountStateRepository,
initialState: State? = null,
) : BaseViewModel<State, Event, Effect>(
initialState = initialState ?: accountStateRepository.getState().toAccountOptionsState(),
),
ViewModel {
override fun event(event: Event) {
when (event) {
Event.LoadAccountState -> handleOneTimeEvent(event, ::loadAccountState)
is Event.OnAccountNameChanged -> updateState { state ->
state.copy(
accountName = state.accountName.updateValue(event.accountName),
)
}
is Event.OnDisplayNameChanged -> updateState {
it.copy(
displayName = it.displayName.updateValue(event.displayName),
)
}
is Event.OnEmailSignatureChanged -> updateState {
it.copy(
emailSignature = it.emailSignature.updateValue(event.emailSignature),
)
}
Event.OnNextClicked -> submit()
Event.OnBackClicked -> navigateBack()
}
}
private fun loadAccountState() {
updateState {
accountStateRepository.getState().toAccountOptionsState()
}
}
private fun submit() = with(state.value) {
val accountNameResult = validator.validateAccountName(accountName.value)
val displayNameResult = validator.validateDisplayName(displayName.value)
val emailSignatureResult = validator.validateEmailSignature(emailSignature.value)
val hasError = listOf(
accountNameResult,
displayNameResult,
emailSignatureResult,
).any { it is ValidationResult.Failure }
updateState {
it.copy(
accountName = it.accountName.updateFromValidationResult(accountNameResult),
displayName = it.displayName.updateFromValidationResult(displayNameResult),
emailSignature = it.emailSignature.updateFromValidationResult(emailSignatureResult),
)
}
if (!hasError) {
accountStateRepository.setDisplayOptions(state.value.toAccountDisplayOptions())
navigateNext()
}
}
private fun navigateBack() = emitEffect(Effect.NavigateBack)
private fun navigateNext() = emitEffect(Effect.NavigateNext)
}

View file

@ -44,8 +44,8 @@
<string name="account_setup_options_section_display_options">Display options</string>
<string name="account_setup_options_account_name_label">Account name</string>
<string name="account_setup_options_account_name_error_blank">Account name can\'t be blank.</string>
<string name="account_setup_options_display_name_label">Display name</string>
<string name="account_setup_options_display_name_error_required">Display name is required.</string>
<string name="account_setup_options_display_name_label">Your name</string>
<string name="account_setup_options_display_name_error_required">Your name is required.</string>
<string name="account_setup_options_email_signature_label">Email signature</string>
<string name="account_setup_options_email_signature_error_blank">Email signature name can\'t be blank.</string>
<string name="account_setup_options_section_sync_options_">Sync options</string>

View file

@ -1,6 +1,7 @@
package app.k9mail.feature.account.setup.domain.usecase
import app.k9mail.feature.account.common.domain.entity.Account
import app.k9mail.feature.account.common.domain.entity.AccountDisplayOptions
import app.k9mail.feature.account.common.domain.entity.AccountOptions
import app.k9mail.feature.account.common.domain.entity.MailConnectionSecurity
import app.k9mail.feature.account.common.domain.entity.SpecialFolderOption
@ -30,12 +31,13 @@ class CreateAccountTest {
)
val result = createAccount.execute(
EMAIL_ADDRESS,
INCOMING_SETTINGS,
OUTGOING_SETTINGS,
AUTHORIZATION_STATE,
SPECIAL_FOLDER_SETTINGS,
OPTIONS,
emailAddress = EMAIL_ADDRESS,
incomingServerSettings = INCOMING_SETTINGS,
outgoingServerSettings = OUTGOING_SETTINGS,
authorizationState = AUTHORIZATION_STATE,
specialFolderSettings = SPECIAL_FOLDER_SETTINGS,
options = OPTIONS,
displayOptions = DISPLAY_OPTIONS,
)
assertThat(result).isEqualTo(AccountCreatorResult.Success("uuid"))
@ -105,5 +107,11 @@ class CreateAccountTest {
messageDisplayCount = 25,
showNotification = true,
)
val DISPLAY_OPTIONS = AccountDisplayOptions(
accountName = "accountName",
displayName = "displayName",
emailSignature = null,
)
}
}

View file

@ -4,6 +4,7 @@ import app.cash.turbine.testIn
import app.k9mail.core.ui.compose.testing.MainDispatcherRule
import app.k9mail.core.ui.compose.testing.mvi.eventStateTest
import app.k9mail.feature.account.common.data.InMemoryAccountStateRepository
import app.k9mail.feature.account.common.domain.entity.AccountDisplayOptions
import app.k9mail.feature.account.common.domain.entity.AccountOptions
import app.k9mail.feature.account.common.domain.entity.AccountState
import app.k9mail.feature.account.common.domain.entity.AuthorizationState
@ -67,6 +68,7 @@ class CreateAccountViewModelTest {
authorizationState = AUTHORIZATION_STATE.state,
specialFolderSettings = SPECIAL_FOLDER_SETTINGS,
options = ACCOUNT_OPTIONS,
displayOptions = ACCOUNT_DISPLAY_OPTIONS,
),
)
@ -193,6 +195,12 @@ class CreateAccountViewModelTest {
showNotification = false,
)
val ACCOUNT_DISPLAY_OPTIONS = AccountDisplayOptions(
accountName = "account name",
displayName = "display name",
emailSignature = null,
)
val ACCOUNT_STATE = AccountState(
emailAddress = EMAIL_ADDRESS,
incomingServerSettings = INCOMING_SERVER_SETTINGS,

View file

@ -1,5 +1,6 @@
package app.k9mail.feature.account.setup.ui.createaccount
import app.k9mail.feature.account.common.domain.entity.AccountDisplayOptions
import app.k9mail.feature.account.common.domain.entity.AccountOptions
import app.k9mail.feature.account.common.domain.entity.SpecialFolderSettings
import app.k9mail.feature.account.setup.AccountSetupExternalContract.AccountCreator.AccountCreatorResult
@ -18,6 +19,7 @@ class FakeCreateAccount : CreateAccount {
authorizationState: String?,
specialFolderSettings: SpecialFolderSettings?,
options: AccountOptions,
displayOptions: AccountDisplayOptions,
): AccountCreatorResult {
recordedInvocations.add(
CreateAccountArguments(
@ -27,6 +29,7 @@ class FakeCreateAccount : CreateAccount {
authorizationState,
specialFolderSettings,
options,
displayOptions,
),
)
@ -41,4 +44,5 @@ data class CreateAccountArguments(
val authorizationState: String?,
val specialFolderSettings: SpecialFolderSettings?,
val options: AccountOptions,
val displayOptions: AccountDisplayOptions,
)

View file

@ -0,0 +1,45 @@
package app.k9mail.feature.account.setup.ui.options.display
import app.k9mail.core.ui.compose.testing.ComposeTest
import app.k9mail.core.ui.compose.testing.setContent
import app.k9mail.core.ui.compose.theme.ThunderbirdTheme
import app.k9mail.feature.account.setup.ui.options.display.DisplayOptionsContract.Effect
import app.k9mail.feature.account.setup.ui.options.display.DisplayOptionsContract.State
import assertk.assertThat
import assertk.assertions.isEqualTo
import kotlinx.coroutines.test.runTest
import org.junit.Test
class DisplayOptionsScreenKtTest : ComposeTest() {
@Test
fun `should delegate navigation effects`() = runTest {
val initialState = State()
val viewModel = FakeDisplayOptionsViewModel(initialState)
var onNextCounter = 0
var onBackCounter = 0
setContent {
ThunderbirdTheme {
DisplayOptionsScreen(
onNext = { onNextCounter++ },
onBack = { onBackCounter++ },
viewModel = viewModel,
)
}
}
assertThat(onNextCounter).isEqualTo(0)
assertThat(onBackCounter).isEqualTo(0)
viewModel.effect(Effect.NavigateNext)
assertThat(onNextCounter).isEqualTo(1)
assertThat(onBackCounter).isEqualTo(0)
viewModel.effect(Effect.NavigateBack)
assertThat(onNextCounter).isEqualTo(1)
assertThat(onBackCounter).isEqualTo(1)
}
}

View file

@ -0,0 +1,39 @@
package app.k9mail.feature.account.setup.ui.options.display
import app.k9mail.feature.account.common.domain.entity.AccountDisplayOptions
import app.k9mail.feature.account.common.domain.input.StringInputField
import assertk.assertThat
import assertk.assertions.isEqualTo
import assertk.assertions.isNull
import org.junit.Test
class DisplayOptionsStateMapperKtTest {
@Test
fun `should map state to account options`() {
val state = DisplayOptionsContract.State(
accountName = StringInputField("accountName"),
displayName = StringInputField("displayName"),
emailSignature = StringInputField("emailSignature"),
)
val result = state.toAccountDisplayOptions()
assertThat(result).isEqualTo(
AccountDisplayOptions(
accountName = "accountName",
displayName = "displayName",
emailSignature = "emailSignature",
),
)
}
@Test
fun `empty signature should map to null`() {
val state = DisplayOptionsContract.State(emailSignature = StringInputField(""))
val result = state.toAccountDisplayOptions()
assertThat(result.emailSignature).isNull()
}
}

View file

@ -0,0 +1,23 @@
package app.k9mail.feature.account.setup.ui.options.display
import app.k9mail.feature.account.common.domain.input.StringInputField
import app.k9mail.feature.account.setup.ui.options.display.DisplayOptionsContract.State
import assertk.assertThat
import assertk.assertions.isEqualTo
import org.junit.Test
class DisplayOptionsStateTest {
@Test
fun `should set default values`() {
val state = State()
assertThat(state).isEqualTo(
State(
accountName = StringInputField(),
displayName = StringInputField(),
emailSignature = StringInputField(),
),
)
}
}

View file

@ -0,0 +1,157 @@
package app.k9mail.feature.account.setup.ui.options.display
import app.cash.turbine.testIn
import app.k9mail.core.common.domain.usecase.validation.ValidationError
import app.k9mail.core.common.domain.usecase.validation.ValidationResult
import app.k9mail.core.ui.compose.testing.MainDispatcherRule
import app.k9mail.core.ui.compose.testing.mvi.eventStateTest
import app.k9mail.feature.account.common.data.InMemoryAccountStateRepository
import app.k9mail.feature.account.common.domain.input.StringInputField
import app.k9mail.feature.account.setup.ui.options.display.DisplayOptionsContract.Effect
import app.k9mail.feature.account.setup.ui.options.display.DisplayOptionsContract.Event
import app.k9mail.feature.account.setup.ui.options.display.DisplayOptionsContract.State
import assertk.assertThat
import assertk.assertions.assertThatAndTurbinesConsumed
import assertk.assertions.isEqualTo
import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
class DisplayOptionsViewModelTest {
@get:Rule
val mainDispatcherRule = MainDispatcherRule()
private val testSubject = DisplayOptionsViewModel(
validator = FakeDisplayOptionsValidator(),
accountStateRepository = InMemoryAccountStateRepository(),
)
@Test
fun `should change state when OnAccountNameChanged event is received`() = runTest {
eventStateTest(
viewModel = testSubject,
initialState = State(),
event = Event.OnAccountNameChanged("accountName"),
expectedState = State(accountName = StringInputField(value = "accountName")),
coroutineScope = backgroundScope,
)
}
@Test
fun `should change state when OnDisplayNameChanged event is received`() = runTest {
eventStateTest(
viewModel = testSubject,
initialState = State(),
event = Event.OnDisplayNameChanged("displayName"),
expectedState = State(displayName = StringInputField(value = "displayName")),
coroutineScope = backgroundScope,
)
}
@Test
fun `should change state when OnEmailSignatureChanged event is received`() = runTest {
eventStateTest(
viewModel = testSubject,
initialState = State(),
event = Event.OnEmailSignatureChanged("emailSignature"),
expectedState = State(emailSignature = StringInputField(value = "emailSignature")),
coroutineScope = backgroundScope,
)
}
@Test
fun `should change state and emit NavigateNext effect when OnNextClicked event received and input valid`() =
runTest {
val viewModel = testSubject
val stateTurbine = viewModel.state.testIn(backgroundScope)
val effectTurbine = viewModel.effect.testIn(backgroundScope)
val turbines = listOf(stateTurbine, effectTurbine)
assertThatAndTurbinesConsumed(
actual = stateTurbine.awaitItem(),
turbines = turbines,
) {
isEqualTo(State())
}
viewModel.event(Event.OnNextClicked)
assertThat(stateTurbine.awaitItem()).isEqualTo(
State(
accountName = StringInputField(value = "", isValid = true),
displayName = StringInputField(value = "", isValid = true),
emailSignature = StringInputField(value = "", isValid = true),
),
)
assertThatAndTurbinesConsumed(
actual = effectTurbine.awaitItem(),
turbines = turbines,
) {
isEqualTo(Effect.NavigateNext)
}
}
@Test
fun `should change state and not emit effect when OnNextClicked event received and input invalid`() =
runTest {
val viewModel = DisplayOptionsViewModel(
validator = FakeDisplayOptionsValidator(
accountNameAnswer = ValidationResult.Failure(TestError),
),
accountStateRepository = InMemoryAccountStateRepository(),
)
val stateTurbine = viewModel.state.testIn(backgroundScope)
val effectTurbine = viewModel.effect.testIn(backgroundScope)
val turbines = listOf(stateTurbine, effectTurbine)
assertThatAndTurbinesConsumed(
actual = stateTurbine.awaitItem(),
turbines = turbines,
) {
isEqualTo(State())
}
viewModel.event(Event.OnNextClicked)
assertThatAndTurbinesConsumed(
actual = stateTurbine.awaitItem(),
turbines = turbines,
) {
isEqualTo(
State(
accountName = StringInputField(value = "", error = TestError, isValid = false),
displayName = StringInputField(value = "", isValid = true),
emailSignature = StringInputField(value = "", isValid = true),
),
)
}
}
@Test
fun `should emit NavigateBack effect when OnBackClicked event received`() = runTest {
val viewModel = testSubject
val stateTurbine = viewModel.state.testIn(backgroundScope)
val effectTurbine = viewModel.effect.testIn(backgroundScope)
val turbines = listOf(stateTurbine, effectTurbine)
assertThatAndTurbinesConsumed(
actual = stateTurbine.awaitItem(),
turbines = turbines,
) {
isEqualTo(State())
}
viewModel.event(Event.OnBackClicked)
assertThatAndTurbinesConsumed(
actual = effectTurbine.awaitItem(),
turbines = turbines,
) {
isEqualTo(Effect.NavigateBack)
}
}
private object TestError : ValidationError
}

View file

@ -0,0 +1,14 @@
package app.k9mail.feature.account.setup.ui.options.display
import app.k9mail.core.common.domain.usecase.validation.ValidationResult
import app.k9mail.feature.account.setup.ui.options.display.DisplayOptionsContract.Validator
internal class FakeDisplayOptionsValidator(
private val accountNameAnswer: ValidationResult = ValidationResult.Success,
private val displayNameAnswer: ValidationResult = ValidationResult.Success,
private val emailSignatureAnswer: ValidationResult = ValidationResult.Success,
) : Validator {
override fun validateAccountName(accountName: String): ValidationResult = accountNameAnswer
override fun validateDisplayName(displayName: String): ValidationResult = displayNameAnswer
override fun validateEmailSignature(emailSignature: String): ValidationResult = emailSignatureAnswer
}

View file

@ -0,0 +1,22 @@
package app.k9mail.feature.account.setup.ui.options.display
import app.k9mail.core.ui.compose.common.mvi.BaseViewModel
import app.k9mail.feature.account.setup.ui.options.display.DisplayOptionsContract.Effect
import app.k9mail.feature.account.setup.ui.options.display.DisplayOptionsContract.Event
import app.k9mail.feature.account.setup.ui.options.display.DisplayOptionsContract.State
import app.k9mail.feature.account.setup.ui.options.display.DisplayOptionsContract.ViewModel
class FakeDisplayOptionsViewModel(
initialState: State = State(),
) : BaseViewModel<State, Event, Effect>(initialState), ViewModel {
val events = mutableListOf<Event>()
override fun event(event: Event) {
events.add(event)
}
fun effect(effect: Effect) {
emitEffect(effect)
}
}