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:
parent
7a12b162b1
commit
c7112297b8
@ -25,8 +25,8 @@ fun FeatureNavHost(
|
||||
onImportClick = { /* TODO */ },
|
||||
)
|
||||
accountSetupScreen(
|
||||
onBackClick = navController::popBackStack,
|
||||
onFinishClick = { /* TODO */ },
|
||||
onBack = navController::popBackStack,
|
||||
onFinish = { /* TODO */ },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
@ -9,4 +9,6 @@ android {
|
||||
|
||||
dependencies {
|
||||
implementation(projects.core.ui.compose.designsystem)
|
||||
|
||||
testImplementation(projects.core.ui.compose.testing)
|
||||
}
|
||||
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
}
|
||||
}
|
@ -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 = {},
|
||||
)
|
||||
}
|
||||
|
@ -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)
|
||||
}
|
||||
|
@ -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(),
|
||||
|
@ -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(),
|
||||
|
@ -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(),
|
||||
|
@ -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"
|
||||
}
|
||||
}
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
@ -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() }
|
||||
}
|
||||
}
|
@ -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)
|
||||
}
|
||||
}
|
@ -188,6 +188,7 @@ shared-jvm-test = [
|
||||
"mockito-kotlin",
|
||||
"koin-test",
|
||||
"koin-test-junit4",
|
||||
"turbine",
|
||||
]
|
||||
shared-jvm-test-compose = [
|
||||
"robolectric",
|
||||
|
Loading…
Reference in New Issue
Block a user