diff --git a/demo-app/src/main/java/fi/paytrail/demo/MainActivity.kt b/demo-app/src/main/java/fi/paytrail/demo/MainActivity.kt index 528dcc5..c581edb 100644 --- a/demo-app/src/main/java/fi/paytrail/demo/MainActivity.kt +++ b/demo-app/src/main/java/fi/paytrail/demo/MainActivity.kt @@ -1,7 +1,6 @@ package fi.paytrail.demo import android.os.Bundle -import android.util.Log import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.compose.animation.AnimatedVisibility @@ -37,17 +36,23 @@ import fi.paytrail.paymentsdk.model.PaytrailPaymentState.State.PAYMENT_OK import fi.paytrail.paymentsdk.tokenization.AddCardForm import fi.paytrail.paymentsdk.tokenization.PayWithTokenizationId import fi.paytrail.paymentsdk.tokenization.TokenPaymentChargeType +import fi.paytrail.paymentsdk.tokenization.TokenPaymentType import fi.paytrail.paymentsdk.tokenization.model.AddCardRequest import fi.paytrail.paymentsdk.tokenization.model.AddCardResult import fi.paytrail.sdk.apiclient.models.Callbacks import kotlinx.coroutines.launch import javax.inject.Inject +private const val NAV_ARG_TOKENIZATION_ID = "tokenizationId" +private const val NAV_ARG_PAYMENT_TYPE = "paymentType" +private const val NAV_ARG_CHARGE_TYPE = "chargeType" + private const val NAV_SHOPPING_CART = "shopping_cart" private const val NAV_PAYMENT = "payment" private const val NAV_CARDS = "cards" private const val NAV_ADD_CARD = "cards/tokenize" -private const val NAV_PAY_WITH_TOKENIZATION_ID = "cards/charge/{tokenizationId}" +private const val NAV_PAY_WITH_TOKENIZATION_ID = + "cards/{$NAV_ARG_TOKENIZATION_ID}/{$NAV_ARG_PAYMENT_TYPE}/{$NAV_ARG_CHARGE_TYPE}" @AndroidEntryPoint class MainActivity : ComponentActivity() { @@ -100,10 +105,6 @@ class MainActivity : ComponentActivity() { // If you want transaction ID, } } - Log.i( - "MainActivity", - "state: ${state.state} Transaction ID ${state.finalRedirectRequest?.transactionId}", - ) }, ) } @@ -140,12 +141,12 @@ class MainActivity : ComponentActivity() { TokenizedCreditCards( modifier = Modifier.fillMaxSize(), viewModel = hiltViewModel(), - payWithCardAction = { tokenizationId -> + payWithCardAction = { tokenizationId: String, paymentType: TokenPaymentType, chargeType: TokenPaymentChargeType -> navController.navigate( - NAV_PAY_WITH_TOKENIZATION_ID.replace( - "{tokenizationId}", - tokenizationId, - ), + NAV_PAY_WITH_TOKENIZATION_ID + .replace("{$NAV_ARG_TOKENIZATION_ID}", tokenizationId) + .replace("{$NAV_ARG_PAYMENT_TYPE}", paymentType.name) + .replace("{$NAV_ARG_CHARGE_TYPE}", chargeType.name), ) }, addCardAction = { navController.navigate(NAV_ADD_CARD) }, @@ -155,16 +156,24 @@ class MainActivity : ComponentActivity() { composable( route = NAV_PAY_WITH_TOKENIZATION_ID, arguments = listOf( - navArgument("tokenizationId") { type = NavType.StringType }, + navArgument(NAV_ARG_TOKENIZATION_ID) { type = NavType.StringType }, + navArgument(NAV_ARG_PAYMENT_TYPE) { type = NavType.StringType }, + navArgument(NAV_ARG_CHARGE_TYPE) { type = NavType.StringType }, ), ) { - val tokenizationId = it.arguments!!.getString("tokenizationId")!! + val args = it.arguments!! + val tokenizationId = args.getString(NAV_ARG_TOKENIZATION_ID)!! + val chargeType = + TokenPaymentChargeType.valueOf(args.getString(NAV_ARG_CHARGE_TYPE)!!) + val paymentType = + TokenPaymentType.valueOf(args.getString(NAV_ARG_PAYMENT_TYPE)!!) PayWithTokenizationId( modifier = Modifier.fillMaxSize(), paymentRequest = shoppingCartRepository.cartAsPaymentRequest(), tokenizationId = tokenizationId, onPaymentStateChanged = onPaymentStateChanged, - chargeType = TokenPaymentChargeType.CHARGE, + paymentType = paymentType, + chargeType = chargeType, ) } diff --git a/demo-app/src/main/java/fi/paytrail/demo/tokenization/TokenizedCreditCards.kt b/demo-app/src/main/java/fi/paytrail/demo/tokenization/TokenizedCreditCards.kt index 8876aa6..457d6f4 100644 --- a/demo-app/src/main/java/fi/paytrail/demo/tokenization/TokenizedCreditCards.kt +++ b/demo-app/src/main/java/fi/paytrail/demo/tokenization/TokenizedCreditCards.kt @@ -1,6 +1,7 @@ package fi.paytrail.demo.tokenization import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.clickable import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -13,34 +14,57 @@ import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Button import androidx.compose.material3.Divider +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.SheetState +import androidx.compose.material3.SheetValue import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState 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.res.stringResource import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import fi.paytrail.demo.R import fi.paytrail.paymentsdk.RequestStatus +import fi.paytrail.paymentsdk.tokenization.TokenPaymentChargeType +import fi.paytrail.paymentsdk.tokenization.TokenPaymentType import fi.paytrail.sdk.apiclient.models.TokenizationRequestResponse import kotlinx.coroutines.flow.Flow +@OptIn(ExperimentalMaterial3Api::class) @Composable fun TokenizedCreditCards( modifier: Modifier, - viewModel: ManageCardsViewModel, - payWithCardAction: (String) -> Unit, + viewModel: TokenizedCreditCardsViewModel, + payWithCardAction: (String, TokenPaymentType, TokenPaymentChargeType) -> Unit, addCardAction: () -> Unit, ) { val cards: List>>> = viewModel.cards .collectAsState(initial = emptyList()) .value + val bottomSheetState = rememberModalBottomSheetState( + skipPartiallyExpanded = true, + ) + val sheetTokenizationId = + viewModel.actionsMenuTokenizationId.collectAsState(initial = null).value + LaunchedEffect(sheetTokenizationId) { + if (sheetTokenizationId.isNullOrEmpty()) bottomSheetState.hide() else bottomSheetState.show() + } + Column(modifier) { Box( modifier = Modifier @@ -50,15 +74,133 @@ fun TokenizedCreditCards( if (cards.isNotEmpty()) { CreditCardListing( cards = cards, - onCardClick = { tokenizationId, _ -> payWithCardAction(tokenizationId) }, - onCardLongClick = { tokenizationId, _ -> viewModel.removeCard(tokenizationId) }, + onCardClick = { tokenizationId, _ -> + payWithCardAction( + tokenizationId, + TokenPaymentType.CIT, + TokenPaymentChargeType.CHARGE, + ) + }, + onCardLongClick = { tokenizationId, _ -> + viewModel.showCardActions( + tokenizationId, + ) + }, ) } else { NoSavedCards() } } + // TODO: Show most recent auth hold & allow commit/revoke + ManageCreditCardsBottomPanel(addCardAction = addCardAction) + + if (bottomSheetState.currentValue != SheetValue.Hidden) { + CardActionsBottomSheet( + viewModel = viewModel, + payWithCardAction = payWithCardAction, + bottomSheetState = bottomSheetState, + sheetTokenizationId = sheetTokenizationId, + ) + } + } +} + +@Composable +@OptIn(ExperimentalMaterial3Api::class) +private fun CardActionsBottomSheet( + viewModel: TokenizedCreditCardsViewModel, + payWithCardAction: (String, TokenPaymentType, TokenPaymentChargeType) -> Unit, + bottomSheetState: SheetState, + sheetTokenizationId: String?, +) { + ModalBottomSheet( + modifier = Modifier.fillMaxWidth(), + onDismissRequest = viewModel::hideCardActions, + sheetState = bottomSheetState, + shape = RoundedCornerShape(topStart = 6.dp, topEnd = 6.dp), + scrimColor = Color.Black.copy(alpha = 0.4f), + ) { + + Column(modifier = Modifier.fillMaxWidth()) { + Divider() + BottomSheetAction( + text = stringResource(R.string.card_action_pay_cit_charge), + onClick = { + payWithCardAction( + sheetTokenizationId!!, + TokenPaymentType.CIT, + TokenPaymentChargeType.CHARGE, + ) + }, + ) + Divider() + BottomSheetAction( + text = stringResource(R.string.card_action_pay_cit_auth_hold), + onClick = { + payWithCardAction( + sheetTokenizationId!!, + TokenPaymentType.CIT, + TokenPaymentChargeType.AUTH_HOLD, + ) + }, + ) + Divider() + BottomSheetAction( + text = stringResource(R.string.card_action_pay_mit_charge), + onClick = { + payWithCardAction( + sheetTokenizationId!!, + TokenPaymentType.MIT, + TokenPaymentChargeType.CHARGE, + ) + }, + ) + Divider() + BottomSheetAction( + text = stringResource(R.string.card_action_pay_mit_auth_hold), + onClick = { + payWithCardAction( + sheetTokenizationId!!, + TokenPaymentType.MIT, + TokenPaymentChargeType.AUTH_HOLD, + ) + }, + ) + Divider() + BottomSheetAction( + text = stringResource(R.string.card_action_remove_card), + onClick = { viewModel.removeCard(sheetTokenizationId!!) }, + ) + Divider() + + Spacer(modifier = Modifier.height(32.dp)) + } + } +} + +@Preview +@Composable +fun BottomSheetAction( + modifier: Modifier = Modifier, + text: String = "Action", + onClick: () -> Unit = {}, +) { + Box( + modifier = modifier + .clickable(onClick = onClick) + .fillMaxWidth() + .padding(16.dp), + ) { + Text( + text = text, + fontSize = 16.sp, + textAlign = TextAlign.Start, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.labelLarge, + ) } } @@ -80,6 +222,7 @@ private fun CreditCardListing( ) { item("card-header-prompt") { Text(stringResource(R.string.manage_cards_prompt)) + Spacer(modifier = Modifier.height(16.dp)) } cards.forEachIndexed { index, cardRequestFlow -> diff --git a/demo-app/src/main/java/fi/paytrail/demo/tokenization/ManageCardsViewModel.kt b/demo-app/src/main/java/fi/paytrail/demo/tokenization/TokenizedCreditCardsViewModel.kt similarity index 71% rename from demo-app/src/main/java/fi/paytrail/demo/tokenization/ManageCardsViewModel.kt rename to demo-app/src/main/java/fi/paytrail/demo/tokenization/TokenizedCreditCardsViewModel.kt index 9624c94..ad2423a 100644 --- a/demo-app/src/main/java/fi/paytrail/demo/tokenization/ManageCardsViewModel.kt +++ b/demo-app/src/main/java/fi/paytrail/demo/tokenization/TokenizedCreditCardsViewModel.kt @@ -3,10 +3,10 @@ package fi.paytrail.demo.tokenization import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel -import fi.paytrail.demo.repository.ShoppingCartRepository import fi.paytrail.paymentsdk.RequestStatus import fi.paytrail.sdk.apiclient.models.TokenizationRequestResponse import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.shareIn @@ -19,17 +19,19 @@ data class TokenizedCreditCard( ) @HiltViewModel -class ManageCardsViewModel @Inject constructor( +class TokenizedCreditCardsViewModel @Inject constructor( private val cardsRepository: SavedCardsRepository, - private val shoppingCartRepository: ShoppingCartRepository, ) : ViewModel() { private val tokenizationIds = cardsRepository.observeSavedTokenizationIDs() + val actionsMenuTokenizationId = MutableStateFlow(null) + // TODO: Cache card information (in repository) so adding/removing cards does not + // trigger reloads. val cards: Flow>>>> = tokenizationIds.map { ids -> ids.map { tokenizationId -> - val requestFlow = getTokenizationResult(tokenizationId) + val requestFlow = getTokenizedCardDetails(tokenizationId) tokenizationId to requestFlow.map { requestStatus -> requestStatus.map { tokenizationResponse -> TokenizedCreditCard(tokenizationId, tokenizationResponse) @@ -42,7 +44,7 @@ class ManageCardsViewModel @Inject constructor( replay = 1, ) - private fun getTokenizationResult(tokenizationId: String) = + private fun getTokenizedCardDetails(tokenizationId: String) = cardsRepository.getToken(tokenizationId) .shareIn( viewModelScope, @@ -50,6 +52,17 @@ class ManageCardsViewModel @Inject constructor( replay = 1, ) as Flow> - fun removeCard(tokenizationId: String) = + fun removeCard(tokenizationId: String) { + hideCardActions() viewModelScope.launch { cardsRepository.removeTokenizationId(tokenizationId) } + } + + fun showCardActions(tokenizationId: String) { + actionsMenuTokenizationId.value = tokenizationId + } + + fun hideCardActions() { + actionsMenuTokenizationId.value = null + } + } diff --git a/demo-app/src/main/res/values/strings.xml b/demo-app/src/main/res/values/strings.xml index 066a6b1..75a8990 100644 --- a/demo-app/src/main/res/values/strings.xml +++ b/demo-app/src/main/res/values/strings.xml @@ -6,5 +6,10 @@ Payment status: %1$s Transaction id: %1$s Add card - Select a card to pay with, or add a new card. Long-press to remove a saved card. + Click on a card to create CIT payment. Long-press for more actions. + Pay (CIT / Charge) + Pay (CIT / Auth Hold) + Pay (MIT / Charge) + Pay (MIT / Auth Hold) + Remove Card \ No newline at end of file diff --git a/payment-sdk/src/main/java/fi/paytrail/paymentsdk/tokenization/PayWithToken.kt b/payment-sdk/src/main/java/fi/paytrail/paymentsdk/tokenization/PayWithToken.kt index c62232f..19815d9 100644 --- a/payment-sdk/src/main/java/fi/paytrail/paymentsdk/tokenization/PayWithToken.kt +++ b/payment-sdk/src/main/java/fi/paytrail/paymentsdk/tokenization/PayWithToken.kt @@ -17,9 +17,9 @@ enum class TokenPaymentChargeType { CHARGE, } -enum class TokenPaymentType(s: String) { - MIT("mit"), - CIT("cit"), +enum class TokenPaymentType { + MIT, + CIT, } @Composable diff --git a/test_cards.txt b/test_cards.txt index faff1c9..3c4a152 100644 --- a/test_cards.txt +++ b/test_cards.txt @@ -1,11 +1,11 @@ -OK OK 4153 0139 9970 0313 11/2023 313 Successful 3D Secure. 3DS form password "secret". -OK OK 4153 0139 9970 0321 11/2023 321 Successful 3D Secure. 3DS form will be automatically completed. -OK OK 4153 0139 9970 0339 11/2023 339 3D Secure attempt. 3DS will be automatically attempted. -(OK) (OK) 4153 0139 9970 0347 11/2023 347 3D Secure fails. The "cardholder_authentication" response parameter will be "no". It is at discretion of the merchant to accept or reject unauthentication transactions. If the merchant decides to decline the payment, the transaction should be reverted. -OK FAIL 4153 0139 9970 0354 11/2023 354 Successful 3D Secure. 3DS form password "secret". Insufficient funds in the test bank account. -OK OK 4153 0139 9970 1162 11/2023 162 with 3DS, Soft decline when charging saved card using Customer Initiated Transaction (requires 3DS). 3DS form password "secret". -OK OK 4153 0139 9970 1170 11/2023 170 with 3DS, Soft decline when charging saved card using Customer Initiated Transaction (requires 3DS). 3DS form will be automatically completed. -OK OK 4153 0139 9970 0024 11/2023 024 Non-EU - "one leg out" card, not enrolled to 3DS. The "cardholder_authentication" response parameter will be "attempted". -OK FAIL 4153 0139 9970 0156 11/2023 156 Non-EU - "one leg out" card, not enrolled to 3DS. Insufficient funds in the test bank account. +OK OK 4153013999700313 11/2023 313 Successful 3D Secure. 3DS form password "secret". +OK OK 4153013999700321 11/2023 321 Successful 3D Secure. 3DS form will be automatically completed. +OK OK 4153013999700339 11/2023 339 3D Secure attempt. 3DS will be automatically attempted. +(OK) (OK) 4153013999700347 11/2023 347 3D Secure fails. The "cardholder_authentication" response parameter will be "no". It is at discretion of the merchant to accept or reject unauthentication transactions. If the merchant decides to decline the payment, the transaction should be reverted. +OK FAIL 4153013999700354 11/2023 354 Successful 3D Secure. 3DS form password "secret". Insufficient funds in the test bank account. +OK OK 4153013999701162 11/2023 162 with 3DS, Soft decline when charging saved card using Customer Initiated Transaction (requires 3DS). 3DS form password "secret". +OK OK 4153013999701170 11/2023 170 with 3DS, Soft decline when charging saved card using Customer Initiated Transaction (requires 3DS). 3DS form will be automatically completed. +OK OK 4153013999700024 11/2023 024 Non-EU - "one leg out" card, not enrolled to 3DS. The "cardholder_authentication" response parameter will be "attempted". +OK FAIL 4153013999700156 11/2023 156 Non-EU - "one leg out" card, not enrolled to 3DS. Insufficient funds in the test bank account. Source: https://docs.paytrail.com/#/payment-method-providers?id=test-cards-for-tokenization