Skip to content

Commit

Permalink
LinkActivity, ViewModel and Navigator
Browse files Browse the repository at this point in the history
  • Loading branch information
toluo-stripe committed Sep 25, 2024
1 parent a9a8ea4 commit fdb91ec
Show file tree
Hide file tree
Showing 8 changed files with 331 additions and 6 deletions.
7 changes: 7 additions & 0 deletions link/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,13 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">

<application>
<activity
android:name="com.stripe.android.link.LinkActivity"
android:autoRemoveFromRecents="true"
android:configChanges="orientation|keyboard|keyboardHidden|screenLayout|screenSize|smallestScreenSize"
android:launchMode="singleTop"
android:theme="@style/StripeTransparentTheme" />

<activity
android:name="com.stripe.android.link.LinkForegroundActivity"
android:autoRemoveFromRecents="true"
Expand Down
71 changes: 71 additions & 0 deletions link/src/main/java/com/stripe/android/link/LinkActivity.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
package com.stripe.android.link

import android.os.Bundle
import androidx.activity.compose.setContent
import androidx.activity.viewModels
import androidx.annotation.VisibleForTesting
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.runtime.LaunchedEffect
import androidx.lifecycle.ViewModelProvider
import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import com.stripe.android.link.ui.cardedit.CardEditScreen
import com.stripe.android.link.ui.paymentmenthod.PaymentMethodScreen
import com.stripe.android.link.ui.signup.SignUpScreen
import com.stripe.android.link.ui.verification.VerificationScreen
import com.stripe.android.link.ui.wallet.WalletScreen

internal class LinkActivity : AppCompatActivity() {
@VisibleForTesting
internal var viewModelFactory: ViewModelProvider.Factory = LinkActivityViewModel.Factory()
private val viewModel: LinkActivityViewModel by viewModels { viewModelFactory }

@VisibleForTesting
internal lateinit var navController: NavHostController

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)

setContent {
navController = rememberNavController()

LaunchedEffect("LinkEffects") {
viewModel.effect.collect { effect ->
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()
}
}
}
}
}
Original file line number Diff line number Diff line change
@@ -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<LinkState, LinkAction, LinkResult, LinkEffect>(
initialState = LinkState,
) {
override fun actionToResult(action: LinkAction): Flow<LinkResult> {
return when (action) {
LinkAction.BackPressed -> handleBackPressed()
LinkAction.WalletClicked -> handleWalletClicked()
}
}

private fun handleBackPressed(): Flow<LinkResult> {
return flowOf(LinkResult.SendEffect(LinkEffect.GoBack))
}

private fun handleWalletClicked(): Flow<LinkResult> {
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 <T : ViewModel> create(modelClass: Class<T>): T {
return LinkActivityViewModel() as T
}
}
}
12 changes: 6 additions & 6 deletions link/src/main/java/com/stripe/android/link/LinkScreen.kt
Original file line number Diff line number Diff line change
@@ -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")
}
17 changes: 17 additions & 0 deletions link/src/main/java/com/stripe/android/link/LinkState.kt
Original file line number Diff line number Diff line change
@@ -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
}
52 changes: 52 additions & 0 deletions link/src/main/java/com/stripe/android/link/LinkViewModel.kt
Original file line number Diff line number Diff line change
@@ -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<State, Action, Result, Effect>(
initialState: State,
) : ViewModel() {
private val actionChannel = Channel<Action>(Channel.UNLIMITED)

private val _state = MutableStateFlow(initialState)
val state: StateFlow<State> = _state

private val _effect = MutableSharedFlow<Effect>()
val effect: Flow<Effect> = _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<Result>

protected abstract fun resultToState(currentState: State, result: Result): State

protected abstract fun resultToEffect(result: Result): Effect?
}
88 changes: 88 additions & 0 deletions link/src/test/java/com/stripe/android/link/LinkActivityTest.kt
Original file line number Diff line number Diff line change
@@ -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<LinkActivity>()

private val navHostController: NavHostController = mock()

private val context = ApplicationProvider.getApplicationContext<Context>()

@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<LinkActivity>.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<LinkActivity> {
return injectableActivityScenario {
injectActivity {
viewModelFactory = viewModelFactoryFor(viewModel)
}
}
}
}
Original file line number Diff line number Diff line change
@@ -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)
}
}
}

0 comments on commit fdb91ec

Please sign in to comment.