diff --git a/link/api/link.api b/link/api/link.api index f2f6f7065af..34ff71644ab 100644 --- a/link/api/link.api +++ b/link/api/link.api @@ -122,13 +122,11 @@ public final class com/stripe/android/link/ui/ComposableSingletons$LinkContentKt public static field lambda-2 Lkotlin/jvm/functions/Function4; public static field lambda-3 Lkotlin/jvm/functions/Function4; public static field lambda-4 Lkotlin/jvm/functions/Function4; - public static field lambda-5 Lkotlin/jvm/functions/Function4; public fun ()V public final fun getLambda-1$link_release ()Lkotlin/jvm/functions/Function3; public final fun getLambda-2$link_release ()Lkotlin/jvm/functions/Function4; public final fun getLambda-3$link_release ()Lkotlin/jvm/functions/Function4; public final fun getLambda-4$link_release ()Lkotlin/jvm/functions/Function4; - public final fun getLambda-5$link_release ()Lkotlin/jvm/functions/Function4; } public final class com/stripe/android/link/ui/ComposableSingletons$LinkTermsKt { @@ -223,6 +221,13 @@ public final class com/stripe/android/link/ui/wallet/ComposableSingletons$Paymen public final fun getLambda-1$link_release ()Lkotlin/jvm/functions/Function2; } +public final class com/stripe/android/link/ui/wallet/ComposableSingletons$WalletScreenKt { + public static final field INSTANCE Lcom/stripe/android/link/ui/wallet/ComposableSingletons$WalletScreenKt; + public static field lambda-1 Lkotlin/jvm/functions/Function3; + public fun ()V + public final fun getLambda-1$link_release ()Lkotlin/jvm/functions/Function3; +} + public final class com/stripe/android/link/utils/ComposableSingletons$InlineContentTemplateBuilderKt { public static final field INSTANCE Lcom/stripe/android/link/utils/ComposableSingletons$InlineContentTemplateBuilderKt; public static field lambda-1 Lkotlin/jvm/functions/Function2; diff --git a/link/res/values/strings.xml b/link/res/values/strings.xml index 080a144a768..19ff7c1d19f 100644 --- a/link/res/values/strings.xml +++ b/link/res/values/strings.xml @@ -72,4 +72,5 @@ Update card This card has expired. Update your card info or choose a different payment method. + Change selection diff --git a/link/src/main/java/com/stripe/android/link/theme/Theme.kt b/link/src/main/java/com/stripe/android/link/theme/Theme.kt index fe87730ac09..46191f6a89c 100644 --- a/link/src/main/java/com/stripe/android/link/theme/Theme.kt +++ b/link/src/main/java/com/stripe/android/link/theme/Theme.kt @@ -13,6 +13,7 @@ private val LocalColors = staticCompositionLocalOf { LinkThemeConfig.colors(fals internal val MinimumTouchTargetSize = 48.dp internal val PrimaryButtonHeight = 56.dp internal val AppBarHeight = 56.dp +internal val HorizontalPadding = 20.dp @Composable internal fun DefaultLinkTheme( diff --git a/link/src/main/java/com/stripe/android/link/ui/LinkContent.kt b/link/src/main/java/com/stripe/android/link/ui/LinkContent.kt index 70aa7d81108..fc1ab1126d5 100644 --- a/link/src/main/java/com/stripe/android/link/ui/LinkContent.kt +++ b/link/src/main/java/com/stripe/android/link/ui/LinkContent.kt @@ -37,6 +37,7 @@ import com.stripe.android.link.ui.signup.SignUpViewModel import com.stripe.android.link.ui.verification.VerificationScreen import com.stripe.android.link.ui.verification.VerificationViewModel import com.stripe.android.link.ui.wallet.WalletScreen +import com.stripe.android.link.ui.wallet.WalletViewModel import com.stripe.android.ui.core.CircularProgressIndicator import kotlinx.coroutines.launch @@ -155,7 +156,17 @@ private fun Screens( } composable(LinkScreen.Wallet.route) { - WalletScreen() + val linkAccount = getLinkAccount() + ?: return@composable dismissWithResult(LinkActivityResult.Failed(NoLinkAccountFoundException())) + val viewModel: WalletViewModel = linkViewModel { parentComponent -> + WalletViewModel.factory( + parentComponent = parentComponent, + linkAccount = linkAccount, + navigateAndClearStack = navigateAndClearStack, + dismissWithResult = dismissWithResult + ) + } + WalletScreen(viewModel) } composable(LinkScreen.CardEdit.route) { diff --git a/link/src/main/java/com/stripe/android/link/ui/wallet/PaymentDetails.kt b/link/src/main/java/com/stripe/android/link/ui/wallet/PaymentDetails.kt index e5281f76a40..cb08375a8c4 100644 --- a/link/src/main/java/com/stripe/android/link/ui/wallet/PaymentDetails.kt +++ b/link/src/main/java/com/stripe/android/link/ui/wallet/PaymentDetails.kt @@ -25,6 +25,7 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight @@ -42,6 +43,7 @@ import com.stripe.android.ui.core.R as StripeUiCoreR @Composable internal fun PaymentDetailsListItem( + modifier: Modifier = Modifier, paymentDetails: ConsumerPaymentDetails.PaymentDetails, enabled: Boolean, isSelected: Boolean, @@ -50,7 +52,7 @@ internal fun PaymentDetailsListItem( onMenuButtonClick: () -> Unit ) { Row( - modifier = Modifier + modifier = modifier .fillMaxWidth() .defaultMinSize(minHeight = 56.dp) .clickable(enabled = enabled, onClick = onClick), @@ -59,7 +61,9 @@ internal fun PaymentDetailsListItem( RadioButton( selected = isSelected, onClick = null, - modifier = Modifier.padding(start = 20.dp, end = 6.dp), + modifier = Modifier + .testTag(WALLET_PAYMENT_DETAIL_ITEM_RADIO_BUTTON) + .padding(start = 20.dp, end = 6.dp), colors = RadioButtonDefaults.colors( selectedColor = MaterialTheme.linkColors.actionLabelLight, unselectedColor = MaterialTheme.linkColors.disabledText @@ -154,12 +158,14 @@ private fun DefaultTag() { } @Composable -private fun RowScope.PaymentDetails( +internal fun RowScope.PaymentDetails( + modifier: Modifier = Modifier, paymentDetails: ConsumerPaymentDetails.PaymentDetails, ) { when (paymentDetails) { is Card -> { CardInfo( + modifier = modifier, last4 = paymentDetails.last4, icon = paymentDetails.brand.icon, contentDescription = paymentDetails.brand.displayName @@ -170,6 +176,7 @@ private fun RowScope.PaymentDetails( } is ConsumerPaymentDetails.Passthrough -> { CardInfo( + modifier = modifier, last4 = paymentDetails.last4, icon = R.drawable.stripe_link_bank, contentDescription = stringResource(R.string.stripe_wallet_passthrough_description) @@ -180,12 +187,13 @@ private fun RowScope.PaymentDetails( @Composable private fun RowScope.CardInfo( + modifier: Modifier = Modifier, last4: String, icon: Int, - contentDescription: String? = null + contentDescription: String? = null, ) { Row( - modifier = Modifier.weight(1f), + modifier = modifier.weight(1f), verticalAlignment = Alignment.CenterVertically ) { Image( @@ -210,10 +218,11 @@ private fun RowScope.CardInfo( @Composable private fun RowScope.BankAccountInfo( + modifier: Modifier = Modifier, bankAccount: ConsumerPaymentDetails.BankAccount, ) { Row( - modifier = Modifier.weight(1f), + modifier = modifier.weight(1f), verticalAlignment = Alignment.CenterVertically ) { Image( @@ -248,3 +257,5 @@ private fun RowScope.BankAccountInfo( } } } + +internal const val WALLET_PAYMENT_DETAIL_ITEM_RADIO_BUTTON = "wallet_payment_detail_item_radio_button" diff --git a/link/src/main/java/com/stripe/android/link/ui/wallet/WalletScreen.kt b/link/src/main/java/com/stripe/android/link/ui/wallet/WalletScreen.kt index 7ee9089f008..2769cd405d6 100644 --- a/link/src/main/java/com/stripe/android/link/ui/wallet/WalletScreen.kt +++ b/link/src/main/java/com/stripe/android/link/ui/wallet/WalletScreen.kt @@ -1,9 +1,333 @@ package com.stripe.android.link.ui.wallet +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.animateContentSize +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.CircularProgressIndicator +import androidx.compose.material.Icon +import androidx.compose.material.MaterialTheme import androidx.compose.material.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.rotate +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.unit.dp +import com.stripe.android.link.R +import com.stripe.android.link.theme.HorizontalPadding +import com.stripe.android.link.theme.linkColors +import com.stripe.android.link.theme.linkShapes +import com.stripe.android.model.ConsumerPaymentDetails +import com.stripe.android.uicore.text.Html +import com.stripe.android.uicore.utils.collectAsState @Composable -internal fun WalletScreen() { - Text(text = "WalletScreen") +internal fun WalletScreen( + viewModel: WalletViewModel, +) { + val state by viewModel.uiState.collectAsState() + var isExpanded by rememberSaveable { mutableStateOf(false) } + + WalletBody( + state = state, + isExpanded = isExpanded, + onItemSelected = viewModel::onItemSelected, + onExpandedChanged = { expanded -> + isExpanded = expanded + } + ) +} + +@Composable +internal fun WalletBody( + state: WalletUiState, + isExpanded: Boolean, + onItemSelected: (ConsumerPaymentDetails.PaymentDetails) -> Unit, + onExpandedChanged: (Boolean) -> Unit, +) { + if (state.paymentDetailsList.isEmpty()) { + Box( + modifier = Modifier + .fillMaxSize() + .testTag(WALLET_LOADER_TAG), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator() + } + return + } + + val focusManager = LocalFocusManager.current + + LaunchedEffect(state.isProcessing) { + if (state.isProcessing) { + focusManager.clearFocus() + } + } + + Column( + modifier = Modifier + .fillMaxSize() + .padding(16.dp) + ) { + Box( + modifier = Modifier + .animateContentSize() + ) { + val selectedItem = state.selectedItem + if (isExpanded || selectedItem == null) { + ExpandedPaymentDetails( + uiState = state, + onItemSelected = onItemSelected, + onMenuButtonClick = {}, + onAddNewPaymentMethodClick = {}, + onCollapse = { + onExpandedChanged(false) + } + ) + } else { + CollapsedPaymentDetails( + selectedPaymentMethod = selectedItem, + enabled = !state.primaryButtonState.isBlocking, + onClick = { + onExpandedChanged(true) + } + ) + } + } + + AnimatedVisibility(state.showBankAccountTerms) { + BankAccountTerms() + } + } +} + +@Composable +internal fun CollapsedPaymentDetails( + selectedPaymentMethod: ConsumerPaymentDetails.PaymentDetails, + enabled: Boolean, + onClick: () -> Unit +) { + Row( + modifier = Modifier + .testTag(COLLAPSED_WALLET_ROW) + .fillMaxWidth() + .height(64.dp) + .border( + width = 1.dp, + color = MaterialTheme.linkColors.componentBorder, + shape = MaterialTheme.linkShapes.large + ) + .clip(MaterialTheme.linkShapes.large) + .background( + color = MaterialTheme.linkColors.componentBackground, + shape = MaterialTheme.linkShapes.large + ) + .clickable( + enabled = enabled, + onClick = onClick + ), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = stringResource(R.string.stripe_wallet_collapsed_payment), + modifier = Modifier + .testTag(COLLAPSED_WALLET_HEADER_TAG) + .padding( + start = HorizontalPadding, + end = 8.dp + ), + color = MaterialTheme.linkColors.disabledText + ) + PaymentDetails( + modifier = Modifier + .testTag(COLLAPSED_WALLET_PAYMENT_DETAILS_TAG), + paymentDetails = selectedPaymentMethod + ) + Icon( + painter = painterResource(R.drawable.stripe_link_chevron), + contentDescription = stringResource(R.string.stripe_wallet_expand_accessibility), + modifier = Modifier + .padding(end = 22.dp) + .testTag(COLLAPSED_WALLET_CHEVRON_ICON_TAG), + tint = MaterialTheme.linkColors.disabledText + ) + } +} + +@Composable +private fun ExpandedPaymentDetails( + uiState: WalletUiState, + onItemSelected: (ConsumerPaymentDetails.PaymentDetails) -> Unit, + onMenuButtonClick: (ConsumerPaymentDetails.PaymentDetails) -> Unit, + onAddNewPaymentMethodClick: () -> Unit, + onCollapse: () -> Unit +) { + val isEnabled = !uiState.primaryButtonState.isBlocking + + LazyColumn( + modifier = Modifier + .fillMaxWidth() + .border( + width = 1.dp, + color = MaterialTheme.linkColors.componentBorder, + shape = MaterialTheme.linkShapes.large + ) + .clip(MaterialTheme.linkShapes.large) + .background( + color = MaterialTheme.linkColors.componentBackground, + shape = MaterialTheme.linkShapes.large + ) + ) { + item { + ExpandedRowHeader( + isEnabled = isEnabled, + onCollapse = onCollapse + ) + } + + items( + items = uiState.paymentDetailsList, + key = { + "payment_detail_${it.id}" + } + ) { item -> + PaymentDetailsListItem( + modifier = Modifier + .testTag(WALLET_SCREEN_PAYMENT_METHODS_LIST), + paymentDetails = item, + enabled = isEnabled, + isSelected = uiState.selectedItem?.id == item.id, + isUpdating = false, + onClick = { + onItemSelected(item) + }, + onMenuButtonClick = { + onMenuButtonClick(item) + } + ) + } + + item { + AddPaymentMethodRow( + isEnabled = isEnabled, + onAddNewPaymentMethodClick = onAddNewPaymentMethodClick + ) + } + } +} + +@Composable +private fun ExpandedRowHeader( + isEnabled: Boolean, + onCollapse: () -> Unit, +) { + Row( + modifier = Modifier + .testTag(WALLET_SCREEN_EXPANDED_ROW_HEADER) + .height(44.dp) + .clickable( + enabled = isEnabled, + onClick = onCollapse + ), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = stringResource(R.string.stripe_wallet_expanded_title), + modifier = Modifier + .padding(start = HorizontalPadding, top = 20.dp), + color = MaterialTheme.colors.onPrimary, + style = MaterialTheme.typography.button + ) + Spacer(modifier = Modifier.weight(1f)) + Icon( + painter = painterResource(id = R.drawable.stripe_link_chevron), + contentDescription = stringResource(R.string.stripe_wallet_expand_accessibility), + modifier = Modifier + .padding(top = 20.dp, end = 22.dp) + .rotate(CHEVRON_ICON_ROTATION), + tint = MaterialTheme.colors.onPrimary + ) + } +} + +@Composable +private fun AddPaymentMethodRow( + isEnabled: Boolean, + onAddNewPaymentMethodClick: () -> Unit, +) { + Row( + modifier = Modifier + .testTag(WALLET_ADD_PAYMENT_METHOD_ROW) + .fillMaxWidth() + .height(60.dp) + .clickable(enabled = isEnabled, onClick = onAddNewPaymentMethodClick), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + painter = painterResource(R.drawable.stripe_link_add_green), + contentDescription = null, + modifier = Modifier.padding(start = HorizontalPadding, end = 12.dp), + tint = Color.Unspecified + ) + Text( + text = stringResource(R.string.stripe_add_payment_method), + modifier = Modifier.padding(end = HorizontalPadding), + color = MaterialTheme.linkColors.actionLabel, + style = MaterialTheme.typography.button + ) + } } + +@Composable +private fun BankAccountTerms() { + Html( + html = stringResource(R.string.stripe_wallet_bank_account_terms).replaceHyperlinks(), + color = MaterialTheme.colors.onSecondary, + style = MaterialTheme.typography.caption, + modifier = Modifier + .fillMaxWidth() + .padding(top = 12.dp), + urlSpanStyle = SpanStyle( + color = MaterialTheme.colors.primary + ) + ) +} + +private fun String.replaceHyperlinks() = this.replace( + "", + "" +).replace("", "") + +private const val CHEVRON_ICON_ROTATION = 180f +internal const val WALLET_LOADER_TAG = "wallet_screen_loader_tag" +internal const val COLLAPSED_WALLET_HEADER_TAG = "collapsed_wallet_header_tag" +internal const val COLLAPSED_WALLET_CHEVRON_ICON_TAG = "collapsed_wallet_chevron_icon_tag" +internal const val COLLAPSED_WALLET_PAYMENT_DETAILS_TAG = "collapsed_wallet_payment_details_tag" +internal const val COLLAPSED_WALLET_ROW = "collapsed_wallet_row_tag" +internal const val WALLET_SCREEN_EXPANDED_ROW_HEADER = "wallet_screen_expanded_row_header" +internal const val WALLET_ADD_PAYMENT_METHOD_ROW = "wallet_add_payment_method_row" +internal const val WALLET_SCREEN_PAYMENT_METHODS_LIST = "wallet_screen_payment_methods_list" diff --git a/link/src/main/java/com/stripe/android/link/ui/wallet/WalletUiState.kt b/link/src/main/java/com/stripe/android/link/ui/wallet/WalletUiState.kt index 42ef33ed789..94611cb29c5 100644 --- a/link/src/main/java/com/stripe/android/link/ui/wallet/WalletUiState.kt +++ b/link/src/main/java/com/stripe/android/link/ui/wallet/WalletUiState.kt @@ -6,12 +6,13 @@ import com.stripe.android.model.ConsumerPaymentDetails @Immutable internal data class WalletUiState( - val supportedTypes: Set, val paymentDetailsList: List, val selectedItem: ConsumerPaymentDetails.PaymentDetails?, val isProcessing: Boolean, ) { + val showBankAccountTerms = selectedItem is ConsumerPaymentDetails.BankAccount + val primaryButtonState: PrimaryButtonState get() { return if (isProcessing) { diff --git a/link/src/main/java/com/stripe/android/link/ui/wallet/WalletViewModel.kt b/link/src/main/java/com/stripe/android/link/ui/wallet/WalletViewModel.kt index a3efdd5e2d6..17ebd455478 100644 --- a/link/src/main/java/com/stripe/android/link/ui/wallet/WalletViewModel.kt +++ b/link/src/main/java/com/stripe/android/link/ui/wallet/WalletViewModel.kt @@ -13,6 +13,7 @@ import com.stripe.android.link.account.LinkAccountManager import com.stripe.android.link.injection.NativeLinkComponent import com.stripe.android.link.model.LinkAccount import com.stripe.android.link.model.supportedPaymentMethodTypes +import com.stripe.android.model.ConsumerPaymentDetails import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.update @@ -24,17 +25,16 @@ internal class WalletViewModel @Inject constructor( private val linkAccount: LinkAccount, private val linkAccountManager: LinkAccountManager, private val logger: Logger, - private val navigate: (route: LinkScreen, clearStack: Boolean) -> Unit, + private val navigateAndClearStack: (route: LinkScreen) -> Unit, private val dismissWithResult: (LinkActivityResult) -> Unit ) : ViewModel() { private val stripeIntent = configuration.stripeIntent private val _uiState = MutableStateFlow( value = WalletUiState( - supportedTypes = stripeIntent.supportedPaymentMethodTypes(linkAccount), paymentDetailsList = emptyList(), selectedItem = null, - isProcessing = false + isProcessing = false, ) ) @@ -51,7 +51,7 @@ internal class WalletViewModel @Inject constructor( viewModelScope.launch { linkAccountManager.listPaymentDetails( - paymentMethodTypes = _uiState.value.supportedTypes + paymentMethodTypes = stripeIntent.supportedPaymentMethodTypes(linkAccount) ).fold( onSuccess = { response -> _uiState.update { @@ -59,7 +59,7 @@ internal class WalletViewModel @Inject constructor( } if (response.paymentDetails.isEmpty()) { - navigate(LinkScreen.PaymentMethod, true) + navigateAndClearStack(LinkScreen.PaymentMethod) } }, // If we can't load the payment details there's nothing to see here @@ -73,11 +73,19 @@ internal class WalletViewModel @Inject constructor( dismissWithResult(LinkActivityResult.Failed(fatalError)) } + fun onItemSelected(item: ConsumerPaymentDetails.PaymentDetails) { + if (item == uiState.value.selectedItem) return + + _uiState.update { + it.copy(selectedItem = item) + } + } + companion object { fun factory( parentComponent: NativeLinkComponent, linkAccount: LinkAccount, - navigate: (route: LinkScreen, clearStack: Boolean) -> Unit, + navigateAndClearStack: (route: LinkScreen) -> Unit, dismissWithResult: (LinkActivityResult) -> Unit ): ViewModelProvider.Factory { return viewModelFactory { @@ -87,7 +95,7 @@ internal class WalletViewModel @Inject constructor( linkAccountManager = parentComponent.linkAccountManager, logger = parentComponent.logger, linkAccount = linkAccount, - navigate = navigate, + navigateAndClearStack = navigateAndClearStack, dismissWithResult = dismissWithResult ) } diff --git a/link/src/test/java/com/stripe/android/link/TestFactory.kt b/link/src/test/java/com/stripe/android/link/TestFactory.kt index 85e10f6a959..9cd4a0602dd 100644 --- a/link/src/test/java/com/stripe/android/link/TestFactory.kt +++ b/link/src/test/java/com/stripe/android/link/TestFactory.kt @@ -82,6 +82,19 @@ internal object TestFactory { ) ) + private val CONSUMER_PAYMENT_DETAILS_BANK_ACCOUNT = ConsumerPaymentDetails.BankAccount( + id = "pm_124", + last4 = "4242", + isDefault = false, + bankName = "Stripe Test Bank", + bankIconCode = null + ) + + private val CONSUMER_PAYMENT_DETAILS_PASSTHROUGH = ConsumerPaymentDetails.Passthrough( + id = "pm_125", + last4 = "4242", + ) + val LINK_NEW_PAYMENT_DETAILS = LinkPaymentDetails.New( paymentDetails = CONSUMER_PAYMENT_DETAILS_CARD, paymentMethodCreateParams = PAYMENT_METHOD_CREATE_PARAMS, @@ -92,7 +105,9 @@ internal object TestFactory { val CONSUMER_PAYMENT_DETAILS: ConsumerPaymentDetails = ConsumerPaymentDetails( paymentDetails = listOf( - CONSUMER_PAYMENT_DETAILS_CARD + CONSUMER_PAYMENT_DETAILS_CARD, + CONSUMER_PAYMENT_DETAILS_BANK_ACCOUNT, + CONSUMER_PAYMENT_DETAILS_PASSTHROUGH, ) ) diff --git a/link/src/test/java/com/stripe/android/link/ui/wallet/WalletScreenScreenshotTest.kt b/link/src/test/java/com/stripe/android/link/ui/wallet/WalletScreenScreenshotTest.kt new file mode 100644 index 00000000000..5bab5155940 --- /dev/null +++ b/link/src/test/java/com/stripe/android/link/ui/wallet/WalletScreenScreenshotTest.kt @@ -0,0 +1,77 @@ +package com.stripe.android.link.ui.wallet + +import com.stripe.android.link.TestFactory +import com.stripe.android.link.theme.DefaultLinkTheme +import com.stripe.android.model.ConsumerPaymentDetails +import com.stripe.android.screenshottesting.PaparazziRule +import org.junit.Rule +import org.junit.Test + +internal class WalletScreenScreenshotTest { + @get:Rule + val paparazziRule = PaparazziRule() + + @Test + fun testEmptyState() { + snapshot( + state = WalletUiState( + paymentDetailsList = emptyList(), + selectedItem = null, + isProcessing = false + ) + ) + } + + @Test + fun testCollapsedState() { + snapshot( + state = WalletUiState( + paymentDetailsList = TestFactory.CONSUMER_PAYMENT_DETAILS.paymentDetails, + selectedItem = TestFactory.CONSUMER_PAYMENT_DETAILS.paymentDetails.firstOrNull(), + isProcessing = false + ) + ) + } + + @Test + fun testExpandedState() { + snapshot( + state = WalletUiState( + paymentDetailsList = TestFactory.CONSUMER_PAYMENT_DETAILS.paymentDetails, + selectedItem = TestFactory.CONSUMER_PAYMENT_DETAILS.paymentDetails.firstOrNull(), + isProcessing = false + ), + isExpanded = true + ) + } + + @Test + fun testBankAccountSelectedState() { + snapshot( + state = WalletUiState( + paymentDetailsList = TestFactory.CONSUMER_PAYMENT_DETAILS.paymentDetails, + selectedItem = TestFactory.CONSUMER_PAYMENT_DETAILS.paymentDetails.firstOrNull { + it is ConsumerPaymentDetails.BankAccount + }, + isProcessing = false + ), + isExpanded = true + ) + } + + private fun snapshot( + state: WalletUiState, + isExpanded: Boolean = false + ) { + paparazziRule.snapshot { + DefaultLinkTheme { + WalletBody( + state = state, + isExpanded = isExpanded, + onItemSelected = {}, + onExpandedChanged = {} + ) + } + } + } +} diff --git a/link/src/test/java/com/stripe/android/link/ui/wallet/WalletScreenTest.kt b/link/src/test/java/com/stripe/android/link/ui/wallet/WalletScreenTest.kt new file mode 100644 index 00000000000..93689187787 --- /dev/null +++ b/link/src/test/java/com/stripe/android/link/ui/wallet/WalletScreenTest.kt @@ -0,0 +1,119 @@ +package com.stripe.android.link.ui.wallet + +import androidx.activity.ComponentActivity +import androidx.compose.ui.test.assertCountEquals +import androidx.compose.ui.test.assertHasClickAction +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.hasTestTag +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performClick +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.stripe.android.link.TestFactory +import com.stripe.android.link.account.FakeLinkAccountManager +import com.stripe.android.link.account.LinkAccountManager +import com.stripe.android.model.ConsumerPaymentDetails +import com.stripe.android.testing.FakeLogger +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +internal class WalletScreenTest { + private val dispatcher = UnconfinedTestDispatcher() + + @get:Rule + val composeTestRule = createAndroidComposeRule() + + @Before + fun setup() { + Dispatchers.setMain(dispatcher) + } + + @Test + fun `wallet list is collapsed on start`() = runTest(dispatcher) { + val viewModel = createViewModel() + composeTestRule.setContent { + WalletScreen(viewModel) + } + composeTestRule.waitForIdle() + + onWalletCollapsedHeader().assertIsDisplayed() + onWalletCollapsedChevron().assertIsDisplayed() + onWalletCollapsedPaymentDetails().assertIsDisplayed() + onCollapsedWalletRow().assertIsDisplayed().assertHasClickAction() + } + + @Test + fun `wallet list is expanded on expand clicked`() = runTest(dispatcher) { + val viewModel = createViewModel() + composeTestRule.setContent { + WalletScreen(viewModel) + } + + composeTestRule.waitForIdle() + + onCollapsedWalletRow().performClick() + + composeTestRule.waitForIdle() + + onWalletAddPaymentMethodRow().assertIsDisplayed().assertHasClickAction() + onExpandedWalletHeader().assertIsDisplayed() + onPaymentMethodList().assertCountEquals(3) + } + + @Test + fun `wallet loader should be displayed when no payment method is available`() = runTest(dispatcher) { + val linkAccountManager = FakeLinkAccountManager() + linkAccountManager.listPaymentDetailsResult = Result.success(ConsumerPaymentDetails(emptyList())) + + val viewModel = createViewModel(linkAccountManager) + composeTestRule.setContent { + WalletScreen(viewModel) + } + + composeTestRule.waitForIdle() + + onLoader().assertIsDisplayed() + onPaymentMethodList().assertCountEquals(0) + } + + private fun createViewModel( + linkAccountManager: LinkAccountManager = FakeLinkAccountManager() + ): WalletViewModel { + return WalletViewModel( + configuration = TestFactory.LINK_CONFIGURATION, + linkAccount = TestFactory.LINK_ACCOUNT, + linkAccountManager = linkAccountManager, + logger = FakeLogger(), + navigateAndClearStack = {}, + dismissWithResult = {} + ) + } + + private fun onWalletCollapsedHeader() = + composeTestRule.onNodeWithTag(COLLAPSED_WALLET_HEADER_TAG, useUnmergedTree = true) + + private fun onWalletCollapsedChevron() = + composeTestRule.onNodeWithTag(COLLAPSED_WALLET_CHEVRON_ICON_TAG, useUnmergedTree = true) + + private fun onWalletCollapsedPaymentDetails() = + composeTestRule.onNodeWithTag(COLLAPSED_WALLET_PAYMENT_DETAILS_TAG, useUnmergedTree = true) + + private fun onCollapsedWalletRow() = composeTestRule.onNodeWithTag(COLLAPSED_WALLET_ROW, useUnmergedTree = true) + + private fun onWalletAddPaymentMethodRow() = + composeTestRule.onNodeWithTag(WALLET_ADD_PAYMENT_METHOD_ROW, useUnmergedTree = true) + + private fun onPaymentMethodList() = composeTestRule.onAllNodes(hasTestTag(WALLET_SCREEN_PAYMENT_METHODS_LIST)) + + private fun onExpandedWalletHeader() = + composeTestRule.onNodeWithTag(WALLET_SCREEN_EXPANDED_ROW_HEADER, useUnmergedTree = true) + + private fun onLoader() = composeTestRule.onNodeWithTag(WALLET_LOADER_TAG) +} diff --git a/link/src/test/java/com/stripe/android/link/ui/wallet/WalletViewModelTest.kt b/link/src/test/java/com/stripe/android/link/ui/wallet/WalletViewModelTest.kt index 4ac37082c0f..199bdcca1da 100644 --- a/link/src/test/java/com/stripe/android/link/ui/wallet/WalletViewModelTest.kt +++ b/link/src/test/java/com/stripe/android/link/ui/wallet/WalletViewModelTest.kt @@ -54,7 +54,6 @@ class WalletViewModelTest { assertThat(viewModel.uiState.value).isEqualTo( WalletUiState( - supportedTypes = TestFactory.LINK_CONFIGURATION.stripeIntent.paymentMethodTypes.toSet(), paymentDetailsList = TestFactory.CONSUMER_PAYMENT_DETAILS.paymentDetails, selectedItem = TestFactory.CONSUMER_PAYMENT_DETAILS.paymentDetails.firstOrNull(), isProcessing = false @@ -92,25 +91,22 @@ class WalletViewModelTest { linkAccountManager.listPaymentDetailsResult = Result.success(ConsumerPaymentDetails(emptyList())) var navScreen: LinkScreen? = null - var navClearStack: Boolean? = null - fun navigate(screen: LinkScreen, clearStack: Boolean) { + fun navigateAndClearStack(screen: LinkScreen) { navScreen = screen - navClearStack = clearStack } createViewModel( linkAccountManager = linkAccountManager, - navigate = ::navigate + navigateAndClearStack = ::navigateAndClearStack ) assertThat(navScreen).isEqualTo(LinkScreen.PaymentMethod) - assertThat(navClearStack).isTrue() } private fun createViewModel( linkAccountManager: LinkAccountManager = FakeLinkAccountManager(), logger: Logger = FakeLogger(), - navigate: (route: LinkScreen, clearStack: Boolean) -> Unit = { _, _ -> }, + navigateAndClearStack: (route: LinkScreen) -> Unit = {}, dismissWithResult: (LinkActivityResult) -> Unit = {} ): WalletViewModel { return WalletViewModel( @@ -118,7 +114,7 @@ class WalletViewModelTest { linkAccount = TestFactory.LINK_ACCOUNT, linkAccountManager = linkAccountManager, logger = logger, - navigate = navigate, + navigateAndClearStack = navigateAndClearStack, dismissWithResult = dismissWithResult ) } diff --git a/link/src/test/snapshots/images/com.stripe.android.link.ui.wallet_WalletScreenScreenshotTest_testBankAccountSelectedState[].png b/link/src/test/snapshots/images/com.stripe.android.link.ui.wallet_WalletScreenScreenshotTest_testBankAccountSelectedState[].png new file mode 100644 index 00000000000..6b69fa1a9df Binary files /dev/null and b/link/src/test/snapshots/images/com.stripe.android.link.ui.wallet_WalletScreenScreenshotTest_testBankAccountSelectedState[].png differ diff --git a/link/src/test/snapshots/images/com.stripe.android.link.ui.wallet_WalletScreenScreenshotTest_testCollapsedState[].png b/link/src/test/snapshots/images/com.stripe.android.link.ui.wallet_WalletScreenScreenshotTest_testCollapsedState[].png new file mode 100644 index 00000000000..b71c72a355d Binary files /dev/null and b/link/src/test/snapshots/images/com.stripe.android.link.ui.wallet_WalletScreenScreenshotTest_testCollapsedState[].png differ diff --git a/link/src/test/snapshots/images/com.stripe.android.link.ui.wallet_WalletScreenScreenshotTest_testEmptyState[].png b/link/src/test/snapshots/images/com.stripe.android.link.ui.wallet_WalletScreenScreenshotTest_testEmptyState[].png new file mode 100644 index 00000000000..cd69858e104 Binary files /dev/null and b/link/src/test/snapshots/images/com.stripe.android.link.ui.wallet_WalletScreenScreenshotTest_testEmptyState[].png differ diff --git a/link/src/test/snapshots/images/com.stripe.android.link.ui.wallet_WalletScreenScreenshotTest_testExpandedState[].png b/link/src/test/snapshots/images/com.stripe.android.link.ui.wallet_WalletScreenScreenshotTest_testExpandedState[].png new file mode 100644 index 00000000000..af9ff73eb74 Binary files /dev/null and b/link/src/test/snapshots/images/com.stripe.android.link.ui.wallet_WalletScreenScreenshotTest_testExpandedState[].png differ