0
0
mirror of https://github.com/thunderbird/thunderbird-android.git synced 2024-09-20 04:02:14 +02:00

Change AccountSetup to use a contract that follows the unidirectional data flow

This commit is contained in:
Wolf-Martell Montwé 2023-05-11 12:35:29 +02:00
parent 7a12b162b1
commit c7112297b8
No known key found for this signature in database
GPG Key ID: 6D45B21512ACBF72
14 changed files with 362 additions and 44 deletions

View File

@ -25,8 +25,8 @@ fun FeatureNavHost(
onImportClick = { /* TODO */ },
)
accountSetupScreen(
onBackClick = navController::popBackStack,
onFinishClick = { /* TODO */ },
onBack = navController::popBackStack,
onFinish = { /* TODO */ },
)
}
}

View File

@ -9,4 +9,6 @@ android {
dependencies {
implementation(projects.core.ui.compose.designsystem)
testImplementation(projects.core.ui.compose.testing)
}

View File

@ -13,13 +13,13 @@ fun NavController.navigateToAccountSetup(navOptions: NavOptions? = null) {
}
fun NavGraphBuilder.accountSetupScreen(
onBackClick: () -> Unit,
onFinishClick: () -> Unit,
onBack: () -> Unit,
onFinish: () -> Unit,
) {
composable(route = NAVIGATION_ROUTE_ACCOUNT_SETUP) {
AccountSetupScreen(
onBackClick = onBackClick,
onFinishClick = onFinishClick,
onBack = onBack,
onFinish = onFinish,
)
}
}

View File

@ -0,0 +1,28 @@
package app.k9mail.feature.account.setup.ui
import app.k9mail.core.ui.compose.common.mvi.UnidirectionalViewModel
interface AccountSetupContract {
enum class SetupStep {
AUTO_CONFIG,
MANUAL_CONFIG,
OPTIONS,
}
interface ViewModel : UnidirectionalViewModel<State, Event, Effect>
data class State(
val setupStep: SetupStep = SetupStep.AUTO_CONFIG,
)
sealed interface Event {
object OnNext : Event
object OnBack : Event
}
sealed interface Effect {
object NavigateNext : Effect
object NavigateBack : Effect
}
}

View File

@ -1,65 +1,59 @@
package app.k9mail.feature.account.setup.ui
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.tooling.preview.Preview
import app.k9mail.core.ui.compose.common.mvi.observe
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.ViewModel
import app.k9mail.feature.account.setup.ui.autoconfig.AccountAutoConfigScreen
import app.k9mail.feature.account.setup.ui.manualconfig.AccountManualConfigScreen
import app.k9mail.feature.account.setup.ui.options.AccountOptionsScreen
import org.koin.androidx.compose.koinViewModel
@Composable
fun AccountSetupScreen(
onFinishClick: () -> Unit,
onBackClick: () -> Unit,
onFinish: () -> Unit,
onBack: () -> Unit,
viewModel: ViewModel = koinViewModel<AccountSetupViewModel>(),
) {
val accountSetupSteps = remember { mutableStateOf(AccountSetupSteps.AUTO_CONFIG) }
val (state, dispatch) = viewModel.observe { effect ->
when (effect) {
Effect.NavigateBack -> onBack()
Effect.NavigateNext -> onFinish()
}
}
when (accountSetupSteps.value) {
AccountSetupSteps.AUTO_CONFIG -> {
when (state.value.setupStep) {
SetupStep.AUTO_CONFIG -> {
AccountAutoConfigScreen(
onNextClick = {
// TODO validate config
accountSetupSteps.value = AccountSetupSteps.MANUAL_CONFIG
},
onBackClick = onBackClick,
onNextClick = { dispatch(Event.OnNext) },
onBackClick = { dispatch(Event.OnBack) },
)
}
AccountSetupSteps.MANUAL_CONFIG -> {
SetupStep.MANUAL_CONFIG -> {
AccountManualConfigScreen(
onNextClick = {
accountSetupSteps.value = AccountSetupSteps.OPTIONS
},
onBackClick = {
accountSetupSteps.value = AccountSetupSteps.AUTO_CONFIG
},
onNextClick = { dispatch(Event.OnNext) },
onBackClick = { dispatch(Event.OnBack) },
)
}
AccountSetupSteps.OPTIONS -> {
SetupStep.OPTIONS -> {
AccountOptionsScreen(
// validate account
onFinishClick = onFinishClick,
onBackClick = {
accountSetupSteps.value = AccountSetupSteps.MANUAL_CONFIG
},
onFinishClick = { dispatch(Event.OnNext) },
onBackClick = { dispatch(Event.OnBack) },
)
}
}
}
enum class AccountSetupSteps {
AUTO_CONFIG,
MANUAL_CONFIG,
OPTIONS,
}
@Preview(showBackground = true)
@Composable
internal fun AccountSetupScreenPreview() {
AccountSetupScreen(
onFinishClick = {},
onBackClick = {},
onFinish = {},
onBack = {},
)
}

View File

@ -1,5 +1,52 @@
package app.k9mail.feature.account.setup.ui
import androidx.lifecycle.ViewModel
import app.k9mail.core.ui.compose.common.mvi.BaseViewModel
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 app.k9mail.feature.account.setup.ui.AccountSetupContract.ViewModel
class AccountSetupViewModel : ViewModel()
class AccountSetupViewModel(
initialState: State = State(),
) : BaseViewModel<State, Event, Effect>(initialState), ViewModel {
override fun event(event: Event) {
when (event) {
Event.OnBack -> onBack()
Event.OnNext -> onNext()
}
}
private fun onBack() {
when (state.value.setupStep) {
SetupStep.AUTO_CONFIG -> navigateBack()
SetupStep.MANUAL_CONFIG -> changeToSetupStep(SetupStep.AUTO_CONFIG)
SetupStep.OPTIONS -> changeToSetupStep(SetupStep.MANUAL_CONFIG)
}
}
private fun onNext() {
when (state.value.setupStep) {
SetupStep.AUTO_CONFIG -> changeToSetupStep(SetupStep.MANUAL_CONFIG)
SetupStep.MANUAL_CONFIG -> changeToSetupStep(SetupStep.OPTIONS)
SetupStep.OPTIONS -> navigateNext()
}
}
private fun changeToSetupStep(setupStep: SetupStep) {
updateState {
it.copy(
setupStep = setupStep,
)
}
}
private fun navigateNext() {
// TODO: validate account
emitEffect(Effect.NavigateNext)
}
private fun navigateBack() = emitEffect(Effect.NavigateBack)
}

View File

@ -12,6 +12,7 @@ import androidx.compose.foundation.layout.requiredHeight
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
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.button.Button
@ -32,7 +33,9 @@ internal fun AccountAutoConfigContent(
modifier: Modifier = Modifier,
) {
ResponsiveContentWithBackground(
modifier = modifier,
modifier = Modifier
.testTag("AccountAutoConfigContent")
.then(modifier),
) {
LazyColumnWithHeaderFooter(
modifier = Modifier.fillMaxSize(),

View File

@ -8,6 +8,7 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
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.button.Button
@ -27,7 +28,9 @@ internal fun AccountManualConfigContent(
modifier: Modifier = Modifier,
) {
ResponsiveContentWithBackground(
modifier = modifier,
modifier = Modifier
.testTag("AccountManualConfigContent")
.then(modifier),
) {
LazyColumnWithHeaderFooter(
modifier = Modifier.fillMaxSize(),

View File

@ -8,6 +8,7 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
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.button.Button
@ -27,7 +28,9 @@ internal fun AccountOptionsContent(
modifier: Modifier = Modifier,
) {
ResponsiveContentWithBackground(
modifier = modifier,
modifier = Modifier
.testTag("AccountOptionsContent")
.then(modifier),
) {
LazyColumnWithHeaderFooter(
modifier = Modifier.fillMaxSize(),

View File

@ -0,0 +1,69 @@
package app.k9mail.feature.account.setup.ui
import app.k9mail.core.ui.compose.testing.ComposeTest
import app.k9mail.core.ui.compose.testing.onNodeWithTag
import app.k9mail.core.ui.compose.testing.setContent
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 kotlinx.coroutines.flow.update
import kotlinx.coroutines.test.runTest
import org.junit.Test
class AccountSetupScreenKtTest : ComposeTest() {
@Test
fun `should display correct screen for every setup step`() = runTest {
val viewModel = FakeAccountSetupViewModel()
setContent {
AccountSetupScreen(
onFinish = { },
onBack = { },
viewModel = viewModel,
)
}
for (step in SetupStep.values()) {
viewModel.mutableState.update { it.copy(setupStep = step) }
onNodeWithTag(getTagForStep(step)).assertExists()
}
}
@Test
fun `should delegate navigation effects`() = runTest {
val initialState = State()
val viewModel = FakeAccountSetupViewModel(initialState)
var onFinishCounter = 0
var onBackCounter = 0
setContent {
AccountSetupScreen(
onFinish = { onFinishCounter++ },
onBack = { onBackCounter++ },
viewModel = viewModel,
)
}
assertThat(onFinishCounter).isEqualTo(0)
assertThat(onBackCounter).isEqualTo(0)
viewModel.mutableEffect.emit(Effect.NavigateNext)
assertThat(onFinishCounter).isEqualTo(1)
assertThat(onBackCounter).isEqualTo(0)
viewModel.mutableEffect.emit(Effect.NavigateBack)
assertThat(onFinishCounter).isEqualTo(1)
assertThat(onBackCounter).isEqualTo(1)
}
private fun getTagForStep(step: SetupStep): String = when (step) {
SetupStep.AUTO_CONFIG -> "AccountAutoConfigContent"
SetupStep.MANUAL_CONFIG -> "AccountManualConfigContent"
SetupStep.OPTIONS -> "AccountOptionsContent"
}
}

View File

@ -0,0 +1,20 @@
package app.k9mail.feature.account.setup.ui
import app.k9mail.feature.account.setup.ui.AccountSetupContract.State
import assertk.all
import assertk.assertThat
import assertk.assertions.isEqualTo
import assertk.assertions.prop
import org.junit.Test
class AccountSetupStateTest {
@Test
fun `should set default values`() {
val state = State()
assertThat(state).all {
prop(State::setupStep).isEqualTo(AccountSetupContract.SetupStep.AUTO_CONFIG)
}
}
}

View File

@ -0,0 +1,122 @@
package app.k9mail.feature.account.setup.ui
import app.cash.turbine.ReceiveTurbine
import app.cash.turbine.testIn
import app.k9mail.core.ui.compose.testing.MainDispatcherRule
import app.k9mail.feature.account.setup.ui.AccountSetupContract.Effect.NavigateBack
import app.k9mail.feature.account.setup.ui.AccountSetupContract.Effect.NavigateNext
import app.k9mail.feature.account.setup.ui.AccountSetupContract.SetupStep
import app.k9mail.feature.account.setup.ui.AccountSetupContract.State
import assertk.Assert
import assertk.all
import assertk.assertThat
import assertk.assertions.isEqualTo
import assertk.assertions.prop
import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
class AccountSetupViewModelTest {
@get:Rule
val mainDispatcherRule = MainDispatcherRule()
@Test
fun `should forward step state on next event`() = runTest {
val viewModel = AccountSetupViewModel()
val stateTurbine = viewModel.state.testIn(backgroundScope)
val effectTurbine = viewModel.effect.testIn(backgroundScope)
val turbines = listOf(stateTurbine, effectTurbine)
// Initial state
assertThatAndAllEventsConsumed(
actual = stateTurbine.awaitItem(),
turbines = turbines,
) {
prop(State::setupStep).isEqualTo(SetupStep.AUTO_CONFIG)
}
viewModel.event(AccountSetupContract.Event.OnNext)
assertThatAndAllEventsConsumed(
actual = stateTurbine.awaitItem(),
turbines = turbines,
) {
prop(State::setupStep).isEqualTo(SetupStep.MANUAL_CONFIG)
}
viewModel.event(AccountSetupContract.Event.OnNext)
assertThatAndAllEventsConsumed(
actual = stateTurbine.awaitItem(),
turbines = turbines,
) {
prop(State::setupStep).isEqualTo(SetupStep.OPTIONS)
}
viewModel.event(AccountSetupContract.Event.OnNext)
assertThatAndAllEventsConsumed(
actual = effectTurbine.awaitItem(),
turbines = turbines,
) {
isEqualTo(NavigateNext)
}
}
@Test
fun `should rewind step state on back event`() = runTest {
val initialState = State(setupStep = SetupStep.OPTIONS)
val viewModel = AccountSetupViewModel(initialState)
val stateTurbine = viewModel.state.testIn(backgroundScope)
val effectTurbine = viewModel.effect.testIn(backgroundScope)
val turbines = listOf(stateTurbine, effectTurbine)
// Initial state
assertThatAndAllEventsConsumed(
actual = stateTurbine.awaitItem(),
turbines = turbines,
) {
prop(State::setupStep).isEqualTo(SetupStep.OPTIONS)
}
viewModel.event(AccountSetupContract.Event.OnBack)
assertThatAndAllEventsConsumed(
actual = stateTurbine.awaitItem(),
turbines = turbines,
) {
prop(State::setupStep).isEqualTo(SetupStep.MANUAL_CONFIG)
}
viewModel.event(AccountSetupContract.Event.OnBack)
assertThatAndAllEventsConsumed(
actual = stateTurbine.awaitItem(),
turbines = turbines,
) {
prop(State::setupStep).isEqualTo(SetupStep.AUTO_CONFIG)
}
viewModel.event(AccountSetupContract.Event.OnBack)
assertThatAndAllEventsConsumed(
actual = effectTurbine.awaitItem(),
turbines = turbines,
) {
isEqualTo(NavigateBack)
}
}
private fun <T> assertThatAndAllEventsConsumed(
actual: T,
turbines: List<ReceiveTurbine<*>>,
assertion: Assert<T>.() -> Unit,
) {
assertThat(actual).all {
assertion()
}
turbines.forEach { it.ensureAllEventsConsumed() }
}
}

View File

@ -0,0 +1,26 @@
package app.k9mail.feature.account.setup.ui
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.State
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
internal class FakeAccountSetupViewModel(
initialState: State = State(),
) : AccountSetupContract.ViewModel {
val mutableState = MutableStateFlow(initialState)
val mutableEffect = MutableSharedFlow<Effect>()
val events = mutableListOf<Event>()
override val state: StateFlow<State> = mutableState.asStateFlow()
override val effect: SharedFlow<Effect> = mutableEffect.asSharedFlow()
override fun event(event: Event) {
events.add(event)
}
}

View File

@ -188,6 +188,7 @@ shared-jvm-test = [
"mockito-kotlin",
"koin-test",
"koin-test-junit4",
"turbine",
]
shared-jvm-test-compose = [
"robolectric",