Skip to content

Commit

Permalink
Add animation to remove button on UpdatePaymentMethodUI (#9633)
Browse files Browse the repository at this point in the history
* Add animation to remove button on UpdatePaymentMethodUI

* Update screenshots

* Clear manual testing change

* Fix lint issue

* Ensure loading indicator stays until removing is complete

* Fix compilation issue

* Update screenshots
  • Loading branch information
amk-stripe authored Nov 15, 2024
1 parent 72208d3 commit 701bcb0
Show file tree
Hide file tree
Showing 23 changed files with 322 additions and 179 deletions.
6 changes: 2 additions & 4 deletions paymentsheet/api/paymentsheet.api
Original file line number Diff line number Diff line change
Expand Up @@ -2130,11 +2130,9 @@ public final class com/stripe/android/paymentsheet/state/PaymentSheetState$Loadi

public final class com/stripe/android/paymentsheet/ui/ComposableSingletons$EditPaymentMethodKt {
public static final field INSTANCE Lcom/stripe/android/paymentsheet/ui/ComposableSingletons$EditPaymentMethodKt;
public static field lambda-1 Lkotlin/jvm/functions/Function3;
public static field lambda-2 Lkotlin/jvm/functions/Function2;
public static field lambda-1 Lkotlin/jvm/functions/Function2;
public fun <init> ()V
public final fun getLambda-1$paymentsheet_release ()Lkotlin/jvm/functions/Function3;
public final fun getLambda-2$paymentsheet_release ()Lkotlin/jvm/functions/Function2;
public final fun getLambda-1$paymentsheet_release ()Lkotlin/jvm/functions/Function2;
}

public final class com/stripe/android/paymentsheet/ui/ComposableSingletons$MandateTextKt {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,18 +41,15 @@ internal fun PrimaryButton(
modifier: Modifier = Modifier,
isLoading: Boolean = false,
displayLockIcon: Boolean = false,
overrideBackgroundColor: Color? = null,
overrideOnBackgroundColor: Color? = null,
overrideBorderColor: Color? = null,
) {
// We need to use PaymentsTheme.primaryButtonStyle instead of MaterialTheme
// because of the rules API for primary button.
val context = LocalContext.current
val background = overrideBackgroundColor ?: Color(StripeTheme.primaryButtonStyle.getBackgroundColor(context))
val onBackground = overrideOnBackgroundColor ?: Color(StripeTheme.primaryButtonStyle.getOnBackgroundColor(context))
val background = Color(StripeTheme.primaryButtonStyle.getBackgroundColor(context))
val onBackground = Color(StripeTheme.primaryButtonStyle.getOnBackgroundColor(context))
val borderStroke = BorderStroke(
StripeTheme.primaryButtonStyle.shape.borderStrokeWidth.dp,
overrideBorderColor ?: Color(StripeTheme.primaryButtonStyle.getBorderStrokeColor(context))
Color(StripeTheme.primaryButtonStyle.getBorderStrokeColor(context))
)
val shape = RoundedCornerShape(
StripeTheme.primaryButtonStyle.shape.cornerRadius.dp
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -244,8 +244,7 @@ internal class SavedPaymentMethodMutator(
canRemove = canRemove.value,
displayableSavedPaymentMethod,
card = it,
onRemovePaymentMethod = ::removePaymentMethod,
navigateBack = { navigationHandler.pop() },
removeExecutor = ::removePaymentMethodInEditScreen,
)
)
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,23 +11,14 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.requiredHeight
import androidx.compose.material.ContentAlpha
import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.material.Icon
import androidx.compose.material.LocalContentAlpha
import androidx.compose.material.LocalMinimumInteractiveComponentEnforcement
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.material.TextButton
import androidx.compose.material.TextField
import androidx.compose.material.ripple.LocalRippleTheme
import androidx.compose.material.ripple.RippleAlpha
import androidx.compose.material.ripple.RippleTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
Expand All @@ -43,7 +34,6 @@ import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.stripe.android.common.ui.LoadingIndicator
import com.stripe.android.common.ui.PrimaryButton
import com.stripe.android.core.strings.resolvableString
import com.stripe.android.model.CardBrand
Expand All @@ -58,10 +48,8 @@ import com.stripe.android.uicore.elements.DROPDOWN_MENU_CLICKABLE_TEST_TAG
import com.stripe.android.uicore.elements.SectionCard
import com.stripe.android.uicore.elements.SingleChoiceDropdown
import com.stripe.android.uicore.elements.TextFieldColors
import com.stripe.android.uicore.getComposeTextStyle
import com.stripe.android.uicore.strings.resolve
import com.stripe.android.uicore.stripeColors
import com.stripe.android.uicore.stripeShapes
import com.stripe.android.uicore.utils.collectAsState
import com.stripe.android.R as PaymentsCoreR
import com.stripe.android.R as StripeR
Expand Down Expand Up @@ -138,9 +126,12 @@ internal fun EditPaymentMethodUi(

if (viewState.canRemove) {
RemoveButton(
title = R.string.stripe_paymentsheet_remove_card.resolvableString,
borderColor = Color.Transparent,
idle = isIdle,
removing = viewState.status == EditPaymentMethodViewState.Status.Removing,
onRemove = { viewActionHandler(OnRemovePressed) },
testTag = PAYMENT_SHEET_EDIT_SCREEN_REMOVE_BUTTON,
)
}
}
Expand Down Expand Up @@ -182,52 +173,6 @@ private fun Label(
)
}

@OptIn(ExperimentalMaterialApi::class)
@Composable
private fun RemoveButton(
idle: Boolean,
removing: Boolean,
onRemove: () -> Unit
) {
CompositionLocalProvider(
LocalContentAlpha provides if (removing) ContentAlpha.disabled else ContentAlpha.high,
LocalRippleTheme provides ErrorRippleTheme
) {
Box(
modifier = Modifier
.testTag(PAYMENT_SHEET_EDIT_SCREEN_REMOVE_BUTTON)
.fillMaxWidth()
.padding(
start = 8.dp,
end = 8.dp
)
.offset(y = 8.dp),
) {
CompositionLocalProvider(LocalMinimumInteractiveComponentEnforcement provides false) {
TextButton(
modifier = Modifier.align(Alignment.Center),
shape = MaterialTheme.stripeShapes.roundedCornerShape,
enabled = idle && !removing,
onClick = onRemove,
) {
Text(
text = stringResource(id = R.string.stripe_paymentsheet_remove_card),
color = MaterialTheme.colors.error.copy(LocalContentAlpha.current),
style = StripeTheme.primaryButtonStyle.getComposeTextStyle(),
)
}
}

if (removing) {
LoadingIndicator(
modifier = Modifier.align(Alignment.CenterEnd),
color = MaterialTheme.colors.error,
)
}
}
}
}

@Composable
private fun Dropdown(
viewState: EditPaymentMethodViewState,
Expand Down Expand Up @@ -292,24 +237,6 @@ private fun Dropdown(
}
}

private object ErrorRippleTheme : RippleTheme {
@Composable
override fun defaultColor(): Color {
return RippleTheme.defaultRippleColor(
MaterialTheme.colors.error,
lightTheme = MaterialTheme.colors.isLight
)
}

@Composable
override fun rippleAlpha(): RippleAlpha {
return RippleTheme.defaultRippleAlpha(
MaterialTheme.colors.error.copy(alpha = 0.25f),
lightTheme = MaterialTheme.colors.isLight
)
}
}

@Composable
@Preview(showBackground = true)
private fun EditPaymentMethodPreview() {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
package com.stripe.android.paymentsheet.ui

import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material.ContentAlpha
import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.material.LocalContentAlpha
import androidx.compose.material.LocalMinimumInteractiveComponentEnforcement
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.material.TextButton
import androidx.compose.material.ripple.LocalRippleTheme
import androidx.compose.material.ripple.RippleAlpha
import androidx.compose.material.ripple.RippleTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.dimensionResource
import androidx.compose.ui.unit.dp
import com.stripe.android.common.ui.LoadingIndicator
import com.stripe.android.core.strings.ResolvableString
import com.stripe.android.paymentsheet.R
import com.stripe.android.uicore.StripeTheme
import com.stripe.android.uicore.getBorderStrokeWidth
import com.stripe.android.uicore.getComposeTextStyle
import com.stripe.android.uicore.stripeShapes

@OptIn(ExperimentalMaterialApi::class)
@Composable
internal fun RemoveButton(
title: ResolvableString,
borderColor: Color,
idle: Boolean,
removing: Boolean,
onRemove: () -> Unit,
testTag: String,
) {
CompositionLocalProvider(
LocalContentAlpha provides if (removing) ContentAlpha.disabled else ContentAlpha.high,
LocalRippleTheme provides ErrorRippleTheme
) {
Box(
modifier = Modifier
.testTag(testTag)
.fillMaxWidth()
) {
CompositionLocalProvider(LocalMinimumInteractiveComponentEnforcement provides false) {
TextButton(
modifier = Modifier
.align(Alignment.Center)
.fillMaxWidth()
.height(height = dimensionResource(id = R.dimen.stripe_paymentsheet_primary_button_height)),
border = BorderStroke(
width = MaterialTheme.getBorderStrokeWidth(isSelected = false),
color = borderColor,
),
shape = MaterialTheme.stripeShapes.roundedCornerShape,
enabled = idle && !removing,
onClick = onRemove,
) {
Text(
text = title.resolve(LocalContext.current),
color = MaterialTheme.colors.error.copy(
LocalContentAlpha.current
),
style = StripeTheme.primaryButtonStyle.getComposeTextStyle(),
)
}
}

if (removing) {
LoadingIndicator(
modifier = Modifier.align(Alignment.CenterEnd)
.padding(
start = 8.dp,
end = 8.dp
),
color = MaterialTheme.colors.error,
)
}
}
}
}

private object ErrorRippleTheme : RippleTheme {
@Composable
override fun defaultColor(): Color {
return RippleTheme.defaultRippleColor(
MaterialTheme.colors.error,
lightTheme = MaterialTheme.colors.isLight
)
}

@Composable
override fun rippleAlpha(): RippleAlpha {
return RippleTheme.defaultRippleAlpha(
MaterialTheme.colors.error.copy(alpha = 0.25f),
lightTheme = MaterialTheme.colors.isLight
)
}
}
Original file line number Diff line number Diff line change
@@ -1,14 +1,31 @@
package com.stripe.android.paymentsheet.ui

import com.stripe.android.common.exception.stripeErrorMessage
import com.stripe.android.core.strings.ResolvableString
import com.stripe.android.model.PaymentMethod
import com.stripe.android.paymentsheet.DisplayableSavedPaymentMethod
import com.stripe.android.uicore.utils.combineAsStateFlow
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import kotlin.coroutines.CoroutineContext

internal interface UpdatePaymentMethodInteractor {
val isLiveMode: Boolean
val canRemove: Boolean
val displayableSavedPaymentMethod: DisplayableSavedPaymentMethod
val card: PaymentMethod.Card

val state: StateFlow<State>

data class State(
val error: ResolvableString?,
val isRemoving: Boolean,
)

fun handleViewAction(viewAction: ViewAction)

sealed class ViewAction {
Expand All @@ -21,14 +38,36 @@ internal class DefaultUpdatePaymentMethodInteractor(
override val canRemove: Boolean,
override val displayableSavedPaymentMethod: DisplayableSavedPaymentMethod,
override val card: PaymentMethod.Card,
val onRemovePaymentMethod: (PaymentMethod) -> Unit,
val navigateBack: () -> Unit,
private val removeExecutor: PaymentMethodRemoveOperation,
workContext: CoroutineContext = Dispatchers.Default,
) : UpdatePaymentMethodInteractor {
private val coroutineScope = CoroutineScope(workContext + SupervisorJob())
private val error = MutableStateFlow<ResolvableString?>(null)
private val isRemoving = MutableStateFlow(false)

private val _state = combineAsStateFlow(
error,
isRemoving,
) { error, isRemoving ->
UpdatePaymentMethodInteractor.State(
error = error,
isRemoving = isRemoving,
)
}
override val state = _state

override fun handleViewAction(viewAction: UpdatePaymentMethodInteractor.ViewAction) {
when (viewAction) {
UpdatePaymentMethodInteractor.ViewAction.RemovePaymentMethod -> {
onRemovePaymentMethod(displayableSavedPaymentMethod.paymentMethod)
navigateBack()
coroutineScope.launch {
error.emit(null)
isRemoving.emit(true)

val removeError = removeExecutor(displayableSavedPaymentMethod.paymentMethod)

isRemoving.emit(false)
error.emit(removeError?.stripeErrorMessage())
}
}
}
}
Expand Down
Loading

0 comments on commit 701bcb0

Please sign in to comment.