Merge pull request #7353 from thunderbird/create_account_screen

Add "create account" screen
This commit is contained in:
Wolf-Martell Montwé 2023-11-16 10:56:15 +01:00 committed by GitHub
commit 3dc3b526ed
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
20 changed files with 584 additions and 57 deletions

View file

@ -13,6 +13,7 @@ import app.k9mail.feature.account.setup.ui.AccountSetupViewModel
import app.k9mail.feature.account.setup.ui.autodiscovery.AccountAutoDiscoveryContract
import app.k9mail.feature.account.setup.ui.autodiscovery.AccountAutoDiscoveryValidator
import app.k9mail.feature.account.setup.ui.autodiscovery.AccountAutoDiscoveryViewModel
import app.k9mail.feature.account.setup.ui.createaccount.CreateAccountViewModel
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
@ -57,7 +58,6 @@ val featureAccountSetupModule: Module = module {
viewModel {
AccountSetupViewModel(
createAccount = get(),
accountStateRepository = get(),
)
}
@ -76,4 +76,11 @@ val featureAccountSetupModule: Module = module {
accountStateRepository = get(),
)
}
viewModel {
CreateAccountViewModel(
createAccount = get(),
accountStateRepository = get(),
)
}
}

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.ValidationResult
import app.k9mail.feature.account.common.domain.entity.AccountOptions
import app.k9mail.feature.account.setup.AccountSetupExternalContract.AccountCreator.AccountCreatorResult
import com.fsck.k9.mail.ServerSettings
interface DomainContract {
@ -19,7 +20,7 @@ interface DomainContract {
outgoingServerSettings: ServerSettings,
authorizationState: String?,
options: AccountOptions,
): String
): AccountCreatorResult
}
fun interface ValidateEmailAddress {

View file

@ -0,0 +1,4 @@
package app.k9mail.feature.account.setup.domain.entity
@JvmInline
value class AccountUuid(val value: String)

View file

@ -18,7 +18,7 @@ class CreateAccount(
outgoingServerSettings: ServerSettings,
authorizationState: String?,
options: AccountOptions,
): String {
): AccountCreatorResult {
val account = Account(
uuid = uuidGenerator(),
emailAddress = emailAddress,
@ -28,9 +28,6 @@ class CreateAccount(
options = options,
)
return when (val result = accountCreator.createAccount(account)) {
is AccountCreatorResult.Success -> result.accountUuid
is AccountCreatorResult.Error -> "" // TODO change to meaningful error
}
return accountCreator.createAccount(account)
}
}

View file

@ -1,6 +1,7 @@
package app.k9mail.feature.account.setup.ui
import app.k9mail.core.ui.compose.common.mvi.UnidirectionalViewModel
import app.k9mail.feature.account.setup.domain.entity.AccountUuid
interface AccountSetupContract {
@ -11,6 +12,7 @@ interface AccountSetupContract {
OUTGOING_CONFIG,
OUTGOING_VALIDATION,
OPTIONS,
CREATE_ACCOUNT,
}
interface ViewModel : UnidirectionalViewModel<State, Event, Effect>
@ -27,6 +29,8 @@ interface AccountSetupContract {
object OnNext : Event
object OnBack : Event
data class OnAccountCreated(val accountUuid: AccountUuid) : Event
}
sealed interface Effect {

View file

@ -20,6 +20,9 @@ import app.k9mail.feature.account.setup.ui.AccountSetupContract.ViewModel
import app.k9mail.feature.account.setup.ui.autodiscovery.AccountAutoDiscoveryContract
import app.k9mail.feature.account.setup.ui.autodiscovery.AccountAutoDiscoveryScreen
import app.k9mail.feature.account.setup.ui.autodiscovery.AccountAutoDiscoveryViewModel
import app.k9mail.feature.account.setup.ui.createaccount.CreateAccountContract
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.AccountOptionsContract
import app.k9mail.feature.account.setup.ui.options.AccountOptionsScreen
import app.k9mail.feature.account.setup.ui.options.AccountOptionsViewModel
@ -39,6 +42,7 @@ fun AccountSetupScreen(
outgoingValidationViewModel: ServerValidationContract.ViewModel =
koinViewModel<OutgoingServerValidationViewModel>(),
optionsViewModel: AccountOptionsContract.ViewModel = koinViewModel<AccountOptionsViewModel>(),
createAccountViewModel: CreateAccountContract.ViewModel = koinViewModel<CreateAccountViewModel>(),
) {
val (state, dispatch) = viewModel.observe { effect ->
when (effect) {
@ -101,6 +105,14 @@ fun AccountSetupScreen(
viewModel = optionsViewModel,
)
}
SetupStep.CREATE_ACCOUNT -> {
CreateAccountScreen(
onNext = { accountUuid -> dispatch(Event.OnAccountCreated(accountUuid)) },
onBack = { dispatch(Event.OnBack) },
viewModel = createAccountViewModel,
)
}
}
}

View file

@ -1,19 +1,16 @@
package app.k9mail.feature.account.setup.ui
import androidx.lifecycle.viewModelScope
import app.k9mail.core.ui.compose.common.mvi.BaseViewModel
import app.k9mail.feature.account.common.domain.AccountDomainContract
import app.k9mail.feature.account.common.domain.entity.AuthorizationState
import app.k9mail.feature.account.setup.domain.DomainContract.UseCase
import app.k9mail.feature.account.setup.domain.entity.AccountUuid
import app.k9mail.feature.account.setup.ui.AccountSetupContract.Effect
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.State
import kotlinx.coroutines.launch
@Suppress("LongParameterList")
class AccountSetupViewModel(
private val createAccount: UseCase.CreateAccount,
private val accountStateRepository: AccountDomainContract.AccountStateRepository,
initialState: State = State(),
) : BaseViewModel<State, Event, Effect>(initialState), AccountSetupContract.ViewModel {
@ -24,6 +21,8 @@ class AccountSetupViewModel(
Event.OnBack -> onBack()
Event.OnNext -> onNext()
is Event.OnAccountCreated -> navigateNext(event.accountUuid)
}
}
@ -69,7 +68,11 @@ class AccountSetupViewModel(
changeToSetupStep(SetupStep.OPTIONS)
}
SetupStep.OPTIONS -> onFinish()
SetupStep.OPTIONS -> {
changeToSetupStep(SetupStep.CREATE_ACCOUNT)
}
SetupStep.CREATE_ACCOUNT -> Unit
}
}
@ -99,6 +102,8 @@ class AccountSetupViewModel(
} else {
changeToSetupStep(SetupStep.OUTGOING_CONFIG)
}
SetupStep.CREATE_ACCOUNT -> changeToSetupStep(SetupStep.OPTIONS)
}
}
@ -114,23 +119,7 @@ class AccountSetupViewModel(
}
}
private fun onFinish() {
val accountState = accountStateRepository.getState()
viewModelScope.launch {
val result = createAccount.execute(
emailAddress = accountState.emailAddress ?: "",
incomingServerSettings = accountState.incomingServerSettings!!,
outgoingServerSettings = accountState.outgoingServerSettings!!,
authorizationState = accountState.authorizationState?.state,
options = accountState.options!!,
)
navigateNext(result)
}
}
private fun navigateNext(accountUuid: String) = emitEffect(Effect.NavigateNext(accountUuid))
private fun navigateNext(accountUuid: AccountUuid) = emitEffect(Effect.NavigateNext(accountUuid.value))
private fun navigateBack() = emitEffect(Effect.NavigateBack)
}

View file

@ -0,0 +1,50 @@
package app.k9mail.feature.account.setup.ui.createaccount
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import app.k9mail.core.ui.compose.designsystem.molecule.ContentLoadingErrorView
import app.k9mail.core.ui.compose.designsystem.molecule.ErrorView
import app.k9mail.core.ui.compose.designsystem.molecule.LoadingView
import app.k9mail.core.ui.compose.designsystem.template.ResponsiveWidthContainer
import app.k9mail.feature.account.common.ui.loadingerror.rememberContentLoadingErrorViewState
import app.k9mail.feature.account.common.ui.view.SuccessView
import app.k9mail.feature.account.setup.R
@Composable
internal fun CreateAccountContent(
state: CreateAccountContract.State,
contentPadding: PaddingValues,
modifier: Modifier = Modifier,
) {
ResponsiveWidthContainer(
modifier = Modifier
.padding(contentPadding)
.testTag("CreateAccountContent")
.then(modifier),
) {
ContentLoadingErrorView(
state = rememberContentLoadingErrorViewState(state),
loading = {
LoadingView(
message = stringResource(R.string.account_setup_create_account_creating),
)
},
error = {
ErrorView(
title = stringResource(R.string.account_setup_create_account_error),
)
},
content = {
SuccessView(
message = stringResource(R.string.account_setup_create_account_created),
)
},
modifier = Modifier.fillMaxSize(),
)
}
}

View file

@ -0,0 +1,26 @@
package app.k9mail.feature.account.setup.ui.createaccount
import app.k9mail.core.ui.compose.common.mvi.UnidirectionalViewModel
import app.k9mail.feature.account.common.ui.loadingerror.LoadingErrorState
import app.k9mail.feature.account.setup.AccountSetupExternalContract.AccountCreator.AccountCreatorResult.Error
import app.k9mail.feature.account.setup.domain.entity.AccountUuid
interface CreateAccountContract {
interface ViewModel : UnidirectionalViewModel<State, Event, Effect>
data class State(
override val isLoading: Boolean = true,
override val error: Error? = null,
) : LoadingErrorState<Error>
sealed interface Event {
data object CreateAccount : Event
data object OnBackClicked : Event
}
sealed interface Effect {
data class NavigateNext(val accountUuid: AccountUuid) : Effect
data object NavigateBack : Effect
}
}

View file

@ -0,0 +1,81 @@
package app.k9mail.feature.account.setup.ui.createaccount
import androidx.activity.compose.BackHandler
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Modifier
import app.k9mail.core.ui.compose.common.PreviewDevices
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.feature.account.common.data.InMemoryAccountStateRepository
import app.k9mail.feature.account.common.ui.AppTitleTopHeader
import app.k9mail.feature.account.common.ui.WizardNavigationBar
import app.k9mail.feature.account.common.ui.WizardNavigationBarState
import app.k9mail.feature.account.setup.AccountSetupExternalContract.AccountCreator.AccountCreatorResult
import app.k9mail.feature.account.setup.domain.entity.AccountUuid
import app.k9mail.feature.account.setup.ui.createaccount.CreateAccountContract.Effect
import app.k9mail.feature.account.setup.ui.createaccount.CreateAccountContract.Event
import app.k9mail.feature.account.setup.ui.createaccount.CreateAccountContract.ViewModel
@Composable
internal fun CreateAccountScreen(
onNext: (AccountUuid) -> Unit,
onBack: () -> Unit,
viewModel: ViewModel,
modifier: Modifier = Modifier,
) {
val (state, dispatch) = viewModel.observe { effect ->
when (effect) {
Effect.NavigateBack -> onBack()
is Effect.NavigateNext -> onNext(effect.accountUuid)
}
}
LaunchedEffect(key1 = Unit) {
dispatch(Event.CreateAccount)
}
BackHandler {
dispatch(Event.OnBackClicked)
}
Scaffold(
topBar = {
AppTitleTopHeader()
},
bottomBar = {
WizardNavigationBar(
onNextClick = {},
onBackClick = {
dispatch(Event.OnBackClicked)
},
state = WizardNavigationBarState(
showNext = false,
isBackEnabled = state.value.error != null,
),
)
},
modifier = modifier,
) { innerPadding ->
CreateAccountContent(
state = state.value,
contentPadding = innerPadding,
)
}
}
@Composable
@PreviewDevices
internal fun AccountOptionsScreenK9Preview() {
K9Theme {
CreateAccountScreen(
onNext = {},
onBack = {},
viewModel = CreateAccountViewModel(
createAccount = { _, _, _, _, _ -> AccountCreatorResult.Success("irrelevant") },
accountStateRepository = InMemoryAccountStateRepository(),
),
)
}
}

View file

@ -0,0 +1,89 @@
package app.k9mail.feature.account.setup.ui.createaccount
import androidx.lifecycle.viewModelScope
import app.k9mail.core.ui.compose.common.mvi.BaseViewModel
import app.k9mail.feature.account.common.domain.AccountDomainContract.AccountStateRepository
import app.k9mail.feature.account.setup.AccountSetupExternalContract.AccountCreator.AccountCreatorResult
import app.k9mail.feature.account.setup.domain.DomainContract.UseCase.CreateAccount
import app.k9mail.feature.account.setup.domain.entity.AccountUuid
import app.k9mail.feature.account.setup.ui.createaccount.CreateAccountContract.Effect
import app.k9mail.feature.account.setup.ui.createaccount.CreateAccountContract.Event
import app.k9mail.feature.account.setup.ui.createaccount.CreateAccountContract.State
import kotlinx.coroutines.cancelChildren
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
private const val CONTINUE_NEXT_DELAY = 2000L
class CreateAccountViewModel(
private val createAccount: CreateAccount,
private val accountStateRepository: AccountStateRepository,
initialState: State = State(),
) : BaseViewModel<State, Event, Effect>(initialState),
CreateAccountContract.ViewModel {
override fun event(event: Event) {
when (event) {
Event.CreateAccount -> handleOneTimeEvent(event, ::createAccount)
Event.OnBackClicked -> maybeNavigateBack()
}
}
private fun createAccount() {
val accountState = accountStateRepository.getState()
viewModelScope.launch {
val result = createAccount.execute(
emailAddress = accountState.emailAddress ?: "",
incomingServerSettings = accountState.incomingServerSettings!!,
outgoingServerSettings = accountState.outgoingServerSettings!!,
authorizationState = accountState.authorizationState?.state,
options = accountState.options!!,
)
when (result) {
is AccountCreatorResult.Success -> showSuccess(AccountUuid(result.accountUuid))
is AccountCreatorResult.Error -> showError(result)
}
}
}
private fun showSuccess(accountUuid: AccountUuid) {
updateState {
it.copy(
isLoading = false,
error = null,
)
}
viewModelScope.launch {
delay(CONTINUE_NEXT_DELAY)
navigateNext(accountUuid)
}
}
private fun showError(error: AccountCreatorResult.Error) {
updateState {
it.copy(
isLoading = false,
error = error,
)
}
}
private fun maybeNavigateBack() {
if (!state.value.isLoading) {
navigateBack()
}
}
private fun navigateBack() {
viewModelScope.coroutineContext.cancelChildren()
emitEffect(Effect.NavigateBack)
}
private fun navigateNext(accountUuid: AccountUuid) {
viewModelScope.coroutineContext.cancelChildren()
emitEffect(Effect.NavigateNext(accountUuid))
}
}

View file

@ -38,4 +38,8 @@
<string name="account_setup_options_email_check_frequency_never">Never</string>
<string name="account_setup_options_email_display_count_label">Number of messages to display</string>
<string name="account_setup_options_show_notifications_label">Show notifications</string>
<string name="account_setup_create_account_creating">Creating account…</string>
<string name="account_setup_create_account_error">An error occurred while trying to create the account</string>
<string name="account_setup_create_account_created">Account successfully created</string>
</resources>

View file

@ -14,6 +14,7 @@ import app.k9mail.feature.account.setup.AccountSetupExternalContract.AccountCrea
import app.k9mail.feature.account.setup.AccountSetupExternalContract.AccountCreator.AccountCreatorResult
import app.k9mail.feature.account.setup.ui.AccountSetupContract
import app.k9mail.feature.account.setup.ui.autodiscovery.AccountAutoDiscoveryContract
import app.k9mail.feature.account.setup.ui.createaccount.CreateAccountContract
import app.k9mail.feature.account.setup.ui.options.AccountOptionsContract
import com.fsck.k9.mail.oauth.AuthStateStorage
import com.fsck.k9.mail.oauth.OAuth2TokenProvider
@ -79,6 +80,7 @@ class AccountSetupModuleKtTest : KoinTest {
Boolean::class,
Class.forName("net.openid.appauth.AppAuthConfiguration").kotlin,
InteractionMode::class,
CreateAccountContract.State::class,
),
)

View file

@ -63,7 +63,7 @@ class CreateAccountTest {
options,
)
assertThat(result).isEqualTo("uuid")
assertThat(result).isEqualTo(AccountCreatorResult.Success("uuid"))
assertThat(recordedAccount).isEqualTo(
Account(
uuid = "uuid",

View file

@ -11,6 +11,7 @@ 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.autodiscovery.FakeAccountAutoDiscoveryViewModel
import app.k9mail.feature.account.setup.ui.createaccount.FakeCreateAccountViewModel
import app.k9mail.feature.account.setup.ui.options.FakeAccountOptionsViewModel
import assertk.assertThat
import assertk.assertions.isEqualTo
@ -35,6 +36,7 @@ class AccountSetupScreenKtTest : ComposeTest() {
outgoingViewModel = FakeOutgoingServerSettingsViewModel(),
outgoingValidationViewModel = FakeServerValidationViewModel(),
optionsViewModel = FakeAccountOptionsViewModel(),
createAccountViewModel = FakeCreateAccountViewModel(),
)
}
}
@ -64,6 +66,7 @@ class AccountSetupScreenKtTest : ComposeTest() {
outgoingViewModel = FakeOutgoingServerSettingsViewModel(),
outgoingValidationViewModel = FakeServerValidationViewModel(),
optionsViewModel = FakeAccountOptionsViewModel(),
createAccountViewModel = FakeCreateAccountViewModel(),
)
}
}
@ -89,5 +92,6 @@ class AccountSetupScreenKtTest : ComposeTest() {
SetupStep.OUTGOING_CONFIG -> "OutgoingServerSettingsContent"
SetupStep.OUTGOING_VALIDATION -> "AccountValidationContent"
SetupStep.OPTIONS -> "AccountOptionsContent"
SetupStep.CREATE_ACCOUNT -> "CreateAccountContent"
}
}

View file

@ -7,12 +7,11 @@ import app.k9mail.feature.account.common.data.InMemoryAccountStateRepository
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.MailConnectionSecurity
import app.k9mail.feature.account.setup.domain.entity.AccountUuid
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 assertk.assertThat
import assertk.assertions.isEqualTo
import assertk.assertions.isNull
import assertk.assertions.prop
import com.fsck.k9.mail.AuthType
import com.fsck.k9.mail.ServerSettings
@ -28,22 +27,8 @@ class AccountSetupViewModelTest {
@Test
fun `should forward step state on next event`() = runTest {
var createAccountEmailAddress: String? = null
var createAccountIncomingServerSettings: ServerSettings? = null
var createAccountOutgoingServerSettings: ServerSettings? = null
var createAccountAuthorizationState: String? = null
var createAccountOptions: AccountOptions? = null
val accountStateRepository = InMemoryAccountStateRepository()
val viewModel = AccountSetupViewModel(
createAccount = { emailAddress, incomingServerSettings, outgoingServerSettings, authState, options ->
createAccountEmailAddress = emailAddress
createAccountIncomingServerSettings = incomingServerSettings
createAccountOutgoingServerSettings = outgoingServerSettings
createAccountAuthorizationState = authState
createAccountOptions = options
"accountUuid"
},
accountStateRepository = accountStateRepository,
)
val turbines = turbinesWithInitialStateCheck(viewModel, State(setupStep = SetupStep.AUTO_CONFIG))
@ -132,9 +117,18 @@ class AccountSetupViewModelTest {
prop(State::setupStep).isEqualTo(SetupStep.OPTIONS)
}
viewModel.event(AccountSetupContract.Event.OnNext)
assertThatAndMviTurbinesConsumed(
actual = turbines.stateTurbine.awaitItem(),
turbines = turbines,
) {
prop(State::setupStep).isEqualTo(SetupStep.CREATE_ACCOUNT)
}
accountStateRepository.setState(expectedAccountState)
viewModel.event(AccountSetupContract.Event.OnNext)
viewModel.event(AccountSetupContract.Event.OnAccountCreated(AccountUuid("accountUuid")))
assertThatAndMviTurbinesConsumed(
actual = turbines.effectTurbine.awaitItem(),
@ -142,19 +136,12 @@ class AccountSetupViewModelTest {
) {
isEqualTo(Effect.NavigateNext("accountUuid"))
}
assertThat(createAccountEmailAddress).isEqualTo(EMAIL_ADDRESS)
assertThat(createAccountIncomingServerSettings).isEqualTo(expectedAccountState.incomingServerSettings)
assertThat(createAccountOutgoingServerSettings).isEqualTo(expectedAccountState.outgoingServerSettings)
assertThat(createAccountAuthorizationState).isNull()
assertThat(createAccountOptions).isEqualTo(expectedAccountState.options)
}
@Test
fun `should rewind step state on back event`() = runTest {
val initialState = State(setupStep = SetupStep.OPTIONS)
val viewModel = AccountSetupViewModel(
createAccount = { _, _, _, _, _ -> "accountUuid" },
accountStateRepository = InMemoryAccountStateRepository(),
initialState = initialState,
)
@ -204,7 +191,6 @@ class AccountSetupViewModelTest {
isAutomaticConfig = true,
)
val viewModel = AccountSetupViewModel(
createAccount = { _, _, _, _, _ -> "accountUuid" },
accountStateRepository = InMemoryAccountStateRepository(),
initialState = initialState,
)
@ -236,7 +222,6 @@ class AccountSetupViewModelTest {
isAutomaticConfig = true,
)
val viewModel = AccountSetupViewModel(
createAccount = { _, _, _, _, _ -> "accountUuid" },
accountStateRepository = InMemoryAccountStateRepository(),
initialState = initialState,
)
@ -268,7 +253,6 @@ class AccountSetupViewModelTest {
isAutomaticConfig = true,
)
val viewModel = AccountSetupViewModel(
createAccount = { _, _, _, _, _ -> "accountUuid" },
accountStateRepository = InMemoryAccountStateRepository(),
initialState = initialState,
)

View file

@ -0,0 +1,52 @@
package app.k9mail.feature.account.setup.ui.createaccount
import app.k9mail.core.ui.compose.testing.ComposeTest
import app.k9mail.core.ui.compose.testing.setContent
import app.k9mail.core.ui.compose.theme.K9Theme
import app.k9mail.feature.account.setup.domain.entity.AccountUuid
import app.k9mail.feature.account.setup.ui.createaccount.CreateAccountContract.Effect
import app.k9mail.feature.account.setup.ui.createaccount.CreateAccountContract.State
import assertk.assertThat
import assertk.assertions.containsExactly
import assertk.assertions.isEmpty
import assertk.assertions.isEqualTo
import kotlin.test.Test
import kotlinx.coroutines.test.runTest
class CreateAccountScreenTest : ComposeTest() {
@Test
fun `should delegate navigation effects`() = runTest {
val accountUuid = AccountUuid("irrelevant")
val initialState = State(
isLoading = false,
error = null,
)
val viewModel = FakeCreateAccountViewModel(initialState)
val navigateNextArguments = mutableListOf<AccountUuid>()
var navigateBackCounter = 0
setContent {
K9Theme {
CreateAccountScreen(
onNext = { accountUuid -> navigateNextArguments.add(accountUuid) },
onBack = { navigateBackCounter++ },
viewModel = viewModel,
)
}
}
assertThat(navigateNextArguments).isEmpty()
assertThat(navigateBackCounter).isEqualTo(0)
viewModel.effect(Effect.NavigateNext(accountUuid))
assertThat(navigateNextArguments).containsExactly(accountUuid)
assertThat(navigateBackCounter).isEqualTo(0)
viewModel.effect(Effect.NavigateBack)
assertThat(navigateNextArguments).containsExactly(accountUuid)
assertThat(navigateBackCounter).isEqualTo(1)
}
}

View file

@ -0,0 +1,160 @@
package app.k9mail.feature.account.setup.ui.createaccount
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.AccountOptions
import app.k9mail.feature.account.common.domain.entity.AccountState
import app.k9mail.feature.account.common.domain.entity.AuthorizationState
import app.k9mail.feature.account.setup.AccountSetupExternalContract.AccountCreator.AccountCreatorResult
import app.k9mail.feature.account.setup.domain.entity.AccountUuid
import app.k9mail.feature.account.setup.ui.createaccount.CreateAccountContract.Effect
import app.k9mail.feature.account.setup.ui.createaccount.CreateAccountContract.Event
import app.k9mail.feature.account.setup.ui.createaccount.CreateAccountContract.State
import assertk.assertThat
import assertk.assertions.containsExactly
import assertk.assertions.isEqualTo
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.test.runTest
import org.junit.Rule
class CreateAccountViewModelTest {
@get:Rule
val mainDispatcherRule = MainDispatcherRule()
private val fakeCreateAccount = FakeCreateAccount()
private val accountStateRepository = InMemoryAccountStateRepository().apply {
setState(ACCOUNT_STATE)
}
private val createAccountViewModel = CreateAccountViewModel(
createAccount = fakeCreateAccount,
accountStateRepository = accountStateRepository,
)
@Test
fun `initial state should be loading state`() {
assertThat(createAccountViewModel.state.value).isEqualTo(State(isLoading = true, error = null))
}
@Test
fun `should change state and emit navigate effect after successfully creating account`() = runTest {
val accountUuid = "accountUuid"
fakeCreateAccount.result = AccountCreatorResult.Success(accountUuid)
eventStateTest(
viewModel = createAccountViewModel,
initialState = State(isLoading = true, error = null),
event = Event.CreateAccount,
expectedState = State(isLoading = false, error = null),
coroutineScope = backgroundScope,
)
assertThat(fakeCreateAccount.recordedInvocations).containsExactly(
CreateAccountArguments(
emailAddress = EMAIL_ADDRESS,
incomingServerSettings = INCOMING_SERVER_SETTINGS,
outgoingServerSettings = OUTGOING_SERVER_SETTINGS,
authorizationState = AUTHORIZATION_STATE.state,
options = ACCOUNT_OPTIONS,
),
)
val effectTurbine = createAccountViewModel.effect.testIn(backgroundScope)
assertThat(effectTurbine.awaitItem()).isEqualTo(Effect.NavigateNext(AccountUuid(accountUuid)))
}
@Test
fun `should change state when creating account has failed`() = runTest {
val errorResult = AccountCreatorResult.Error("something went wrong")
fakeCreateAccount.result = errorResult
eventStateTest(
viewModel = createAccountViewModel,
initialState = State(isLoading = true, error = null),
event = Event.CreateAccount,
expectedState = State(isLoading = false, error = errorResult),
coroutineScope = backgroundScope,
)
}
@Test
fun `should ignore OnBackClicked event when in loading state`() = runTest {
val effectTurbine = createAccountViewModel.effect.testIn(scope = backgroundScope)
createAccountViewModel.event(Event.OnBackClicked)
effectTurbine.ensureAllEventsConsumed()
}
@Test
fun `should emit NavigateBack effect when OnBackClicked event was received while in success state`() = runTest {
fakeCreateAccount.result = AccountCreatorResult.Success("accountUuid")
createAccountViewModel.event(Event.CreateAccount)
val effectTurbine = createAccountViewModel.effect.testIn(backgroundScope)
createAccountViewModel.event(Event.OnBackClicked)
assertThat(effectTurbine.awaitItem()).isEqualTo(Effect.NavigateBack)
}
@Test
fun `should emit NavigateBack effect when OnBackClicked event was received while in error state`() = runTest {
fakeCreateAccount.result = AccountCreatorResult.Error("something went wrong")
createAccountViewModel.event(Event.CreateAccount)
val effectTurbine = createAccountViewModel.effect.testIn(backgroundScope)
createAccountViewModel.event(Event.OnBackClicked)
assertThat(effectTurbine.awaitItem()).isEqualTo(Effect.NavigateBack)
}
companion object {
private const val EMAIL_ADDRESS = "test@domain.example"
private val INCOMING_SERVER_SETTINGS = ServerSettings(
"imap",
"imap.domain.example",
993,
ConnectionSecurity.SSL_TLS_REQUIRED,
AuthType.PLAIN,
"username",
"password",
null,
)
private val OUTGOING_SERVER_SETTINGS = ServerSettings(
"smtp",
"smtp.domain.example",
465,
ConnectionSecurity.SSL_TLS_REQUIRED,
AuthType.PLAIN,
"username",
"password",
null,
)
private val AUTHORIZATION_STATE = AuthorizationState("authorization state")
private val ACCOUNT_OPTIONS = AccountOptions(
accountName = "account name",
displayName = "display name",
emailSignature = null,
checkFrequencyInMinutes = 0,
messageDisplayCount = 50,
showNotification = false,
)
private val ACCOUNT_STATE = AccountState(
emailAddress = EMAIL_ADDRESS,
incomingServerSettings = INCOMING_SERVER_SETTINGS,
outgoingServerSettings = OUTGOING_SERVER_SETTINGS,
authorizationState = AUTHORIZATION_STATE,
options = ACCOUNT_OPTIONS,
)
}
}

View file

@ -0,0 +1,40 @@
package app.k9mail.feature.account.setup.ui.createaccount
import app.k9mail.feature.account.common.domain.entity.AccountOptions
import app.k9mail.feature.account.setup.AccountSetupExternalContract.AccountCreator.AccountCreatorResult
import app.k9mail.feature.account.setup.domain.DomainContract.UseCase.CreateAccount
import com.fsck.k9.mail.ServerSettings
class FakeCreateAccount : CreateAccount {
val recordedInvocations = mutableListOf<CreateAccountArguments>()
var result: AccountCreatorResult = AccountCreatorResult.Success("default result")
override suspend fun execute(
emailAddress: String,
incomingServerSettings: ServerSettings,
outgoingServerSettings: ServerSettings,
authorizationState: String?,
options: AccountOptions,
): AccountCreatorResult {
recordedInvocations.add(
CreateAccountArguments(
emailAddress,
incomingServerSettings,
outgoingServerSettings,
authorizationState,
options,
),
)
return result
}
}
data class CreateAccountArguments(
val emailAddress: String,
val incomingServerSettings: ServerSettings,
val outgoingServerSettings: ServerSettings,
val authorizationState: String?,
val options: AccountOptions,
)

View file

@ -0,0 +1,21 @@
package app.k9mail.feature.account.setup.ui.createaccount
import app.k9mail.core.ui.compose.common.mvi.BaseViewModel
import app.k9mail.feature.account.setup.ui.createaccount.CreateAccountContract.Effect
import app.k9mail.feature.account.setup.ui.createaccount.CreateAccountContract.Event
import app.k9mail.feature.account.setup.ui.createaccount.CreateAccountContract.State
import app.k9mail.feature.account.setup.ui.createaccount.CreateAccountContract.ViewModel
class FakeCreateAccountViewModel(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)
}
}