diff --git a/link/src/main/AndroidManifest.xml b/link/src/main/AndroidManifest.xml index 7793364d263..c3286cbe53c 100644 --- a/link/src/main/AndroidManifest.xml +++ b/link/src/main/AndroidManifest.xml @@ -2,6 +2,13 @@ + + + when (effect) { + LinkEffect.GoBack -> navController.popBackStack() + is LinkEffect.NavigateTo -> { + navController.navigate(effect.screen.route) + } + } + } + } + + NavHost( + navController = navController, + startDestination = LinkScreen.SignUp.route + ) { + composable(LinkScreen.SignUp.route) { + SignUpScreen() + } + + composable(LinkScreen.Verification.route) { + VerificationScreen() + } + + composable(LinkScreen.Wallet.route) { + WalletScreen() + } + + composable(LinkScreen.CardEdit.route) { + CardEditScreen() + } + + composable(LinkScreen.PaymentMethod.route) { + PaymentMethodScreen() + } + } + } + } +} diff --git a/link/src/main/java/com/stripe/android/link/LinkActivityViewModel.kt b/link/src/main/java/com/stripe/android/link/LinkActivityViewModel.kt new file mode 100644 index 00000000000..cb6d2a5842d --- /dev/null +++ b/link/src/main/java/com/stripe/android/link/LinkActivityViewModel.kt @@ -0,0 +1,45 @@ +package com.stripe.android.link + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOf + +internal class LinkActivityViewModel : LinkViewModel( + initialState = LinkState, +) { + override fun actionToResult(action: LinkAction): Flow { + return when (action) { + LinkAction.BackPressed -> handleBackPressed() + LinkAction.WalletClicked -> handleWalletClicked() + } + } + + private fun handleBackPressed(): Flow { + return flowOf(LinkResult.SendEffect(LinkEffect.GoBack)) + } + + private fun handleWalletClicked(): Flow { + return flowOf( + value = LinkResult.SendEffect( + effect = LinkEffect.NavigateTo( + screen = LinkScreen.Wallet + ) + ) + ) + } + + override fun resultToState(currentState: LinkState, result: LinkResult) = currentState + + override fun resultToEffect(result: LinkResult): LinkEffect? { + return when (result) { + is LinkResult.SendEffect -> result.effect + } + } + + internal class Factory : ViewModelProvider.Factory { + override fun create(modelClass: Class): T { + return LinkActivityViewModel() as T + } + } +} diff --git a/link/src/main/java/com/stripe/android/link/LinkScreen.kt b/link/src/main/java/com/stripe/android/link/LinkScreen.kt index f411643ffcb..5756767a753 100644 --- a/link/src/main/java/com/stripe/android/link/LinkScreen.kt +++ b/link/src/main/java/com/stripe/android/link/LinkScreen.kt @@ -1,9 +1,9 @@ package com.stripe.android.link -internal sealed interface LinkScreen { - data object Verification : LinkScreen - data object Wallet : LinkScreen - data object PaymentMethod : LinkScreen - data object CardEdit : LinkScreen - data object SignUp : LinkScreen +internal sealed class LinkScreen(val route: String) { + data object Verification : LinkScreen("verification") + data object Wallet : LinkScreen("wallet") + data object PaymentMethod : LinkScreen("paymentMethod") + data object CardEdit : LinkScreen("cardEdit") + data object SignUp : LinkScreen("signUp") } diff --git a/link/src/main/java/com/stripe/android/link/LinkState.kt b/link/src/main/java/com/stripe/android/link/LinkState.kt new file mode 100644 index 00000000000..84968f77cfd --- /dev/null +++ b/link/src/main/java/com/stripe/android/link/LinkState.kt @@ -0,0 +1,17 @@ +package com.stripe.android.link + +internal object LinkState + +internal sealed interface LinkAction { + data object BackPressed : LinkAction + data object WalletClicked : LinkAction +} + +internal sealed interface LinkResult { + data class SendEffect(val effect: LinkEffect) : LinkResult +} + +internal sealed interface LinkEffect { + data object GoBack : LinkEffect + data class NavigateTo(val screen: LinkScreen) : LinkEffect +} diff --git a/link/src/main/java/com/stripe/android/link/LinkViewModel.kt b/link/src/main/java/com/stripe/android/link/LinkViewModel.kt new file mode 100644 index 00000000000..e4b4b352415 --- /dev/null +++ b/link/src/main/java/com/stripe/android/link/LinkViewModel.kt @@ -0,0 +1,52 @@ +package com.stripe.android.link + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.flatMapConcat +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +// This will be used as a base for all Link screen viewmodels to create a consistent architecture +// There should be no more logic here besides updating state and sending effects +internal abstract class LinkViewModel( + initialState: State, +) : ViewModel() { + private val actionChannel = Channel(Channel.UNLIMITED) + + private val _state = MutableStateFlow(initialState) + val state: StateFlow = _state + + private val _effect = MutableSharedFlow() + val effect: Flow = _effect + + init { + viewModelScope.launch { + actionChannel.receiveAsFlow() + .flatMapConcat(::actionToResult) + .collect { result -> + _state.update { currentState -> + resultToState(currentState, result) + } + resultToEffect(result)?.let { newEffect -> + _effect.emit(newEffect) + } + } + } + } + + fun handleAction(action: Action) { + actionChannel.trySend(action) + } + + protected abstract fun actionToResult(action: Action): Flow + + protected abstract fun resultToState(currentState: State, result: Result): State + + protected abstract fun resultToEffect(result: Result): Effect? +} diff --git a/link/src/test/java/com/stripe/android/link/LinkActivityTest.kt b/link/src/test/java/com/stripe/android/link/LinkActivityTest.kt new file mode 100644 index 00000000000..4119227e918 --- /dev/null +++ b/link/src/test/java/com/stripe/android/link/LinkActivityTest.kt @@ -0,0 +1,88 @@ +package com.stripe.android.link + +import android.content.Context +import android.content.Intent +import android.os.Build +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.navigation.NavHostController +import androidx.test.core.app.ApplicationProvider +import com.stripe.android.link.utils.InjectableActivityScenario +import com.stripe.android.link.utils.injectableActivityScenario +import com.stripe.android.link.utils.viewModelFactoryFor +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.test.resetMain +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import kotlin.test.AfterTest + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [Build.VERSION_CODES.Q]) +internal class LinkActivityTest { + + @get:Rule + val rule = InstantTaskExecutorRule() + + @get:Rule + val composeTestRule = createAndroidComposeRule() + + private val navHostController: NavHostController = mock() + + private val context = ApplicationProvider.getApplicationContext() + + @AfterTest + fun cleanup() { + Dispatchers.resetMain() + } + + @Test + fun `test that navigator pops back on back pressed`() { + val vm = LinkActivityViewModel() + val scenario = activityScenario(viewModel = vm) + scenario.launchTest { + vm.handleAction(LinkAction.BackPressed) + + composeTestRule.waitForIdle() + + verify(navHostController).popBackStack() + } + } + + @Test + fun `test that navigator navigates to wallet on wallet clicked`() { + val vm = LinkActivityViewModel() + val scenario = activityScenario(viewModel = vm) + scenario.launchTest { + vm.handleAction(LinkAction.WalletClicked) + + composeTestRule.waitForIdle() + + verify(navHostController).navigate(LinkScreen.Wallet.route) + } + } + + private fun InjectableActivityScenario.launchTest( + startIntent: Intent = Intent(context, LinkActivity::class.java), + block: (LinkActivity) -> Unit + ) { + launch(startIntent).onActivity { activity -> + activity.navController = navHostController + block(activity) + } + } + + private fun activityScenario( + viewModel: LinkActivityViewModel = LinkActivityViewModel(), + ): InjectableActivityScenario { + return injectableActivityScenario { + injectActivity { + viewModelFactory = viewModelFactoryFor(viewModel) + } + } + } +} diff --git a/link/src/test/java/com/stripe/android/link/LinkActivityViewModelTest.kt b/link/src/test/java/com/stripe/android/link/LinkActivityViewModelTest.kt new file mode 100644 index 00000000000..d36a2deb708 --- /dev/null +++ b/link/src/test/java/com/stripe/android/link/LinkActivityViewModelTest.kt @@ -0,0 +1,45 @@ +package com.stripe.android.link + +import app.cash.turbine.test +import com.google.common.truth.Truth +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +internal class LinkActivityViewModelTest { + private val dispatcher = UnconfinedTestDispatcher() + private val vm = LinkActivityViewModel() + + @Before + fun setUp() { + Dispatchers.setMain(dispatcher) + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun `test that viewmodel has correct initial state`() = runTest(dispatcher) { + vm.state.test { + Truth.assertThat(awaitItem()).isEqualTo(LinkState) + } + } + + @Test + fun `test that correct effect is emitted on back pressed`() = runTest(dispatcher) { + vm.effect.test { + vm.handleAction(LinkAction.BackPressed) + Truth.assertThat(awaitItem()).isEqualTo(LinkEffect.GoBack) + } + } +}