Add CreateAccountScreen

This commit is contained in:
cketti 2023-11-14 13:38:23 +01:00
parent 8f06c24bd3
commit 849f150138
15 changed files with 544 additions and 13 deletions

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

@ -4,6 +4,7 @@ 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.AccountSetupExternalContract.AccountCreator.AccountCreatorResult
import app.k9mail.feature.account.setup.domain.DomainContract.UseCase
import app.k9mail.feature.account.setup.ui.AccountSetupContract.Effect
import app.k9mail.feature.account.setup.ui.AccountSetupContract.Event
@ -126,7 +127,11 @@ class AccountSetupViewModel(
options = accountState.options!!,
)
navigateNext(result)
if (result is AccountCreatorResult.Success) {
navigateNext(result.accountUuid)
} else {
error("Creating account failed")
}
}
}

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

@ -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

@ -7,6 +7,7 @@ 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.AccountSetupExternalContract.AccountCreator.AccountCreatorResult
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
@ -42,7 +43,7 @@ class AccountSetupViewModelTest {
createAccountAuthorizationState = authState
createAccountOptions = options
"accountUuid"
AccountCreatorResult.Success("accountUuid")
},
accountStateRepository = accountStateRepository,
)
@ -154,7 +155,7 @@ class AccountSetupViewModelTest {
fun `should rewind step state on back event`() = runTest {
val initialState = State(setupStep = SetupStep.OPTIONS)
val viewModel = AccountSetupViewModel(
createAccount = { _, _, _, _, _ -> "accountUuid" },
createAccount = { _, _, _, _, _ -> AccountCreatorResult.Success("accountUuid") },
accountStateRepository = InMemoryAccountStateRepository(),
initialState = initialState,
)
@ -204,7 +205,7 @@ class AccountSetupViewModelTest {
isAutomaticConfig = true,
)
val viewModel = AccountSetupViewModel(
createAccount = { _, _, _, _, _ -> "accountUuid" },
createAccount = { _, _, _, _, _ -> AccountCreatorResult.Success("accountUuid") },
accountStateRepository = InMemoryAccountStateRepository(),
initialState = initialState,
)
@ -236,7 +237,7 @@ class AccountSetupViewModelTest {
isAutomaticConfig = true,
)
val viewModel = AccountSetupViewModel(
createAccount = { _, _, _, _, _ -> "accountUuid" },
createAccount = { _, _, _, _, _ -> AccountCreatorResult.Success("accountUuid") },
accountStateRepository = InMemoryAccountStateRepository(),
initialState = initialState,
)
@ -268,7 +269,7 @@ class AccountSetupViewModelTest {
isAutomaticConfig = true,
)
val viewModel = AccountSetupViewModel(
createAccount = { _, _, _, _, _ -> "accountUuid" },
createAccount = { _, _, _, _, _ -> AccountCreatorResult.Success("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) :
BaseViewModel<State, Event, Effect>(initialState), ViewModel {
val events = mutableListOf<Event>()
override fun event(event: Event) {
events.add(event)
}
fun effect(effect: Effect) {
emitEffect(effect)
}
}