Merge pull request #6953 from thundernest/add_account_setup_incoming_config-ui

Add account setup incoming config UI
This commit is contained in:
Wolf-Martell Montwé 2023-06-07 15:59:19 +02:00 committed by GitHub
commit 7155b09d9a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
25 changed files with 833 additions and 55 deletions

View file

@ -20,11 +20,6 @@
</option>
</JavaCodeStyleSettings>
<JetCodeStyleSettings>
<option name="PACKAGES_TO_USE_STAR_IMPORTS">
<value>
<package name="kotlinx.android.synthetic" alias="false" withSubpackages="true" />
</value>
</option>
<option name="PACKAGES_IMPORT_LAYOUT">
<value>
<package name="" alias="false" withSubpackages="true" />
@ -33,7 +28,6 @@
</option>
<option name="NAME_COUNT_TO_USE_STAR_IMPORT" value="99" />
<option name="NAME_COUNT_TO_USE_STAR_IMPORT_FOR_MEMBERS" value="99" />
<option name="IMPORT_NESTED_CLASSES" value="true" />
<option name="ALLOW_TRAILING_COMMA" value="true" />
<option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" />
</JetCodeStyleSettings>

View file

@ -1,6 +1,7 @@
package app.k9mail.feature.account.setup
import app.k9mail.feature.account.setup.ui.AccountSetupViewModel
import app.k9mail.feature.account.setup.ui.incoming.AccountIncomingConfigViewModel
import app.k9mail.feature.account.setup.ui.options.AccountOptionsContract
import app.k9mail.feature.account.setup.ui.options.AccountOptionsValidator
import app.k9mail.feature.account.setup.ui.options.AccountOptionsViewModel
@ -16,6 +17,7 @@ val featureAccountSetupModule: Module = module {
factory<AccountOptionsContract.Validator> { AccountOptionsValidator() }
viewModel { AccountSetupViewModel() }
viewModel { AccountIncomingConfigViewModel() }
viewModel {
AccountOutgoingConfigViewModel(
validator = get(),

View file

@ -34,3 +34,12 @@ fun ConnectionSecurity.toImapDefaultPort(): Long {
TLS -> 993
}
}
@Suppress("MagicNumber")
fun ConnectionSecurity.toPop3DefaultPort(): Long {
return when (this) {
None -> 110
StartTLS -> 110
TLS -> 995
}
}

View file

@ -0,0 +1,30 @@
package app.k9mail.feature.account.setup.domain.entity
import kotlinx.collections.immutable.toImmutableList
enum class IncomingProtocolType {
IMAP,
POP3,
;
companion object {
val DEFAULT = IMAP
fun all() = values().toList().toImmutableList()
}
}
@Suppress("SameReturnValue")
fun IncomingProtocolType.toDefaultSecurity(): ConnectionSecurity {
return when (this) {
IncomingProtocolType.IMAP -> ConnectionSecurity.TLS
IncomingProtocolType.POP3 -> ConnectionSecurity.TLS
}
}
fun IncomingProtocolType.toDefaultPort(connectionSecurity: ConnectionSecurity): Long {
return when (this) {
IncomingProtocolType.IMAP -> connectionSecurity.toImapDefaultPort()
IncomingProtocolType.POP3 -> connectionSecurity.toPop3DefaultPort()
}
}

View file

@ -3,7 +3,7 @@ package app.k9mail.feature.account.setup.domain.input
import app.k9mail.core.common.domain.usecase.validation.ValidationError
import app.k9mail.core.common.domain.usecase.validation.ValidationResult
data class NumberInputField(
class NumberInputField(
override val value: Long? = null,
override val error: ValidationError? = null,
override val isValid: Boolean = false,
@ -37,15 +37,35 @@ data class NumberInputField(
override fun updateFromValidationResult(result: ValidationResult): NumberInputField {
return when (result) {
is ValidationResult.Success -> copy(
is ValidationResult.Success -> NumberInputField(
value = value,
error = null,
isValid = true,
)
is ValidationResult.Failure -> copy(
is ValidationResult.Failure -> NumberInputField(
value = value,
error = result.error,
isValid = false,
)
}
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as NumberInputField
if (value != other.value) return false
if (error != other.error) return false
return isValid == other.isValid
}
override fun hashCode(): Int {
var result = value?.hashCode() ?: 0
result = 31 * result + (error?.hashCode() ?: 0)
result = 31 * result + isValid.hashCode()
return result
}
}

View file

@ -3,7 +3,7 @@ package app.k9mail.feature.account.setup.domain.input
import app.k9mail.core.common.domain.usecase.validation.ValidationError
import app.k9mail.core.common.domain.usecase.validation.ValidationResult
data class StringInputField(
class StringInputField(
override val value: String = "",
override val error: ValidationError? = null,
override val isValid: Boolean = false,
@ -37,15 +37,35 @@ data class StringInputField(
override fun updateFromValidationResult(result: ValidationResult): StringInputField {
return when (result) {
is ValidationResult.Success -> copy(
is ValidationResult.Success -> StringInputField(
value = value,
error = null,
isValid = true,
)
is ValidationResult.Failure -> copy(
is ValidationResult.Failure -> StringInputField(
value = value,
error = result.error,
isValid = false,
)
}
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as StringInputField
if (value != other.value) return false
if (error != other.error) return false
return isValid == other.isValid
}
override fun hashCode(): Int {
var result = value.hashCode()
result = 31 * result + (error?.hashCode() ?: 0)
result = 31 * result + isValid.hashCode()
return result
}
}

View file

@ -8,7 +8,9 @@ import app.k9mail.feature.account.setup.ui.AccountSetupContract.Event
import app.k9mail.feature.account.setup.ui.AccountSetupContract.SetupStep
import app.k9mail.feature.account.setup.ui.AccountSetupContract.ViewModel
import app.k9mail.feature.account.setup.ui.autoconfig.AccountAutoConfigScreen
import app.k9mail.feature.account.setup.ui.incoming.AccountIncomingConfigContract
import app.k9mail.feature.account.setup.ui.incoming.AccountIncomingConfigScreen
import app.k9mail.feature.account.setup.ui.incoming.AccountIncomingConfigViewModel
import app.k9mail.feature.account.setup.ui.options.AccountOptionsContract
import app.k9mail.feature.account.setup.ui.options.AccountOptionsScreen
import app.k9mail.feature.account.setup.ui.options.AccountOptionsViewModel
@ -22,6 +24,7 @@ fun AccountSetupScreen(
onFinish: () -> Unit,
onBack: () -> Unit,
viewModel: ViewModel = koinViewModel<AccountSetupViewModel>(),
incomingViewModel: AccountIncomingConfigContract.ViewModel = koinViewModel<AccountIncomingConfigViewModel>(),
outgoingViewModel: AccountOutgoingConfigContract.ViewModel = koinViewModel<AccountOutgoingConfigViewModel>(),
optionsViewModel: AccountOptionsContract.ViewModel = koinViewModel<AccountOptionsViewModel>(),
) {
@ -44,6 +47,7 @@ fun AccountSetupScreen(
AccountIncomingConfigScreen(
onNext = { dispatch(Event.OnNext) },
onBack = { dispatch(Event.OnBack) },
viewModel = incomingViewModel,
)
}

View file

@ -0,0 +1,13 @@
package app.k9mail.feature.account.setup.ui
import android.content.res.Resources
import app.k9mail.feature.account.setup.R
import app.k9mail.feature.account.setup.domain.entity.ConnectionSecurity
internal fun ConnectionSecurity.toResourceString(resources: Resources): String {
return when (this) {
ConnectionSecurity.None -> resources.getString(R.string.account_setup_connection_security_none)
ConnectionSecurity.StartTLS -> resources.getString(R.string.account_setup_connection_security_start_tls)
ConnectionSecurity.TLS -> resources.getString(R.string.account_setup_connection_security_ssl)
}
}

View file

@ -2,27 +2,48 @@ package app.k9mail.feature.account.setup.ui.incoming
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Spacer
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 app.k9mail.core.ui.compose.common.DevicePreviews
import app.k9mail.core.ui.compose.designsystem.atom.text.TextBody1
import app.k9mail.core.ui.compose.designsystem.molecule.input.CheckboxInput
import app.k9mail.core.ui.compose.designsystem.molecule.input.NumberInput
import app.k9mail.core.ui.compose.designsystem.molecule.input.PasswordInput
import app.k9mail.core.ui.compose.designsystem.molecule.input.SelectInput
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.setup.R
import app.k9mail.feature.account.setup.domain.entity.ConnectionSecurity
import app.k9mail.feature.account.setup.domain.entity.IncomingProtocolType
import app.k9mail.feature.account.setup.ui.common.defaultItemPadding
import app.k9mail.feature.account.setup.ui.incoming.AccountIncomingConfigContract.Event
import app.k9mail.feature.account.setup.ui.incoming.AccountIncomingConfigContract.State
import app.k9mail.feature.account.setup.ui.toResourceString
import kotlinx.collections.immutable.persistentListOf
@Suppress("LongMethod")
@Composable
internal fun AccountIncomingConfigContent(
state: State,
onEvent: (Event) -> Unit,
contentPadding: PaddingValues,
modifier: Modifier = Modifier,
) {
val resources = LocalContext.current.resources
ResponsiveWidthContainer(
modifier = Modifier
.testTag("AccountIncomingConfigContent")
@ -38,7 +59,110 @@ internal fun AccountIncomingConfigContent(
verticalArrangement = Arrangement.spacedBy(MainTheme.spacings.default),
) {
item {
TextBody1(text = "Incoming Config")
Spacer(modifier = Modifier.requiredHeight(MainTheme.sizes.smaller))
}
item {
SelectInput(
options = IncomingProtocolType.all(),
selectedOption = state.protocolType,
onOptionChange = { onEvent(Event.ProtocolTypeChanged(it)) },
label = stringResource(id = R.string.account_setup_incoming_config_protocol_type_label),
contentPadding = defaultItemPadding(),
)
}
item {
TextInput(
text = state.server.value,
onTextChange = { onEvent(Event.ServerChanged(it)) },
label = stringResource(id = R.string.account_setup_incoming_config_server_label),
contentPadding = defaultItemPadding(),
)
}
item {
SelectInput(
options = ConnectionSecurity.all(),
optionToStringTransformation = { it.toResourceString(resources) },
selectedOption = state.security,
onOptionChange = { onEvent(Event.SecurityChanged(it)) },
label = stringResource(id = R.string.account_setup_incoming_config_security_label),
contentPadding = defaultItemPadding(),
)
}
item {
NumberInput(
value = state.port.value,
onValueChange = { onEvent(Event.PortChanged(it)) },
label = stringResource(id = R.string.account_setup_outgoing_config_port_label),
contentPadding = defaultItemPadding(),
)
}
item {
TextInput(
text = state.username.value,
onTextChange = { onEvent(Event.UsernameChanged(it)) },
label = stringResource(id = R.string.account_setup_outgoing_config_username_label),
contentPadding = defaultItemPadding(),
)
}
item {
PasswordInput(
password = state.password.value,
onPasswordChange = { onEvent(Event.PasswordChanged(it)) },
contentPadding = defaultItemPadding(),
)
}
item {
// TODO add client certificate support
SelectInput(
options = persistentListOf(
stringResource(
id = R.string.account_setup_client_certificate_none_available,
),
),
optionToStringTransformation = { it },
selectedOption = stringResource(
id = R.string.account_setup_client_certificate_none_available,
),
onOptionChange = { onEvent(Event.ClientCertificateChanged(it)) },
label = stringResource(id = R.string.account_setup_outgoing_config_client_certificate_label),
contentPadding = defaultItemPadding(),
)
}
if (state.protocolType == IncomingProtocolType.IMAP) {
item {
CheckboxInput(
text = stringResource(id = R.string.account_setup_incoming_config_imap_namespace_label),
checked = state.imapAutodetectNamespaceEnabled,
onCheckedChange = { onEvent(Event.ImapAutoDetectNamespaceChanged(it)) },
contentPadding = defaultItemPadding(),
)
}
item {
TextInput(
text = state.imapPrefix.value,
onTextChange = { onEvent(Event.ImapPrefixChanged(it)) },
label = stringResource(id = R.string.account_setup_incoming_config_imap_prefix_label),
contentPadding = defaultItemPadding(),
)
}
}
item {
CheckboxInput(
text = stringResource(id = R.string.account_setup_incoming_config_compression_label),
checked = state.useCompression,
onCheckedChange = { onEvent(Event.UseCompressionChanged(it)) },
contentPadding = defaultItemPadding(),
)
}
}
}
@ -49,6 +173,8 @@ internal fun AccountIncomingConfigContent(
internal fun AccountIncomingConfigContentK9Preview() {
K9Theme {
AccountIncomingConfigContent(
onEvent = { },
state = State(),
contentPadding = PaddingValues(),
)
}
@ -59,6 +185,8 @@ internal fun AccountIncomingConfigContentK9Preview() {
internal fun AccountIncomingConfigContentThunderbirdPreview() {
ThunderbirdTheme {
AccountIncomingConfigContent(
onEvent = { },
state = State(),
contentPadding = PaddingValues(),
)
}

View file

@ -0,0 +1,51 @@
package app.k9mail.feature.account.setup.ui.incoming
import app.k9mail.core.ui.compose.common.mvi.UnidirectionalViewModel
import app.k9mail.feature.account.setup.domain.entity.ConnectionSecurity
import app.k9mail.feature.account.setup.domain.entity.IncomingProtocolType
import app.k9mail.feature.account.setup.domain.entity.toDefaultPort
import app.k9mail.feature.account.setup.domain.entity.toDefaultSecurity
import app.k9mail.feature.account.setup.domain.input.NumberInputField
import app.k9mail.feature.account.setup.domain.input.StringInputField
interface AccountIncomingConfigContract {
interface ViewModel : UnidirectionalViewModel<State, Event, Effect> {
fun initState(state: State)
}
data class State(
val protocolType: IncomingProtocolType = IncomingProtocolType.DEFAULT,
val server: StringInputField = StringInputField(),
val security: ConnectionSecurity = IncomingProtocolType.DEFAULT.toDefaultSecurity(),
val port: NumberInputField = NumberInputField(
IncomingProtocolType.DEFAULT.toDefaultPort(IncomingProtocolType.DEFAULT.toDefaultSecurity()),
),
val username: StringInputField = StringInputField(),
val password: StringInputField = StringInputField(),
val clientCertificate: String = "",
val imapAutodetectNamespaceEnabled: Boolean = true,
val imapPrefix: StringInputField = StringInputField(),
val useCompression: Boolean = true,
)
sealed class Event {
data class ProtocolTypeChanged(val protocolType: IncomingProtocolType) : Event()
data class ServerChanged(val server: String) : Event()
data class SecurityChanged(val security: ConnectionSecurity) : Event()
data class PortChanged(val port: Long?) : Event()
data class UsernameChanged(val username: String) : Event()
data class PasswordChanged(val password: String) : Event()
data class ClientCertificateChanged(val clientCertificate: String) : Event()
data class ImapAutoDetectNamespaceChanged(val enabled: Boolean) : Event()
data class ImapPrefixChanged(val imapPrefix: String) : Event()
data class UseCompressionChanged(val useCompression: Boolean) : Event()
object OnNextClicked : Event()
object OnBackClicked : Event()
}
sealed class Effect {
object NavigateNext : Effect()
object NavigateBack : Effect()
}
}

View file

@ -4,19 +4,31 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import app.k9mail.core.ui.compose.common.DevicePreviews
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.setup.R
import app.k9mail.feature.account.setup.ui.common.AccountSetupBottomBar
import app.k9mail.feature.account.setup.ui.common.AccountSetupTopAppBar
import app.k9mail.feature.account.setup.ui.incoming.AccountIncomingConfigContract.Effect
import app.k9mail.feature.account.setup.ui.incoming.AccountIncomingConfigContract.Event
import app.k9mail.feature.account.setup.ui.incoming.AccountIncomingConfigContract.ViewModel
@Composable
fun AccountIncomingConfigScreen(
onNext: () -> Unit,
onBack: () -> Unit,
viewModel: ViewModel,
modifier: Modifier = Modifier,
) {
val (state, dispatch) = viewModel.observe { effect ->
when (effect) {
is Effect.NavigateNext -> onNext()
is Effect.NavigateBack -> onBack()
}
}
Scaffold(
topBar = {
AccountSetupTopAppBar(
@ -27,13 +39,15 @@ fun AccountIncomingConfigScreen(
AccountSetupBottomBar(
nextButtonText = stringResource(id = R.string.account_setup_button_next),
backButtonText = stringResource(id = R.string.account_setup_button_back),
onNextClick = onNext,
onBackClick = onBack,
onNextClick = { dispatch(Event.OnNextClicked) },
onBackClick = { dispatch(Event.OnBackClicked) },
)
},
modifier = modifier,
) { innerPadding ->
AccountIncomingConfigContent(
onEvent = { dispatch(it) },
state = state.value,
contentPadding = innerPadding,
)
}
@ -46,6 +60,7 @@ internal fun AccountIncomingConfigScreenK9Preview() {
AccountIncomingConfigScreen(
onNext = {},
onBack = {},
viewModel = AccountIncomingConfigViewModel(),
)
}
}
@ -57,6 +72,7 @@ internal fun AccountIncomingConfigScreenThunderbirdPreview() {
AccountIncomingConfigScreen(
onNext = {},
onBack = {},
viewModel = AccountIncomingConfigViewModel(),
)
}
}

View file

@ -0,0 +1,80 @@
package app.k9mail.feature.account.setup.ui.incoming
import app.k9mail.core.ui.compose.common.mvi.BaseViewModel
import app.k9mail.feature.account.setup.domain.entity.ConnectionSecurity
import app.k9mail.feature.account.setup.domain.entity.IncomingProtocolType
import app.k9mail.feature.account.setup.domain.entity.toDefaultPort
import app.k9mail.feature.account.setup.domain.entity.toDefaultSecurity
import app.k9mail.feature.account.setup.ui.incoming.AccountIncomingConfigContract.Effect
import app.k9mail.feature.account.setup.ui.incoming.AccountIncomingConfigContract.Event
import app.k9mail.feature.account.setup.ui.incoming.AccountIncomingConfigContract.Event.ClientCertificateChanged
import app.k9mail.feature.account.setup.ui.incoming.AccountIncomingConfigContract.Event.ImapAutoDetectNamespaceChanged
import app.k9mail.feature.account.setup.ui.incoming.AccountIncomingConfigContract.Event.ImapPrefixChanged
import app.k9mail.feature.account.setup.ui.incoming.AccountIncomingConfigContract.Event.OnBackClicked
import app.k9mail.feature.account.setup.ui.incoming.AccountIncomingConfigContract.Event.OnNextClicked
import app.k9mail.feature.account.setup.ui.incoming.AccountIncomingConfigContract.Event.PasswordChanged
import app.k9mail.feature.account.setup.ui.incoming.AccountIncomingConfigContract.Event.PortChanged
import app.k9mail.feature.account.setup.ui.incoming.AccountIncomingConfigContract.Event.ProtocolTypeChanged
import app.k9mail.feature.account.setup.ui.incoming.AccountIncomingConfigContract.Event.SecurityChanged
import app.k9mail.feature.account.setup.ui.incoming.AccountIncomingConfigContract.Event.ServerChanged
import app.k9mail.feature.account.setup.ui.incoming.AccountIncomingConfigContract.Event.UseCompressionChanged
import app.k9mail.feature.account.setup.ui.incoming.AccountIncomingConfigContract.Event.UsernameChanged
import app.k9mail.feature.account.setup.ui.incoming.AccountIncomingConfigContract.State
import app.k9mail.feature.account.setup.ui.incoming.AccountIncomingConfigContract.ViewModel
class AccountIncomingConfigViewModel(
initialState: State = State(),
) : BaseViewModel<State, Event, Effect>(initialState), ViewModel {
override fun initState(state: State) {
updateState {
state.copy()
}
}
override fun event(event: Event) {
when (event) {
is ProtocolTypeChanged -> updateProtocolType(event.protocolType)
is ServerChanged -> updateState { it.copy(server = it.server.updateValue(event.server)) }
is SecurityChanged -> updateSecurity(event.security)
is PortChanged -> updateState { it.copy(port = it.port.updateValue(event.port)) }
is UsernameChanged -> updateState { it.copy(username = it.username.updateValue(event.username)) }
is PasswordChanged -> updateState { it.copy(password = it.password.updateValue(event.password)) }
is ClientCertificateChanged -> updateState { it.copy(clientCertificate = event.clientCertificate) }
is ImapAutoDetectNamespaceChanged -> updateState { it.copy(imapAutodetectNamespaceEnabled = event.enabled) }
is ImapPrefixChanged -> updateState { it.copy(imapPrefix = it.imapPrefix.updateValue(event.imapPrefix)) }
is UseCompressionChanged -> updateState { it.copy(useCompression = event.useCompression) }
OnBackClicked -> navigateBack()
OnNextClicked -> submit()
}
}
private fun updateProtocolType(protocolType: IncomingProtocolType) {
updateState {
it.copy(
protocolType = protocolType,
security = protocolType.toDefaultSecurity(),
port = it.port.updateValue(
protocolType.toDefaultPort(protocolType.toDefaultSecurity()),
),
)
}
}
private fun updateSecurity(security: ConnectionSecurity) {
updateState {
it.copy(
security = security,
port = it.port.updateValue(it.protocolType.toDefaultPort(security)),
)
}
}
private fun submit() {
navigateNext()
}
private fun navigateBack() = emitEffect(Effect.NavigateBack)
private fun navigateNext() = emitEffect(Effect.NavigateNext)
}

View file

@ -30,6 +30,7 @@ import app.k9mail.feature.account.setup.domain.entity.ConnectionSecurity
import app.k9mail.feature.account.setup.ui.common.defaultItemPadding
import app.k9mail.feature.account.setup.ui.outgoing.AccountOutgoingConfigContract.Event
import app.k9mail.feature.account.setup.ui.outgoing.AccountOutgoingConfigContract.State
import app.k9mail.feature.account.setup.ui.toResourceString
import kotlinx.collections.immutable.persistentListOf
@Suppress("LongMethod")
@ -119,12 +120,12 @@ internal fun AccountOutgoingConfigContent(
SelectInput(
options = persistentListOf(
stringResource(
id = R.string.account_setup_outgoing_config_client_certificate_none_available,
id = R.string.account_setup_client_certificate_none_available,
),
),
optionToStringTransformation = { it },
selectedOption = stringResource(
id = R.string.account_setup_outgoing_config_client_certificate_none_available,
id = R.string.account_setup_client_certificate_none_available,
),
onOptionChange = { onEvent(Event.ClientCertificateChanged(it)) },
label = stringResource(id = R.string.account_setup_outgoing_config_client_certificate_label),

View file

@ -33,17 +33,17 @@ class AccountOutgoingConfigViewModel(
override fun event(event: Event) {
when (event) {
is ServerChanged -> updateState { it.copy(server = it.server.copy(value = event.server)) }
is ServerChanged -> updateState { it.copy(server = it.server.updateValue(event.server)) }
is SecurityChanged -> updateSecurity(event.security)
is PortChanged -> updateState { it.copy(port = it.port.copy(value = event.port)) }
is UsernameChanged -> updateState { it.copy(username = it.username.copy(value = event.username)) }
is PasswordChanged -> updateState { it.copy(password = it.password.copy(value = event.password)) }
is PortChanged -> updateState { it.copy(port = it.port.updateValue(event.port)) }
is UsernameChanged -> updateState { it.copy(username = it.username.updateValue(event.username)) }
is PasswordChanged -> updateState { it.copy(password = it.password.updateValue(event.password)) }
is ClientCertificateChanged -> updateState { it.copy(clientCertificate = event.clientCertificate) }
is ImapAutoDetectNamespaceChanged -> updateState { it.copy(imapAutodetectNamespaceEnabled = event.enabled) }
is UseCompressionChanged -> updateState { it.copy(useCompression = event.useCompression) }
OnBackClicked -> navigateBack()
OnNextClicked -> submit()
OnBackClicked -> navigateBack()
}
}
@ -51,10 +51,7 @@ class AccountOutgoingConfigViewModel(
updateState {
it.copy(
security = security,
port = it.port.copy(
value = security.toSmtpDefaultPort(),
error = null,
),
port = it.port.updateValue(security.toSmtpDefaultPort()),
)
}
}

View file

@ -3,20 +3,11 @@ package app.k9mail.feature.account.setup.ui.outgoing
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.entity.ConnectionSecurity
import app.k9mail.feature.account.setup.domain.usecase.ValidatePassword.ValidatePasswordError
import app.k9mail.feature.account.setup.domain.usecase.ValidatePort.ValidatePortError
import app.k9mail.feature.account.setup.domain.usecase.ValidateServer.ValidateServerError
import app.k9mail.feature.account.setup.domain.usecase.ValidateUsername.ValidateUsernameError
internal fun ConnectionSecurity.toResourceString(resources: Resources): String {
return when (this) {
ConnectionSecurity.None -> resources.getString(R.string.account_setup_outgoing_config_security_none)
ConnectionSecurity.StartTLS -> resources.getString(R.string.account_setup_outgoing_config_security_start_tls)
ConnectionSecurity.TLS -> resources.getString(R.string.account_setup_outgoing_config_security_ssl)
}
}
internal fun ValidationError.toResourceString(resources: Resources): String {
return when (this) {
is ValidateServerError -> toServerErrorString(resources)

View file

@ -4,18 +4,25 @@
<string name="account_setup_button_next">Next</string>
<string name="account_setup_button_back">Back</string>
<string name="account_setup_button_finish">Finish</string>
<string name="account_setup_connection_security_none">None</string>
<string name="account_setup_connection_security_ssl">SSL/TLS</string>
<string name="account_setup_connection_security_start_tls">StartTLS</string>
<string name="account_setup_client_certificate_none_available">None available</string>
<string name="account_setup_auto_config_title">K-9 Mail</string>
<string name="account_setup_incoming_config_top_bar_title">Incoming server settings</string>
<string name="account_setup_incoming_config_protocol_type_label">Protocol</string>
<string name="account_setup_incoming_config_server_label">Server</string>
<string name="account_setup_incoming_config_security_label">Security</string>
<string name="account_setup_incoming_config_imap_namespace_label">Auto-detect IMAP namespace</string>
<string name="account_setup_incoming_config_imap_prefix_label">IMAP path prefix</string>
<string name="account_setup_incoming_config_compression_label">Use compression</string>
<string name="account_setup_outgoing_config_top_bar_title">Outgoing server settings</string>
<string name="account_setup_outgoing_config_server_label">Server</string>
<string name="account_setup_outgoing_config_server_error_required">Server name is required.</string>
<string name="account_setup_outgoing_config_security_label">Security</string>
<string name="account_setup_outgoing_config_security_none">None</string>
<string name="account_setup_outgoing_config_security_ssl">SSL/TLS</string>
<string name="account_setup_outgoing_config_security_start_tls">StartTLS</string>
<string name="account_setup_outgoing_config_port_label">Port</string>
<string name="account_setup_outgoing_config_port_error_required">Port is required.</string>
<string name="account_setup_outgoing_config_port_error_invalid">Port is invalid (must be 165535).</string>
@ -23,7 +30,6 @@
<string name="account_setup_outgoing_config_username_error_required">Username is required.</string>
<string name="account_setup_outgoing_config_password_error_required">Password is required.</string>
<string name="account_setup_outgoing_config_client_certificate_label">Client certificate</string>
<string name="account_setup_outgoing_config_client_certificate_none_available">None available</string>
<string name="account_setup_outgoing_config_imap_namespace_label">Auto-detect IMAP namespace</string>
<string name="account_setup_outgoing_config_compression_label">Use compression</string>

View file

@ -1,36 +1,59 @@
package app.k9mail.feature.account.setup.domain.entity
import assertk.assertThat
import assertk.assertions.isEqualTo
import org.junit.Test
class ConnectionSecurityTest {
@Test
fun `should provide right default smtp port`() {
val connectionSecurity = ConnectionSecurity.all()
val connectionSecurities = ConnectionSecurity.all()
for (security in connectionSecurity) {
for (security in connectionSecurities) {
val port = security.toSmtpDefaultPort()
assertThat(port).isEqualTo(
when (security) {
ConnectionSecurity.None -> assert(port == 587L)
ConnectionSecurity.StartTLS -> assert(port == 587L)
ConnectionSecurity.TLS -> assert(port == 465L)
}
ConnectionSecurity.None -> 587L
ConnectionSecurity.StartTLS -> 587L
ConnectionSecurity.TLS -> 465L
},
)
}
}
@Test
fun `should provide right default imap port`() {
val connectionSecurity = ConnectionSecurity.all()
val connectionSecurities = ConnectionSecurity.all()
for (security in connectionSecurity) {
for (security in connectionSecurities) {
val port = security.toImapDefaultPort()
assertThat(port).isEqualTo(
when (security) {
ConnectionSecurity.None -> assert(port == 143L)
ConnectionSecurity.StartTLS -> assert(port == 143L)
ConnectionSecurity.TLS -> assert(port == 993L)
}
ConnectionSecurity.None -> 143L
ConnectionSecurity.StartTLS -> 143L
ConnectionSecurity.TLS -> 993L
},
)
}
}
@Test
fun `should provide right default pop3 port`() {
val connectionSecurities = ConnectionSecurity.all()
for (security in connectionSecurities) {
val port = security.toPop3DefaultPort()
assertThat(port).isEqualTo(
when (security) {
ConnectionSecurity.None -> 110L
ConnectionSecurity.StartTLS -> 110L
ConnectionSecurity.TLS -> 995L
},
)
}
}
}

View file

@ -0,0 +1,40 @@
package app.k9mail.feature.account.setup.domain.entity
import assertk.assertThat
import assertk.assertions.isEqualTo
import org.junit.Test
class IncomingProtocolTypeTest {
@Test
fun `should provide right default security`() {
val incomingProtocolTypes = IncomingProtocolType.all()
for (incomingProtocolType in incomingProtocolTypes) {
val security = incomingProtocolType.toDefaultSecurity()
assertThat(security).isEqualTo(
when (incomingProtocolType) {
IncomingProtocolType.IMAP -> ConnectionSecurity.TLS
IncomingProtocolType.POP3 -> ConnectionSecurity.TLS
},
)
}
}
@Test
fun `should provide right default port`() {
val incomingProtocolTypes = IncomingProtocolType.all()
for (incomingProtocolType in incomingProtocolTypes) {
val port = incomingProtocolType.toDefaultPort(ConnectionSecurity.TLS)
assertThat(port).isEqualTo(
when (incomingProtocolType) {
IncomingProtocolType.IMAP -> 993L
IncomingProtocolType.POP3 -> 995L
},
)
}
}
}

View file

@ -7,7 +7,9 @@ import assertk.all
import assertk.assertThat
import assertk.assertions.isEqualTo
import assertk.assertions.isFalse
import assertk.assertions.isNotSameAs
import assertk.assertions.isNull
import assertk.assertions.isSameAs
import assertk.assertions.isTrue
import assertk.assertions.prop
import org.junit.Test
@ -27,7 +29,7 @@ data class InputFieldTestData<T>(
@RunWith(Parameterized::class)
class InputFieldTest(
val data: InputFieldTestData<Any>,
private val data: InputFieldTestData<Any>,
) {
@Test
@ -50,6 +52,7 @@ class InputFieldTest(
val result = initialInput.updateValue(data.updatedValue)
assertThat(result).all {
isNotSameAs(initialInput)
hasValue(data.updatedValue)
hasNoError()
isNotValid()
@ -67,6 +70,7 @@ class InputFieldTest(
val result = initialInput.updateError(TestValidationError)
assertThat(result).all {
isNotSameAs(initialInput)
hasValue(data.initialValue)
hasError(TestValidationError)
isNotValid()
@ -84,6 +88,7 @@ class InputFieldTest(
val result = initialInput.updateValidity(isValid = true)
assertThat(result).all {
isNotSameAs(initialInput)
hasValue(data.initialValue)
hasNoError()
isValid()
@ -101,6 +106,7 @@ class InputFieldTest(
val result = initialInput.updateValidity(isValid = false)
assertThat(result).all {
isSameAs(initialInput)
hasValue(data.initialValue)
hasError(TestValidationError)
isNotValid()
@ -118,6 +124,7 @@ class InputFieldTest(
val result = initialInput.updateError(TestValidationError2)
assertThat(result).all {
isNotSameAs(initialInput)
hasValue(data.initialValue)
hasError(TestValidationError2)
isNotValid()
@ -135,6 +142,7 @@ class InputFieldTest(
val result = initialInput.updateFromValidationResult(ValidationResult.Success)
assertThat(result).all {
isNotSameAs(initialInput)
hasValue(data.initialValue)
hasNoError()
isValid()
@ -152,12 +160,45 @@ class InputFieldTest(
val result = initialInput.updateFromValidationResult(ValidationResult.Failure(TestValidationError))
assertThat(result).all {
isNotSameAs(initialInput)
hasValue(data.initialValue)
hasError(TestValidationError)
isNotValid()
}
}
@Test
fun `should decide equality on properties`() {
val input1 = data.createInitialInput(
data.initialValue,
data.initialError,
data.initialIsValid,
)
val input2 = data.createInitialInput(
data.initialValue,
data.initialError,
data.initialIsValid,
)
assertThat(input1.equals(input2)).isTrue()
}
@Test
fun `should have same hashCode`() {
val input1 = data.createInitialInput(
data.initialValue,
data.initialError,
data.initialIsValid,
)
val input2 = data.createInitialInput(
data.initialValue,
data.initialError,
data.initialIsValid,
)
assertThat(input1.hashCode()).isEqualTo(input2.hashCode())
}
private fun Assert<InputField<Any>>.hasValue(value: Any) {
prop("value") { InputField<*>::value.call(it) }.isEqualTo(value)
}

View file

@ -7,6 +7,7 @@ import app.k9mail.core.ui.compose.theme.ThunderbirdTheme
import app.k9mail.feature.account.setup.ui.AccountSetupContract.Effect
import app.k9mail.feature.account.setup.ui.AccountSetupContract.SetupStep
import app.k9mail.feature.account.setup.ui.AccountSetupContract.State
import app.k9mail.feature.account.setup.ui.incoming.FakeAccountIncomingConfigViewModel
import app.k9mail.feature.account.setup.ui.options.FakeAccountOptionsViewModel
import app.k9mail.feature.account.setup.ui.outgoing.FakeAccountOutgoingConfigViewModel
import assertk.assertThat
@ -21,6 +22,7 @@ class AccountSetupScreenKtTest : ComposeTest() {
@Test
fun `should display correct screen for every setup step`() = runTest {
val viewModel = FakeAccountSetupViewModel()
val incomingViewModel = FakeAccountIncomingConfigViewModel()
val outgoingViewModel = FakeAccountOutgoingConfigViewModel()
val optionsViewModel = FakeAccountOptionsViewModel()
@ -30,6 +32,7 @@ class AccountSetupScreenKtTest : ComposeTest() {
onFinish = { },
onBack = { },
viewModel = viewModel,
incomingViewModel = incomingViewModel,
outgoingViewModel = outgoingViewModel,
optionsViewModel = optionsViewModel,
)
@ -46,6 +49,7 @@ class AccountSetupScreenKtTest : ComposeTest() {
fun `should delegate navigation effects`() = runTest {
val initialState = State()
val viewModel = FakeAccountSetupViewModel(initialState)
val incomingViewModel = FakeAccountIncomingConfigViewModel()
val outgoingViewModel = FakeAccountOutgoingConfigViewModel()
val optionsViewModel = FakeAccountOptionsViewModel()
var onFinishCounter = 0
@ -57,6 +61,7 @@ class AccountSetupScreenKtTest : ComposeTest() {
onFinish = { onFinishCounter++ },
onBack = { onBackCounter++ },
viewModel = viewModel,
incomingViewModel = incomingViewModel,
outgoingViewModel = outgoingViewModel,
optionsViewModel = optionsViewModel,
)

View file

@ -0,0 +1,44 @@
package app.k9mail.feature.account.setup.ui.incoming
import app.k9mail.core.ui.compose.testing.ComposeTest
import app.k9mail.core.ui.compose.testing.setContent
import app.k9mail.feature.account.setup.ui.incoming.AccountIncomingConfigContract.Effect
import app.k9mail.feature.account.setup.ui.incoming.AccountIncomingConfigContract.State
import assertk.assertThat
import assertk.assertions.isEqualTo
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
import org.junit.Test
@OptIn(ExperimentalCoroutinesApi::class)
class AccountIncomingConfigScreenKtTest : ComposeTest() {
@Test
fun `should delegate navigation effects`() = runTest {
val initialState = State()
val viewModel = FakeAccountIncomingConfigViewModel(initialState)
var onNextCounter = 0
var onBackCounter = 0
setContent {
AccountIncomingConfigScreen(
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,34 @@
package app.k9mail.feature.account.setup.ui.incoming
import app.k9mail.feature.account.setup.domain.entity.ConnectionSecurity
import app.k9mail.feature.account.setup.domain.entity.IncomingProtocolType
import app.k9mail.feature.account.setup.domain.entity.toImapDefaultPort
import app.k9mail.feature.account.setup.domain.input.NumberInputField
import app.k9mail.feature.account.setup.domain.input.StringInputField
import app.k9mail.feature.account.setup.ui.incoming.AccountIncomingConfigContract.State
import assertk.all
import assertk.assertThat
import assertk.assertions.isEqualTo
import assertk.assertions.isTrue
import assertk.assertions.prop
import org.junit.Test
class AccountIncomingConfigStateTest {
@Test
fun `should set default values`() {
val state = State()
assertThat(state).all {
prop(State::protocolType).isEqualTo(IncomingProtocolType.IMAP)
prop(State::server).isEqualTo(StringInputField())
prop(State::security).isEqualTo(ConnectionSecurity.DEFAULT)
prop(State::port).isEqualTo(NumberInputField(value = ConnectionSecurity.DEFAULT.toImapDefaultPort()))
prop(State::username).isEqualTo(StringInputField())
prop(State::password).isEqualTo(StringInputField())
prop(State::clientCertificate).isEqualTo("")
prop(State::imapAutodetectNamespaceEnabled).isTrue()
prop(State::useCompression).isTrue()
}
}
}

View file

@ -0,0 +1,201 @@
package app.k9mail.feature.account.setup.ui.incoming
import androidx.lifecycle.viewmodel.compose.viewModel
import app.cash.turbine.testIn
import app.k9mail.core.ui.compose.testing.MainDispatcherRule
import app.k9mail.feature.account.setup.domain.entity.ConnectionSecurity
import app.k9mail.feature.account.setup.domain.entity.IncomingProtocolType
import app.k9mail.feature.account.setup.domain.entity.toImapDefaultPort
import app.k9mail.feature.account.setup.domain.entity.toPop3DefaultPort
import app.k9mail.feature.account.setup.domain.input.NumberInputField
import app.k9mail.feature.account.setup.domain.input.StringInputField
import app.k9mail.feature.account.setup.testing.eventStateTest
import app.k9mail.feature.account.setup.ui.incoming.AccountIncomingConfigContract.Effect
import app.k9mail.feature.account.setup.ui.incoming.AccountIncomingConfigContract.Event
import app.k9mail.feature.account.setup.ui.incoming.AccountIncomingConfigContract.State
import assertk.assertions.assertThatAndTurbinesConsumed
import assertk.assertions.isEqualTo
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
@OptIn(ExperimentalCoroutinesApi::class)
class AccountIncomingConfigViewModelTest {
@get:Rule
val mainDispatcherRule = MainDispatcherRule()
private val testSubject = AccountIncomingConfigViewModel()
@Test
fun `should change protocol, security and port when ProtocolTypeChanged event is received`() = runTest {
val initialState = State(
security = ConnectionSecurity.StartTLS,
port = NumberInputField(value = ConnectionSecurity.StartTLS.toImapDefaultPort()),
)
testSubject.initState(initialState)
eventStateTest(
viewModel = testSubject,
initialState = initialState,
event = Event.ProtocolTypeChanged(IncomingProtocolType.POP3),
expectedState = State(
protocolType = IncomingProtocolType.POP3,
security = ConnectionSecurity.TLS,
port = NumberInputField(value = ConnectionSecurity.TLS.toPop3DefaultPort()),
),
coroutineScope = backgroundScope,
)
}
@Test
fun `should change state when ServerChanged event is received`() = runTest {
eventStateTest(
viewModel = testSubject,
initialState = State(),
event = Event.ServerChanged("server"),
expectedState = State(server = StringInputField(value = "server")),
coroutineScope = backgroundScope,
)
}
@Test
fun `should change security and port when SecurityChanged event is received`() = runTest {
eventStateTest(
viewModel = testSubject,
initialState = State(),
event = Event.SecurityChanged(ConnectionSecurity.StartTLS),
expectedState = State(
security = ConnectionSecurity.StartTLS,
port = NumberInputField(value = ConnectionSecurity.StartTLS.toImapDefaultPort()),
),
coroutineScope = backgroundScope,
)
}
@Test
fun `should change state when PortChanged event is received`() = runTest {
eventStateTest(
viewModel = testSubject,
initialState = State(),
event = Event.PortChanged(456L),
expectedState = State(port = NumberInputField(value = 456L)),
coroutineScope = backgroundScope,
)
}
@Test
fun `should change state when UsernameChanged event is received`() = runTest {
eventStateTest(
viewModel = testSubject,
initialState = State(),
event = Event.UsernameChanged("username"),
expectedState = State(username = StringInputField(value = "username")),
coroutineScope = backgroundScope,
)
}
@Test
fun `should change state when PasswordChanged event is received`() = runTest {
eventStateTest(
viewModel = testSubject,
initialState = State(),
event = Event.PasswordChanged("password"),
expectedState = State(password = StringInputField(value = "password")),
coroutineScope = backgroundScope,
)
}
@Test
fun `should change state when ClientCertificateChanged event is received`() = runTest {
eventStateTest(
viewModel = testSubject,
initialState = State(),
event = Event.ClientCertificateChanged("clientCertificate"),
expectedState = State(clientCertificate = "clientCertificate"),
coroutineScope = backgroundScope,
)
}
@Test
fun `should change state when ImapAutoDetectNamespaceChanged event is received`() = runTest {
eventStateTest(
viewModel = testSubject,
initialState = State(imapAutodetectNamespaceEnabled = true),
event = Event.ImapAutoDetectNamespaceChanged(false),
expectedState = State(imapAutodetectNamespaceEnabled = false),
coroutineScope = backgroundScope,
)
}
@Test
fun `should change state when ImapPrefixChanged event is received`() = runTest {
eventStateTest(
viewModel = testSubject,
initialState = State(),
event = Event.ImapPrefixChanged("imapPrefix"),
expectedState = State(imapPrefix = StringInputField(value = "imapPrefix")),
coroutineScope = backgroundScope,
)
}
@Test
fun `should change state when UseCompressionChanged event is received`() = runTest {
eventStateTest(
viewModel = testSubject,
initialState = State(useCompression = true),
event = Event.UseCompressionChanged(false),
expectedState = State(useCompression = false),
coroutineScope = backgroundScope,
)
}
@Test
fun `should emit NavigateNext effect when OnNextClicked 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.OnNextClicked)
assertThatAndTurbinesConsumed(
actual = effectTurbine.awaitItem(),
turbines = turbines,
) {
isEqualTo(Effect.NavigateNext)
}
}
@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)
}
}
}

View file

@ -0,0 +1,26 @@
package app.k9mail.feature.account.setup.ui.incoming
import app.k9mail.core.ui.compose.common.mvi.BaseViewModel
import app.k9mail.feature.account.setup.ui.incoming.AccountIncomingConfigContract.Effect
import app.k9mail.feature.account.setup.ui.incoming.AccountIncomingConfigContract.Event
import app.k9mail.feature.account.setup.ui.incoming.AccountIncomingConfigContract.State
import app.k9mail.feature.account.setup.ui.incoming.AccountIncomingConfigContract.ViewModel
class FakeAccountIncomingConfigViewModel(
initialState: State = State(),
) : BaseViewModel<State, Event, Effect>(initialState), ViewModel {
val events = mutableListOf<Event>()
override fun initState(state: State) {
updateState { state }
}
override fun event(event: Event) {
events.add(event)
}
fun effect(effect: Effect) {
emitEffect(effect)
}
}

View file

@ -6,9 +6,11 @@ import app.k9mail.feature.account.setup.ui.outgoing.AccountOutgoingConfigContrac
import app.k9mail.feature.account.setup.ui.outgoing.AccountOutgoingConfigContract.State
import assertk.assertThat
import assertk.assertions.isEqualTo
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
import org.junit.Test
@OptIn(ExperimentalCoroutinesApi::class)
class AccountOutgoingConfigScreenKtTest : ComposeTest() {
@Test