diff --git a/link/api/link.api b/link/api/link.api index e0dea44cbc9..5004154dd36 100644 --- a/link/api/link.api +++ b/link/api/link.api @@ -320,6 +320,7 @@ public final class com/stripe/android/link/ui/ComposableSingletons$LinkAppBarKt public static field lambda-6 Lkotlin/jvm/functions/Function2; public static field lambda-7 Lkotlin/jvm/functions/Function2; public static field lambda-8 Lkotlin/jvm/functions/Function2; + public static field lambda-9 Lkotlin/jvm/functions/Function2; public fun ()V public final fun getLambda-1$link_release ()Lkotlin/jvm/functions/Function2; public final fun getLambda-2$link_release ()Lkotlin/jvm/functions/Function2; @@ -329,6 +330,7 @@ public final class com/stripe/android/link/ui/ComposableSingletons$LinkAppBarKt public final fun getLambda-6$link_release ()Lkotlin/jvm/functions/Function2; public final fun getLambda-7$link_release ()Lkotlin/jvm/functions/Function2; public final fun getLambda-8$link_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda-9$link_release ()Lkotlin/jvm/functions/Function2; } public final class com/stripe/android/link/ui/ComposableSingletons$PrimaryButtonKt { diff --git a/link/res/values/strings.xml b/link/res/values/strings.xml index cc5aea9a6ae..051eb7ef582 100644 --- a/link/res/values/strings.xml +++ b/link/res/values/strings.xml @@ -2,6 +2,7 @@ Link Back + Menu Save my info for secure 1-click checkout @@ -9,6 +10,7 @@ Pay faster at %1$s and thousands of merchants. By joining Link, you agree to the <a href=\"https://link.co/terms\">Terms</a> and <a href=\"https://link.co/privacy\">Privacy Policy</a>. Join Link + Log out of Link Enter your verification code Enter the code sent to %1$s to use Link to pay by default. diff --git a/link/src/androidTest/java/com/stripe/android/link/ui/LinkAppBarStateTest.kt b/link/src/androidTest/java/com/stripe/android/link/ui/LinkAppBarStateTest.kt index fbfd9ad0293..8390b279b33 100644 --- a/link/src/androidTest/java/com/stripe/android/link/ui/LinkAppBarStateTest.kt +++ b/link/src/androidTest/java/com/stripe/android/link/ui/LinkAppBarStateTest.kt @@ -37,7 +37,8 @@ internal class LinkAppBarStateTest { val expected = LinkAppBarState( navigationIcon = R.drawable.ic_link_close, - hideHeader = false, + showHeader = true, + showOverflowMenu = false, email = null ) @@ -54,7 +55,8 @@ internal class LinkAppBarStateTest { val expected = LinkAppBarState( navigationIcon = R.drawable.ic_link_close, - hideHeader = false, + showHeader = true, + showOverflowMenu = false, email = null ) @@ -71,7 +73,8 @@ internal class LinkAppBarStateTest { val expected = LinkAppBarState( navigationIcon = R.drawable.ic_link_close, - hideHeader = false, + showHeader = true, + showOverflowMenu = false, email = "someone@stripe.com" ) @@ -88,7 +91,8 @@ internal class LinkAppBarStateTest { val expected = LinkAppBarState( navigationIcon = R.drawable.ic_link_close, - hideHeader = false, + showHeader = true, + showOverflowMenu = true, email = "someone@stripe.com" ) @@ -105,7 +109,8 @@ internal class LinkAppBarStateTest { val expected = LinkAppBarState( navigationIcon = R.drawable.ic_link_back, - hideHeader = true, + showHeader = false, + showOverflowMenu = false, email = null ) @@ -122,7 +127,8 @@ internal class LinkAppBarStateTest { val expected = LinkAppBarState( navigationIcon = R.drawable.ic_link_back, - hideHeader = true, + showHeader = false, + showOverflowMenu = false, email = null ) @@ -139,7 +145,8 @@ internal class LinkAppBarStateTest { val expected = LinkAppBarState( navigationIcon = R.drawable.ic_link_close, - hideHeader = false, + showHeader = true, + showOverflowMenu = false, email = null ) diff --git a/link/src/androidTest/java/com/stripe/android/link/ui/LinkAppBarTest.kt b/link/src/androidTest/java/com/stripe/android/link/ui/LinkAppBarTest.kt index 79f1fcd4abb..a86bba1156c 100644 --- a/link/src/androidTest/java/com/stripe/android/link/ui/LinkAppBarTest.kt +++ b/link/src/androidTest/java/com/stripe/android/link/ui/LinkAppBarTest.kt @@ -27,26 +27,42 @@ internal class LinkAppBarTest { } @Test - fun on_button_click_button_callback_is_called() { + fun on_back_button_click_callback_is_called() { var count = 0 - setContent(onButtonClick = { count++ }) + setContent(onBackPress = { count++ }) composeTestRule.onNodeWithContentDescription("Back").performClick() + + assertThat(count).isEqualTo(1) + } + + @Test + fun on_overflow_button_click_callback_is_called() { + var count = 0 + setContent(showBottomSheetContent = { count++ }) + + composeTestRule.onNodeWithContentDescription("Menu").performClick() + assertThat(count).isEqualTo(1) } private fun setContent( email: String? = null, - onButtonClick: () -> Unit = {} + onBackPress: () -> Unit = {}, + onLogout: () -> Unit = {}, + showBottomSheetContent: (BottomSheetContent?) -> Unit = {} ) = composeTestRule.setContent { DefaultLinkTheme { LinkAppBar( state = LinkAppBarState( navigationIcon = R.drawable.ic_link_close, - hideHeader = false, + showHeader = true, + showOverflowMenu = true, email = email ), - onButtonClick = onButtonClick + onBackPress = onBackPress, + onLogout = onLogout, + showBottomSheetContent = showBottomSheetContent ) } } diff --git a/link/src/androidTest/java/com/stripe/android/link/ui/LinkLogoutSheetTest.kt b/link/src/androidTest/java/com/stripe/android/link/ui/LinkLogoutSheetTest.kt new file mode 100644 index 00000000000..f0f9e9cc0ac --- /dev/null +++ b/link/src/androidTest/java/com/stripe/android/link/ui/LinkLogoutSheetTest.kt @@ -0,0 +1,58 @@ +package com.stripe.android.link.ui + +import androidx.activity.ComponentActivity +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.google.common.truth.Truth.assertThat +import com.stripe.android.link.theme.DefaultLinkTheme +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class LinkLogoutSheetTest { + + @get:Rule + val composeTestRule = createAndroidComposeRule() + + @Test + fun on_logout_button_click_callback_is_called() { + val clickRecorder = MockClickRecorder() + setContent(clickRecorder) + + composeTestRule.onNodeWithText("Log out of Link").performClick() + + assertThat(clickRecorder).isEqualTo(MockClickRecorder(logoutClicked = true)) + } + + @Test + fun on_cancel_button_click_callback_is_called() { + val clickRecorder = MockClickRecorder() + setContent(clickRecorder) + + composeTestRule.onNodeWithText("Cancel").performClick() + + assertThat(clickRecorder).isEqualTo(MockClickRecorder(cancelClicked = true)) + } + + private fun setContent( + clickRecorder: MockClickRecorder + ) = composeTestRule.setContent { + DefaultLinkTheme { + LinkLogoutSheet( + onLogoutClick = clickRecorder::onLogoutClicked, + onCancelClick = clickRecorder::onCancelClick + ) + } + } + + private data class MockClickRecorder( + var logoutClicked: Boolean = false, + var cancelClicked: Boolean = false + ) { + fun onLogoutClicked() { logoutClicked = true } + fun onCancelClick() { cancelClicked = true } + } +} diff --git a/link/src/main/java/com/stripe/android/link/LinkActivity.kt b/link/src/main/java/com/stripe/android/link/LinkActivity.kt index 9cd54c0930a..78d3b14e661 100644 --- a/link/src/main/java/com/stripe/android/link/LinkActivity.kt +++ b/link/src/main/java/com/stripe/android/link/LinkActivity.kt @@ -125,7 +125,18 @@ internal class LinkActivity : ComponentActivity() { LinkAppBar( state = appBarState, - onButtonClick = { viewModel.navigator.onBack(userInitiated = true) } + onBackPressed = viewModel::onBackPressed, + onLogout = viewModel::logout, + showBottomSheetContent = { + if (it == null) { + coroutineScope.launch { + sheetState.hide() + bottomSheetContent = null + } + } else { + bottomSheetContent = it + } + } ) NavHost(navController, LinkScreen.Loading.route) { diff --git a/link/src/main/java/com/stripe/android/link/LinkActivityViewModel.kt b/link/src/main/java/com/stripe/android/link/LinkActivityViewModel.kt index ad81ea6c19a..a6702a6617e 100644 --- a/link/src/main/java/com/stripe/android/link/LinkActivityViewModel.kt +++ b/link/src/main/java/com/stripe/android/link/LinkActivityViewModel.kt @@ -49,6 +49,15 @@ internal class LinkActivityViewModel @Inject internal constructor( confirmationManager.setupPaymentLauncher(activityResultCaller) } + fun onBackPressed() { + navigator.onBack(userInitiated = true) + } + + fun logout() { + navigator.dismiss() + linkAccountManager.logout() + } + fun unregisterFromActivity() { confirmationManager.invalidatePaymentLauncher() } diff --git a/link/src/main/java/com/stripe/android/link/ui/LinkAppBar.kt b/link/src/main/java/com/stripe/android/link/ui/LinkAppBar.kt index d2595c9456f..e92a67fe3d7 100644 --- a/link/src/main/java/com/stripe/android/link/ui/LinkAppBar.kt +++ b/link/src/main/java/com/stripe/android/link/ui/LinkAppBar.kt @@ -5,17 +5,17 @@ import androidx.compose.animation.core.animateFloatAsState import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.defaultMinSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width import androidx.compose.material.Icon import androidx.compose.material.IconButton import androidx.compose.material.MaterialTheme import androidx.compose.material.Surface import androidx.compose.material.Text +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.MoreVert import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment @@ -29,13 +29,14 @@ import androidx.compose.ui.unit.dp import com.stripe.android.link.R import com.stripe.android.link.theme.AppBarHeight import com.stripe.android.link.theme.DefaultLinkTheme -import com.stripe.android.link.theme.MinimumTouchTargetSize import com.stripe.android.link.theme.linkColors @Composable internal fun LinkAppBar( state: LinkAppBarState, - onButtonClick: () -> Unit + onBackPressed: () -> Unit, + onLogout: () -> Unit, + showBottomSheetContent: (BottomSheetContent?) -> Unit ) { Row( modifier = Modifier @@ -45,7 +46,7 @@ internal fun LinkAppBar( verticalAlignment = Alignment.Top ) { IconButton( - onClick = onButtonClick, + onClick = onBackPressed, modifier = Modifier.padding(4.dp) ) { Icon( @@ -55,7 +56,7 @@ internal fun LinkAppBar( ) } - val contentAlpha by animateFloatAsState(targetValue = if (state.hideHeader) 0f else 1f) + val contentAlpha by animateFloatAsState(targetValue = if (state.showHeader) 1f else 0f) Column( modifier = Modifier @@ -88,7 +89,35 @@ internal fun LinkAppBar( } } - Spacer(modifier = Modifier.width(MinimumTouchTargetSize)) + val overflowIconAlpha by animateFloatAsState( + targetValue = if (state.showOverflowMenu) 1f else 0f + ) + + IconButton( + onClick = { + showBottomSheetContent { + LinkLogoutSheet( + onLogoutClick = { + showBottomSheetContent(null) + onLogout() + }, + onCancelClick = { + showBottomSheetContent(null) + } + ) + } + }, + enabled = state.showOverflowMenu, + modifier = Modifier + .alpha(overflowIconAlpha) + .padding(4.dp) + ) { + Icon( + imageVector = Icons.Default.MoreVert, + contentDescription = stringResource(R.string.menu), + tint = MaterialTheme.linkColors.closeButton + ) + } } } @@ -100,10 +129,13 @@ private fun LinkAppBar() { LinkAppBar( state = LinkAppBarState( navigationIcon = R.drawable.ic_link_close, - hideHeader = false, + showHeader = true, + showOverflowMenu = true, email = "email@example.com" ), - onButtonClick = {} + onBackPressed = {}, + onLogout = {}, + showBottomSheetContent = {} ) } } @@ -117,10 +149,13 @@ private fun LinkAppBar_NoEmail() { LinkAppBar( state = LinkAppBarState( navigationIcon = R.drawable.ic_link_close, - hideHeader = false, + showHeader = true, + showOverflowMenu = true, email = null ), - onButtonClick = {} + onBackPressed = {}, + onLogout = {}, + showBottomSheetContent = {} ) } } @@ -134,10 +169,13 @@ private fun LinkAppBar_ChildScreen() { LinkAppBar( state = LinkAppBarState( navigationIcon = R.drawable.ic_link_back, - hideHeader = true, + showHeader = false, + showOverflowMenu = false, email = "email@example.com" ), - onButtonClick = {} + onBackPressed = {}, + onLogout = {}, + showBottomSheetContent = {} ) } } @@ -151,10 +189,13 @@ private fun LinkAppBar_ChildScreen_NoEmail() { LinkAppBar( state = LinkAppBarState( navigationIcon = R.drawable.ic_link_back, - hideHeader = true, + showHeader = false, + showOverflowMenu = false, email = null ), - onButtonClick = {} + onBackPressed = {}, + onLogout = {}, + showBottomSheetContent = {} ) } } diff --git a/link/src/main/java/com/stripe/android/link/ui/LinkAppBarState.kt b/link/src/main/java/com/stripe/android/link/ui/LinkAppBarState.kt index 9d544f977da..65283b8d898 100644 --- a/link/src/main/java/com/stripe/android/link/ui/LinkAppBarState.kt +++ b/link/src/main/java/com/stripe/android/link/ui/LinkAppBarState.kt @@ -8,7 +8,8 @@ import com.stripe.android.link.R internal data class LinkAppBarState( @DrawableRes val navigationIcon: Int, - val hideHeader: Boolean, + val showHeader: Boolean, + val showOverflowMenu: Boolean, val email: String? ) @@ -32,13 +33,16 @@ internal fun rememberLinkAppBarState( val hideHeader = currentRoute in routesWithoutHeader val hideEmail = email.isNullOrBlank() || currentRoute in routesWithoutEmail + val showOverflowMenu = currentRoute == LinkScreen.Wallet.route + LinkAppBarState( navigationIcon = if (isRootScreen) { R.drawable.ic_link_close } else { R.drawable.ic_link_back }, - hideHeader = hideHeader, + showHeader = !hideHeader, + showOverflowMenu = showOverflowMenu, email = email?.takeIf { it.isNotBlank() && !hideEmail } ) } diff --git a/link/src/main/java/com/stripe/android/link/ui/LinkLogoutSheet.kt b/link/src/main/java/com/stripe/android/link/ui/LinkLogoutSheet.kt new file mode 100644 index 00000000000..192f404ead9 --- /dev/null +++ b/link/src/main/java/com/stripe/android/link/ui/LinkLogoutSheet.kt @@ -0,0 +1,35 @@ +package com.stripe.android.link.ui + +import androidx.compose.runtime.Composable +import com.stripe.android.link.R +import com.stripe.android.link.ui.menus.LinkMenu +import com.stripe.android.link.ui.menus.LinkMenuItem + +internal sealed class LinkLogoutMenuItem( + override val textResId: Int, + override val isDestructive: Boolean = false +) : LinkMenuItem { + object Logout : LinkLogoutMenuItem(textResId = R.string.log_out, isDestructive = true) + object Cancel : LinkLogoutMenuItem(textResId = R.string.cancel) +} + +@Composable +internal fun LinkLogoutSheet( + onLogoutClick: () -> Unit, + onCancelClick: () -> Unit +) { + val items = listOf( + LinkLogoutMenuItem.Logout, + LinkLogoutMenuItem.Cancel + ) + + LinkMenu( + items = items, + onItemPress = { item -> + when (item) { + LinkLogoutMenuItem.Logout -> onLogoutClick() + LinkLogoutMenuItem.Cancel -> onCancelClick() + } + } + ) +} diff --git a/link/src/main/java/com/stripe/android/link/ui/verification/VerificationDialog.kt b/link/src/main/java/com/stripe/android/link/ui/verification/VerificationDialog.kt index 6416d9af589..2bfd9f90eb2 100644 --- a/link/src/main/java/com/stripe/android/link/ui/verification/VerificationDialog.kt +++ b/link/src/main/java/com/stripe/android/link/ui/verification/VerificationDialog.kt @@ -86,7 +86,13 @@ fun LinkVerificationDialog( LinkAppBar( state = appBarState, - onButtonClick = onDismiss + onBackPressed = onDismiss, + onLogout = { + // This can't be invoked from the verification dialog + }, + showBottomSheetContent = { + // This can't be invoked from the verification dialog + } ) VerificationBody( diff --git a/link/src/test/java/com/stripe/android/link/LinkActivityViewModelTest.kt b/link/src/test/java/com/stripe/android/link/LinkActivityViewModelTest.kt index ba8e5e2685c..972fb52e147 100644 --- a/link/src/test/java/com/stripe/android/link/LinkActivityViewModelTest.kt +++ b/link/src/test/java/com/stripe/android/link/LinkActivityViewModelTest.kt @@ -2,6 +2,7 @@ package com.stripe.android.link import android.app.Application import androidx.lifecycle.Lifecycle +import androidx.navigation.NavHostController import androidx.savedstate.SavedStateRegistry import androidx.savedstate.SavedStateRegistryOwner import androidx.test.core.app.ApplicationProvider @@ -20,7 +21,9 @@ import org.junit.Test import org.junit.runner.RunWith import org.mockito.kotlin.any import org.mockito.kotlin.argWhere +import org.mockito.kotlin.doReturn import org.mockito.kotlin.mock +import org.mockito.kotlin.never import org.mockito.kotlin.reset import org.mockito.kotlin.spy import org.mockito.kotlin.times @@ -132,6 +135,49 @@ class LinkActivityViewModelTest { ) } + @Test + fun `Navigating back on root screen dismisses Link, but doesn't log out user`() { + val viewModel = createViewModel() + setupNavigation(hasBackStack = false) + + viewModel.onBackPressed() + + spy(navigator).dismiss() + verify(linkAccountManager, never()).logout() + } + + @Test + fun `Navigating back on child screen navigates back, but doesn't dismiss Link`() { + val viewModel = createViewModel() + setupNavigation(hasBackStack = true) + + viewModel.onBackPressed() + + verify(navigator, never()).dismiss() + verify(linkAccountManager, never()).logout() + } + + @Test + fun `Navigating back is prevented when back navigation is disabled`() { + val viewModel = createViewModel() + setupNavigation(userNavigationEnabled = false) + + viewModel.onBackPressed() + + verify(navigator, never()).dismiss() + verify(linkAccountManager, never()).logout() + } + + @Test + fun `Logging out logs out the user and dismisses Link`() { + val viewModel = createViewModel() + + viewModel.logout() + + verify(navigator).dismiss() + verify(linkAccountManager).logout() + } + private fun createViewModel(args: LinkActivityContract.Args = defaultArgs) = LinkActivityViewModel( args, @@ -140,6 +186,17 @@ class LinkActivityViewModelTest { confirmationManager ) + private fun setupNavigation( + hasBackStack: Boolean = false, + userNavigationEnabled: Boolean = true + ) { + val mockNavController = mock { + on { popBackStack() } doReturn hasBackStack + } + whenever(navigator.userNavigationEnabled).thenReturn(userNavigationEnabled) + whenever(navigator.navigationController).thenReturn(mockNavController) + } + private companion object { const val INJECTOR_KEY = "injectorKey" const val PRODUCT_USAGE = "productUsage" diff --git a/payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/PostalCodeConfig.kt b/payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/PostalCodeConfig.kt index b365197b7ca..135db60e2cc 100644 --- a/payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/PostalCodeConfig.kt +++ b/payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/PostalCodeConfig.kt @@ -27,9 +27,10 @@ internal class PostalCodeConfig( override fun isValid(): Boolean { return when (format) { is CountryPostalFormat.Other -> input.isNotBlank() - else -> + else -> { input.length in format.minimumLength..format.maximumLength && input.matches(format.regexPattern) + } } }