diff options
| author | Jonatan Rhodin <jonatan.rhodin@mullvad.net> | 2023-11-16 01:02:13 +0100 |
|---|---|---|
| committer | Jonatan Rhodin <jonatan.rhodin@mullvad.net> | 2023-11-16 09:34:26 +0100 |
| commit | 52e2705f246b390a7c84597498677226057f76c3 (patch) | |
| tree | 248db21aec636984fc6950c986b12b37790dd48b /android/app/src | |
| parent | 06a950f5982d0d56031803a6d96a7bce6701bc08 (diff) | |
| download | mullvadvpn-52e2705f246b390a7c84597498677226057f76c3.tar.xz mullvadvpn-52e2705f246b390a7c84597498677226057f76c3.zip | |
Add payment support to account screen
Diffstat (limited to 'android/app/src')
5 files changed, 494 insertions, 4 deletions
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/PlayPayment.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/PlayPayment.kt new file mode 100644 index 0000000000..a56d3dff74 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/PlayPayment.kt @@ -0,0 +1,188 @@ +package net.mullvad.mullvadvpn.compose.component + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import net.mullvad.mullvadvpn.R +import net.mullvad.mullvadvpn.compose.button.VariantButton +import net.mullvad.mullvadvpn.compose.state.PaymentState +import net.mullvad.mullvadvpn.lib.payment.ProductIds +import net.mullvad.mullvadvpn.lib.payment.model.PaymentProduct +import net.mullvad.mullvadvpn.lib.payment.model.PaymentStatus +import net.mullvad.mullvadvpn.lib.payment.model.ProductId +import net.mullvad.mullvadvpn.lib.payment.model.ProductPrice +import net.mullvad.mullvadvpn.lib.theme.AppTheme +import net.mullvad.mullvadvpn.lib.theme.Dimens + +@Preview +@Composable +private fun PreviewPlayPaymentPaymentAvailable() { + AppTheme { + Box(modifier = Modifier.background(color = MaterialTheme.colorScheme.background)) { + PlayPayment( + billingPaymentState = + PaymentState.PaymentAvailable( + products = + listOf( + PaymentProduct( + productId = ProductId("test"), + price = ProductPrice("$10"), + status = null + ) + ) + ), + onPurchaseBillingProductClick = {}, + onInfoClick = {}, + modifier = Modifier.padding(Dimens.screenVerticalMargin) + ) + } + } +} + +@Preview +@Composable +private fun PreviewPlayPaymentLoading() { + AppTheme { + Box(modifier = Modifier.background(color = MaterialTheme.colorScheme.background)) { + PlayPayment( + billingPaymentState = PaymentState.Loading, + onPurchaseBillingProductClick = {}, + onInfoClick = {}, + modifier = Modifier.padding(Dimens.screenVerticalMargin) + ) + } + } +} + +@Preview +@Composable +private fun PreviewPlayPaymentPaymentPending() { + AppTheme { + Box(modifier = Modifier.background(color = MaterialTheme.colorScheme.background)) { + PlayPayment( + billingPaymentState = + PaymentState.PaymentAvailable( + products = + listOf( + PaymentProduct( + productId = ProductId("test"), + price = ProductPrice("$10"), + status = PaymentStatus.PENDING + ) + ) + ), + onPurchaseBillingProductClick = {}, + onInfoClick = {}, + modifier = Modifier.padding(Dimens.screenVerticalMargin) + ) + } + } +} + +@Preview +@Composable +private fun PreviewPlayPaymentVerificationInProgress() { + AppTheme { + Box(modifier = Modifier.background(color = MaterialTheme.colorScheme.background)) { + PlayPayment( + billingPaymentState = + PaymentState.PaymentAvailable( + products = + listOf( + PaymentProduct( + productId = ProductId("test"), + price = ProductPrice("$10"), + status = PaymentStatus.VERIFICATION_IN_PROGRESS + ) + ) + ), + onPurchaseBillingProductClick = {}, + onInfoClick = {}, + modifier = Modifier.padding(Dimens.screenVerticalMargin) + ) + } + } +} + +@Composable +fun PlayPayment( + billingPaymentState: PaymentState, + onPurchaseBillingProductClick: (ProductId) -> Unit, + onInfoClick: () -> Unit, + modifier: Modifier = Modifier +) { + when (billingPaymentState) { + PaymentState.Loading -> { + Column(modifier = modifier.fillMaxWidth()) { + MullvadCircularProgressIndicatorSmall(modifier = modifier) + } + } + PaymentState.NoPayment, + PaymentState.NoProductsFounds -> { + // Show nothing + } + is PaymentState.PaymentAvailable -> { + billingPaymentState.products.forEach { product -> + Column(modifier = modifier) { + val statusMessage = + when (product.status) { + PaymentStatus.PENDING -> + stringResource(id = R.string.payment_status_pending) + PaymentStatus.VERIFICATION_IN_PROGRESS -> + stringResource( + id = R.string.payment_status_verification_in_progress + ) + else -> null + } + statusMessage?.let { + Row(verticalAlignment = Alignment.Bottom) { + Text( + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onBackground, + text = statusMessage, + modifier = Modifier.padding(bottom = Dimens.smallPadding) + ) + IconButton( + onClick = onInfoClick + ) { + Icon( + painter = painterResource(id = R.drawable.icon_info), + contentDescription = null, + tint = MaterialTheme.colorScheme.onBackground + ) + } + } + } + VariantButton( + text = + stringResource(id = R.string.add_30_days_time_x, product.price.value), + onClick = { onPurchaseBillingProductClick(product.productId) }, + isEnabled = product.status == null + ) + } + } + } + // Show the button without the price + is PaymentState.Error -> { + Column(modifier = modifier) { + VariantButton( + text = stringResource(id = R.string.add_30_days_time), + onClick = { onPurchaseBillingProductClick(ProductId(ProductIds.OneMonth)) } + ) + } + } + } +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/payment/PaymentDialog.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/payment/PaymentDialog.kt new file mode 100644 index 0000000000..7e94b7455e --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/payment/PaymentDialog.kt @@ -0,0 +1,186 @@ +package net.mullvad.mullvadvpn.compose.dialog.payment + +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.compositeOver +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import net.mullvad.mullvadvpn.R +import net.mullvad.mullvadvpn.compose.button.PrimaryButton +import net.mullvad.mullvadvpn.compose.component.MullvadCircularProgressIndicatorMedium +import net.mullvad.mullvadvpn.lib.payment.model.ProductId +import net.mullvad.mullvadvpn.lib.theme.AppTheme +import net.mullvad.mullvadvpn.lib.theme.color.AlphaDescription + +@Preview +@Composable +private fun PreviewPaymentDialogPurchaseCompleted() { + AppTheme { + PaymentDialog( + paymentDialogData = + PaymentDialogData( + title = R.string.payment_completed_dialog_title, + message = R.string.payment_completed_dialog_message, + icon = PaymentDialogIcon.SUCCESS, + confirmAction = PaymentDialogAction.Close, + successfulPayment = true + ), + retryPurchase = {}, + onCloseDialog = {} + ) + } +} + +@Preview +@Composable +private fun PreviewPaymentDialogPurchasePending() { + AppTheme { + PaymentDialog( + paymentDialogData = + PaymentDialogData( + title = R.string.payment_pending_dialog_title, + message = R.string.payment_pending_dialog_message, + confirmAction = PaymentDialogAction.Close, + closeOnDismiss = true + ), + retryPurchase = {}, + onCloseDialog = {} + ) + } +} + +@Preview +@Composable +private fun PreviewPaymentDialogGenericError() { + AppTheme { + PaymentDialog( + paymentDialogData = + PaymentDialogData( + title = R.string.error_occurred, + message = R.string.try_again, + icon = PaymentDialogIcon.FAIL, + confirmAction = PaymentDialogAction.Close + ), + retryPurchase = {}, + onCloseDialog = {} + ) + } +} + +@Preview +@Composable +private fun PreviewPaymentDialogLoading() { + AppTheme { + PaymentDialog( + paymentDialogData = + PaymentDialogData( + title = R.string.loading_connecting, + icon = PaymentDialogIcon.LOADING, + closeOnDismiss = false + ), + retryPurchase = {}, + onCloseDialog = {} + ) + } +} + +@Preview +@Composable +private fun PreviewPaymentDialogPaymentAvailabilityError() { + AppTheme { + PaymentDialog( + paymentDialogData = + PaymentDialogData( + title = R.string.payment_billing_error_dialog_title, + message = R.string.payment_billing_error_dialog_message, + icon = PaymentDialogIcon.FAIL, + confirmAction = PaymentDialogAction.Close, + dismissAction = PaymentDialogAction.RetryPurchase(productId = ProductId("test")) + ), + retryPurchase = {}, + onCloseDialog = {} + ) + } +} + +@Composable +fun PaymentDialog( + paymentDialogData: PaymentDialogData, + retryPurchase: (ProductId) -> Unit, + onCloseDialog: (isPaymentSuccessful: Boolean) -> Unit +) { + val clickResolver: (action: PaymentDialogAction) -> Unit = { + when (it) { + is PaymentDialogAction.RetryPurchase -> retryPurchase(it.productId) + is PaymentDialogAction.Close -> onCloseDialog(paymentDialogData.successfulPayment) + } + } + AlertDialog( + icon = { + when (paymentDialogData.icon) { + PaymentDialogIcon.SUCCESS -> + Icon( + painter = painterResource(id = R.drawable.icon_success), + contentDescription = null + ) + PaymentDialogIcon.FAIL -> + Icon( + painter = painterResource(id = R.drawable.icon_fail), + contentDescription = null + ) + PaymentDialogIcon.LOADING -> MullvadCircularProgressIndicatorMedium() + else -> {} + } + }, + title = { + paymentDialogData.title?.let { + Text( + text = stringResource(id = paymentDialogData.title), + style = MaterialTheme.typography.headlineSmall + ) + } + }, + text = + paymentDialogData.message?.let { + { + Text( + text = stringResource(id = paymentDialogData.message), + style = MaterialTheme.typography.bodySmall + ) + } + }, + containerColor = MaterialTheme.colorScheme.background, + titleContentColor = MaterialTheme.colorScheme.onBackground, + iconContentColor = Color.Unspecified, + textContentColor = + MaterialTheme.colorScheme.onBackground + .copy(alpha = AlphaDescription) + .compositeOver(MaterialTheme.colorScheme.background), + onDismissRequest = { + if (paymentDialogData.closeOnDismiss) { + onCloseDialog(paymentDialogData.successfulPayment) + } + }, + dismissButton = { + paymentDialogData.dismissAction?.let { + PrimaryButton( + text = stringResource(id = it.message), + onClick = { clickResolver(it) } + ) + } + }, + confirmButton = { + paymentDialogData.confirmAction?.let { + PrimaryButton( + text = stringResource(id = it.message), + onClick = { clickResolver(it) } + ) + } + } + ) +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/payment/VerificationPendingDialog.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/payment/VerificationPendingDialog.kt new file mode 100644 index 0000000000..112afeebf5 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/payment/VerificationPendingDialog.kt @@ -0,0 +1,48 @@ +package net.mullvad.mullvadvpn.compose.dialog.payment + +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.compositeOver +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import net.mullvad.mullvadvpn.R +import net.mullvad.mullvadvpn.compose.button.PrimaryButton +import net.mullvad.mullvadvpn.lib.theme.AppTheme +import net.mullvad.mullvadvpn.lib.theme.color.AlphaDescription + +@Preview +@Composable +private fun PreviewVerificationPendingDialog() { + AppTheme { VerificationPendingDialog(onClose = {}) } +} + +@Composable +fun VerificationPendingDialog(onClose: () -> Unit) { + AlertDialog( + icon = {}, // Makes it look a bit more balanced + title = { + Text( + text = stringResource(id = R.string.payment_pending_dialog_title), + style = MaterialTheme.typography.headlineSmall + ) + }, + text = { + Text( + text = stringResource(id = R.string.payment_pending_dialog_message), + style = MaterialTheme.typography.bodySmall + ) + }, + containerColor = MaterialTheme.colorScheme.background, + titleContentColor = MaterialTheme.colorScheme.onBackground, + textContentColor = + MaterialTheme.colorScheme.onBackground + .copy(alpha = AlphaDescription) + .compositeOver(MaterialTheme.colorScheme.background), + onDismissRequest = onClose, + confirmButton = { + PrimaryButton(text = stringResource(id = R.string.got_it), onClick = onClose) + } + ) +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/AccountScreen.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/AccountScreen.kt index 34ba02d756..fecd23406a 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/AccountScreen.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/AccountScreen.kt @@ -1,5 +1,6 @@ package net.mullvad.mullvadvpn.compose.screen +import android.app.Activity import androidx.compose.animation.animateContentSize import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -22,6 +23,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview @@ -37,11 +39,19 @@ import net.mullvad.mullvadvpn.compose.component.CopyableObfuscationView import net.mullvad.mullvadvpn.compose.component.InformationView import net.mullvad.mullvadvpn.compose.component.MissingPolicy import net.mullvad.mullvadvpn.compose.component.NavigateBackDownIconButton +import net.mullvad.mullvadvpn.compose.component.PlayPayment import net.mullvad.mullvadvpn.compose.component.ScaffoldWithMediumTopBar import net.mullvad.mullvadvpn.compose.dialog.DeviceNameInfoDialog +import net.mullvad.mullvadvpn.compose.dialog.payment.PaymentDialog +import net.mullvad.mullvadvpn.compose.dialog.payment.VerificationPendingDialog +import net.mullvad.mullvadvpn.compose.extensions.createOpenAccountPageHook +import net.mullvad.mullvadvpn.compose.state.PaymentState import net.mullvad.mullvadvpn.compose.util.SecureScreenWhileInView -import net.mullvad.mullvadvpn.constant.IS_PLAY_BUILD import net.mullvad.mullvadvpn.lib.common.util.openAccountPageInBrowser +import net.mullvad.mullvadvpn.lib.payment.model.PaymentProduct +import net.mullvad.mullvadvpn.lib.payment.model.PaymentStatus +import net.mullvad.mullvadvpn.lib.payment.model.ProductId +import net.mullvad.mullvadvpn.lib.payment.model.ProductPrice import net.mullvad.mullvadvpn.lib.theme.AppTheme import net.mullvad.mullvadvpn.lib.theme.Dimens import net.mullvad.mullvadvpn.util.toExpiryDateString @@ -55,11 +65,27 @@ import org.joda.time.DateTime private fun PreviewAccountScreen() { AppTheme { AccountScreen( + showSitePayment = true, uiState = AccountUiState( deviceName = "Test Name", accountNumber = "1234123412341234", - accountExpiry = null + accountExpiry = null, + billingPaymentState = + PaymentState.PaymentAvailable( + listOf( + PaymentProduct( + ProductId("productId"), + price = ProductPrice("34 SEK"), + status = null + ), + PaymentProduct( + ProductId("productId_pending"), + price = ProductPrice("34 SEK"), + status = PaymentStatus.PENDING + ) + ), + ) ), uiSideEffect = MutableSharedFlow<AccountViewModel.UiSideEffect>().asSharedFlow(), enterTransitionEndAction = MutableSharedFlow() @@ -70,12 +96,18 @@ private fun PreviewAccountScreen() { @ExperimentalMaterial3Api @Composable fun AccountScreen( + showSitePayment: Boolean, uiState: AccountUiState, uiSideEffect: SharedFlow<AccountViewModel.UiSideEffect>, enterTransitionEndAction: SharedFlow<Unit>, onRedeemVoucherClick: () -> Unit = {}, onManageAccountClick: () -> Unit = {}, onLogoutClick: () -> Unit = {}, + onPurchaseBillingProductClick: + (productId: ProductId, activityProvider: () -> Activity) -> Unit = + { _, _ -> + }, + onClosePurchaseResultDialog: (success: Boolean) -> Unit = {}, onBackClick: () -> Unit = {} ) { // This will enable SECURE_FLAG while this screen is visible to preview screenshot @@ -84,17 +116,38 @@ fun AccountScreen( val context = LocalContext.current val backgroundColor = MaterialTheme.colorScheme.background val systemUiController = rememberSystemUiController() - var showDeviceNameInfoDialog by remember { mutableStateOf(false) } + var showVerificationPendingDialog by remember { mutableStateOf(false) } LaunchedEffect(Unit) { systemUiController.setNavigationBarColor(backgroundColor) enterTransitionEndAction.collect { systemUiController.setStatusBarColor(backgroundColor) } } + val openAccountPage = LocalUriHandler.current.createOpenAccountPageHook() + LaunchedEffect(Unit) { + uiSideEffect.collect { viewAction -> + if (viewAction is AccountViewModel.UiSideEffect.OpenAccountManagementPageInBrowser) { + openAccountPage(viewAction.token) + } + } + } + if (showDeviceNameInfoDialog) { DeviceNameInfoDialog { showDeviceNameInfoDialog = false } } + if (showVerificationPendingDialog) { + VerificationPendingDialog(onClose = { showVerificationPendingDialog = false }) + } + + uiState.paymentDialogData?.let { + PaymentDialog( + paymentDialogData = uiState.paymentDialogData, + retryPurchase = { onPurchaseBillingProductClick(it) { context as Activity } }, + onCloseDialog = onClosePurchaseResultDialog + ) + } + LaunchedEffect(Unit) { uiSideEffect.collect { uiSideEffect -> if (uiSideEffect is AccountViewModel.UiSideEffect.OpenAccountManagementPageInBrowser) { @@ -121,7 +174,18 @@ fun AccountScreen( Spacer(modifier = Modifier.weight(1f)) Column(modifier = Modifier.padding(bottom = Dimens.screenVerticalMargin)) { - if (IS_PLAY_BUILD.not()) { + uiState.billingPaymentState?.let { + PlayPayment( + billingPaymentState = uiState.billingPaymentState, + onPurchaseBillingProductClick = { productId -> + onPurchaseBillingProductClick(productId) { context as Activity } + }, + onInfoClick = { showVerificationPendingDialog = true }, + modifier = Modifier.padding(bottom = Dimens.buttonSpacing) + ) + } + + if (showSitePayment) { ExternalButton( text = stringResource(id = R.string.manage_account), onClick = onManageAccountClick, diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/AccountFragment.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/AccountFragment.kt index efdc0783a3..5225368dac 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/AccountFragment.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/AccountFragment.kt @@ -9,6 +9,7 @@ import androidx.compose.runtime.collectAsState import androidx.compose.ui.platform.ComposeView import net.mullvad.mullvadvpn.R import net.mullvad.mullvadvpn.compose.screen.AccountScreen +import net.mullvad.mullvadvpn.constant.IS_PLAY_BUILD import net.mullvad.mullvadvpn.lib.theme.AppTheme import net.mullvad.mullvadvpn.viewmodel.AccountViewModel import org.koin.androidx.viewmodel.ext.android.viewModel @@ -27,12 +28,15 @@ class AccountFragment : BaseFragment() { AppTheme { val state = vm.uiState.collectAsState().value AccountScreen( + showSitePayment = IS_PLAY_BUILD.not(), uiState = state, uiSideEffect = vm.uiSideEffect, enterTransitionEndAction = vm.enterTransitionEndAction, onRedeemVoucherClick = { openRedeemVoucherFragment() }, onManageAccountClick = vm::onManageAccountClick, onLogoutClick = vm::onLogoutClick, + onPurchaseBillingProductClick = vm::startBillingPayment, + onClosePurchaseResultDialog = vm::onClosePurchaseResultDialog, onBackClick = { activity?.onBackPressedDispatcher?.onBackPressed() } ) } |
