diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/ConfirmBackupCancellationDialog.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/ConfirmBackupCancellationDialog.kt new file mode 100644 index 0000000000..3363b35a09 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/ConfirmBackupCancellationDialog.kt @@ -0,0 +1,104 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.backup.v2.ui.subscription + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.AlertDialogDefaults +import androidx.compose.material3.BasicAlertDialog +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import org.signal.core.ui.Previews +import org.signal.core.ui.SignalPreview +import org.thoughtcrime.securesms.R + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ConfirmBackupCancellationDialog( + onConfirmAndDownloadNow: () -> Unit, + onConfirmAndDownloadLater: () -> Unit, + onKeepSubscriptionClick: () -> Unit +) { + BasicAlertDialog(onDismissRequest = onKeepSubscriptionClick) { + Surface( + shape = AlertDialogDefaults.shape, + color = AlertDialogDefaults.containerColor + ) { + Column { + Text( + text = stringResource(id = R.string.ConfirmBackupCancellationDialog__confirm_cancellation), + color = AlertDialogDefaults.titleContentColor, + style = MaterialTheme.typography.headlineSmall, + modifier = Modifier + .padding(top = 24.dp) + .padding(horizontal = 24.dp) + ) + + Text( + text = stringResource(id = R.string.ConfirmBackupCancellationDialog__you_wont_be_charged_again), + color = AlertDialogDefaults.textContentColor, + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier + .padding(top = 16.dp) + .padding(horizontal = 24.dp) + ) + + TextButton( + onClick = onConfirmAndDownloadNow, + modifier = Modifier + .align(Alignment.End) + .padding(end = 12.dp) + ) { + Text( + text = stringResource(id = R.string.ConfirmBackupCancellationDialog__confirm_and_download_now) + ) + } + + TextButton( + onClick = onConfirmAndDownloadLater, + modifier = Modifier + .align(Alignment.End) + .padding(end = 12.dp) + ) { + Text( + text = stringResource(id = R.string.ConfirmBackupCancellationDialog__confirm_and_download_later) + ) + } + + TextButton( + onClick = onKeepSubscriptionClick, + modifier = Modifier + .align(Alignment.End) + .padding(end = 12.dp, bottom = 12.dp) + ) { + Text( + text = stringResource(id = R.string.ConfirmBackupCancellationDialog__keep_subscription) + ) + } + } + } + } +} + +@SignalPreview +@Composable +private fun ConfirmCancellationDialogPreview() { + Previews.Preview { + ConfirmBackupCancellationDialog( + onKeepSubscriptionClick = {}, + onConfirmAndDownloadNow = {}, + onConfirmAndDownloadLater = {} + ) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsCheckoutSheet.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsCheckoutSheet.kt index 56ae367873..a1980d4803 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsCheckoutSheet.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsCheckoutSheet.kt @@ -101,6 +101,7 @@ private fun SheetContent( MessageBackupsTypeBlock( messageBackupsType = messageBackupsType, + isCurrent = false, isSelected = false, onSelected = {}, enabled = false, diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsFlowFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsFlowFragment.kt index f36dd75f0e..ea41905192 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsFlowFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsFlowFragment.kt @@ -9,6 +9,7 @@ import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.navigation.compose.composable @@ -36,7 +37,7 @@ class MessageBackupsFlowFragment : ComposeFragment(), DonationCheckoutDelegate.C @OptIn(ExperimentalMaterial3Api::class) @Composable override fun FragmentContent() { - val state by viewModel.state + val state by viewModel.stateFlow.collectAsState() val navController = rememberNavController() val checkoutDelegate = remember { @@ -93,11 +94,13 @@ class MessageBackupsFlowFragment : ComposeFragment(), DonationCheckoutDelegate.C composable(route = MessageBackupsScreen.TYPE_SELECTION.name) { MessageBackupsTypeSelectionScreen( + currentBackupTier = state.currentMessageBackupTier, selectedBackupTier = state.selectedMessageBackupTier, availableBackupTypes = state.availableBackupTypes, onMessageBackupsTierSelected = viewModel::onMessageBackupTierUpdated, onNavigationClick = viewModel::goToPreviousScreen, onReadMoreClicked = {}, + onCancelSubscriptionClicked = viewModel::displayCancellationDialog, onNextClicked = viewModel::goToNextScreen ) @@ -115,6 +118,20 @@ class MessageBackupsFlowFragment : ComposeFragment(), DonationCheckoutDelegate.C } ) } + + if (state.screen == MessageBackupsScreen.CANCELLATION_DIALOG) { + ConfirmBackupCancellationDialog( + onConfirmAndDownloadNow = { + // TODO [message-backups] Set appropriate state to handle post-cancellation action. + viewModel.goToNextScreen() + }, + onConfirmAndDownloadLater = { + // TODO [message-backups] Set appropriate state to handle post-cancellation action. + viewModel.goToNextScreen() + }, + onKeepSubscriptionClick = viewModel::goToPreviousScreen + ) + } } } @@ -131,16 +148,30 @@ class MessageBackupsFlowFragment : ComposeFragment(), DonationCheckoutDelegate.C return@LaunchedEffect } + if (state.screen == MessageBackupsScreen.CREATING_IN_APP_PAYMENT) { + return@LaunchedEffect + } + if (state.screen == MessageBackupsScreen.PROCESS_PAYMENT) { checkoutDelegate.handleGatewaySelectionResponse(state.inAppPayment!!) viewModel.goToPreviousScreen() return@LaunchedEffect } + if (state.screen == MessageBackupsScreen.PROCESS_CANCELLATION) { + cancelSubscription() + viewModel.goToPreviousScreen() + return@LaunchedEffect + } + if (state.screen == MessageBackupsScreen.CHECKOUT_SHEET) { return@LaunchedEffect } + if (state.screen == MessageBackupsScreen.CANCELLATION_DIALOG) { + return@LaunchedEffect + } + val routeScreen = MessageBackupsScreen.valueOf(route) if (routeScreen.isAfter(state.screen)) { navController.popBackStack() @@ -150,6 +181,16 @@ class MessageBackupsFlowFragment : ComposeFragment(), DonationCheckoutDelegate.C } } + private fun cancelSubscription() { + findNavController().safeNavigate( + MessageBackupsFlowFragmentDirections.actionDonateToSignalFragmentToStripePaymentInProgressFragment( + DonationProcessorAction.CANCEL_SUBSCRIPTION, + null, + InAppPaymentType.RECURRING_BACKUP + ) + ) + } + override fun navigateToStripePaymentInProgress(inAppPayment: InAppPaymentTable.InAppPayment) { findNavController().safeNavigate( MessageBackupsFlowFragmentDirections.actionDonateToSignalFragmentToStripePaymentInProgressFragment( @@ -195,7 +236,11 @@ class MessageBackupsFlowFragment : ComposeFragment(), DonationCheckoutDelegate.C } } - override fun onSubscriptionCancelled(inAppPaymentType: InAppPaymentType) = error("This view doesn't support cancellation, that is done elsewhere.") + override fun onSubscriptionCancelled(inAppPaymentType: InAppPaymentType) { + if (!findNavController().popBackStack()) { + requireActivity().finishAfterTransition() + } + } override fun onProcessorActionProcessed() = Unit diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsFlowViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsFlowViewModel.kt index 950d860ea7..74d6e7db4f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsFlowViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsFlowViewModel.kt @@ -7,10 +7,12 @@ package org.thoughtcrime.securesms.backup.v2.ui.subscription import android.text.TextUtils import androidx.compose.runtime.State -import androidx.compose.runtime.mutableStateOf import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.signal.donations.InAppPaymentType @@ -32,7 +34,7 @@ import org.whispersystems.signalservice.api.kbs.PinHashUtil.verifyLocalPinHash import org.whispersystems.signalservice.internal.push.SubscriptionsConfiguration class MessageBackupsFlowViewModel : ViewModel() { - private val internalState = mutableStateOf( + private val internalStateFlow = MutableStateFlow( MessageBackupsFlowState( availableBackupTypes = emptyList(), selectedMessageBackupTier = SignalStore.backup.backupTier, @@ -41,70 +43,90 @@ class MessageBackupsFlowViewModel : ViewModel() { ) ) - val state: State = internalState + val stateFlow: StateFlow = internalStateFlow init { viewModelScope.launch { - internalState.value = internalState.value.copy( - availableBackupTypes = BackupRepository.getAvailableBackupsTypes( - if (!RemoteConfig.messageBackups) emptyList() else listOf(MessageBackupTier.FREE, MessageBackupTier.PAID) + internalStateFlow.update { + it.copy( + availableBackupTypes = BackupRepository.getAvailableBackupsTypes( + if (!RemoteConfig.messageBackups) emptyList() else listOf(MessageBackupTier.FREE, MessageBackupTier.PAID) + ) ) - ) + } } } fun goToNextScreen() { - val nextScreen = when (internalState.value.screen) { - MessageBackupsScreen.EDUCATION -> MessageBackupsScreen.PIN_EDUCATION - MessageBackupsScreen.PIN_EDUCATION -> MessageBackupsScreen.PIN_CONFIRMATION - MessageBackupsScreen.PIN_CONFIRMATION -> validatePinAndUpdateState() - MessageBackupsScreen.TYPE_SELECTION -> validateTypeAndUpdateState() - MessageBackupsScreen.CHECKOUT_SHEET -> validateGatewayAndUpdateState() - MessageBackupsScreen.PROCESS_PAYMENT -> MessageBackupsScreen.COMPLETED - MessageBackupsScreen.COMPLETED -> error("Unsupported state transition from terminal state COMPLETED") - } + internalStateFlow.update { + val nextScreen = when (it.screen) { + MessageBackupsScreen.EDUCATION -> MessageBackupsScreen.PIN_EDUCATION + MessageBackupsScreen.PIN_EDUCATION -> MessageBackupsScreen.PIN_CONFIRMATION + MessageBackupsScreen.PIN_CONFIRMATION -> validatePinAndUpdateState(it.pin) + MessageBackupsScreen.TYPE_SELECTION -> validateTypeAndUpdateState(it.selectedMessageBackupTier!!) + MessageBackupsScreen.CHECKOUT_SHEET -> validateGatewayAndUpdateState(it) + MessageBackupsScreen.CREATING_IN_APP_PAYMENT -> error("This is driven by an async coroutine.") + MessageBackupsScreen.CANCELLATION_DIALOG -> MessageBackupsScreen.PROCESS_CANCELLATION + MessageBackupsScreen.PROCESS_PAYMENT -> MessageBackupsScreen.COMPLETED + MessageBackupsScreen.PROCESS_CANCELLATION -> MessageBackupsScreen.COMPLETED + MessageBackupsScreen.COMPLETED -> error("Unsupported state transition from terminal state COMPLETED") + } - internalState.value = state.value.copy(screen = nextScreen) + it.copy(screen = nextScreen) + } } fun goToPreviousScreen() { - if (internalState.value.screen == internalState.value.startScreen) { - internalState.value = state.value.copy(screen = MessageBackupsScreen.COMPLETED) - return - } + internalStateFlow.update { + if (it.screen == it.startScreen) { + it.copy(screen = MessageBackupsScreen.COMPLETED) + } else { + val previousScreen = when (it.screen) { + MessageBackupsScreen.EDUCATION -> MessageBackupsScreen.COMPLETED + MessageBackupsScreen.PIN_EDUCATION -> MessageBackupsScreen.EDUCATION + MessageBackupsScreen.PIN_CONFIRMATION -> MessageBackupsScreen.PIN_EDUCATION + MessageBackupsScreen.TYPE_SELECTION -> MessageBackupsScreen.PIN_CONFIRMATION + MessageBackupsScreen.CHECKOUT_SHEET -> MessageBackupsScreen.TYPE_SELECTION + MessageBackupsScreen.CREATING_IN_APP_PAYMENT -> MessageBackupsScreen.TYPE_SELECTION + MessageBackupsScreen.PROCESS_PAYMENT -> MessageBackupsScreen.TYPE_SELECTION + MessageBackupsScreen.PROCESS_CANCELLATION -> MessageBackupsScreen.TYPE_SELECTION + MessageBackupsScreen.CANCELLATION_DIALOG -> MessageBackupsScreen.TYPE_SELECTION + MessageBackupsScreen.COMPLETED -> error("Unsupported state transition from terminal state COMPLETED") + } - val previousScreen = when (internalState.value.screen) { - MessageBackupsScreen.EDUCATION -> MessageBackupsScreen.COMPLETED - MessageBackupsScreen.PIN_EDUCATION -> MessageBackupsScreen.EDUCATION - MessageBackupsScreen.PIN_CONFIRMATION -> MessageBackupsScreen.PIN_EDUCATION - MessageBackupsScreen.TYPE_SELECTION -> MessageBackupsScreen.PIN_CONFIRMATION - MessageBackupsScreen.CHECKOUT_SHEET -> MessageBackupsScreen.TYPE_SELECTION - MessageBackupsScreen.PROCESS_PAYMENT -> MessageBackupsScreen.TYPE_SELECTION - MessageBackupsScreen.COMPLETED -> error("Unsupported state transition from terminal state COMPLETED") + it.copy(screen = previousScreen) + } } + } - internalState.value = state.value.copy(screen = previousScreen) + fun displayCancellationDialog() { + internalStateFlow.update { + check(it.screen == MessageBackupsScreen.TYPE_SELECTION) + it.copy(screen = MessageBackupsScreen.CANCELLATION_DIALOG) + } } fun onPinEntryUpdated(pin: String) { - internalState.value = state.value.copy(pin = pin) + // TODO [alex] -- shouldn't store this in a flow + internalStateFlow.update { + it.copy(pin = pin) + } } fun onPinKeyboardTypeUpdated(pinKeyboardType: PinKeyboardType) { - internalState.value = state.value.copy(pinKeyboardType = pinKeyboardType) + internalStateFlow.update { it.copy(pinKeyboardType = pinKeyboardType) } } fun onPaymentMethodUpdated(paymentMethod: InAppPaymentData.PaymentMethodType) { - internalState.value = state.value.copy(selectedPaymentMethod = paymentMethod) + internalStateFlow.update { it.copy(selectedPaymentMethod = paymentMethod) } } fun onMessageBackupTierUpdated(messageBackupTier: MessageBackupTier) { - internalState.value = state.value.copy(selectedMessageBackupTier = messageBackupTier) + internalStateFlow.update { it.copy(selectedMessageBackupTier = messageBackupTier) } } - private fun validatePinAndUpdateState(): MessageBackupsScreen { + private fun validatePinAndUpdateState(pin: String): MessageBackupsScreen { val pinHash = SignalStore.svr.localPinHash - val pin = state.value.pin if (pinHash == null || TextUtils.isEmpty(pin) || pin.length < SvrConstants.MINIMUM_PIN_LENGTH) return MessageBackupsScreen.PIN_CONFIRMATION @@ -114,25 +136,26 @@ class MessageBackupsFlowViewModel : ViewModel() { return MessageBackupsScreen.TYPE_SELECTION } - private fun validateTypeAndUpdateState(): MessageBackupsScreen { + private fun validateTypeAndUpdateState(tier: MessageBackupTier): MessageBackupsScreen { SignalStore.backup.areBackupsEnabled = true - SignalStore.backup.backupTier = state.value.selectedMessageBackupTier!! + SignalStore.backup.backupTier = tier // TODO [message-backups] - Does anything need to be kicked off? - return when (state.value.selectedMessageBackupTier!!) { + return when (tier) { MessageBackupTier.FREE -> MessageBackupsScreen.COMPLETED MessageBackupTier.PAID -> MessageBackupsScreen.CHECKOUT_SHEET } } - private fun validateGatewayAndUpdateState(): MessageBackupsScreen { - val stateSnapshot = state.value - val backupsType = stateSnapshot.availableBackupTypes.first { it.tier == stateSnapshot.selectedMessageBackupTier } - - internalState.value = state.value.copy(inAppPayment = null) + private fun validateGatewayAndUpdateState(state: MessageBackupsFlowState): MessageBackupsScreen { + val backupsType = state.availableBackupTypes.first { it.tier == state.selectedMessageBackupTier } viewModelScope.launch(Dispatchers.IO) { + withContext(Dispatchers.Main) { + internalStateFlow.update { it.copy(inAppPayment = null) } + } + SignalDatabase.inAppPayments.clearCreated() val id = SignalDatabase.inAppPayments.insert( type = InAppPaymentType.RECURRING_BACKUP, @@ -145,7 +168,7 @@ class MessageBackupsFlowViewModel : ViewModel() { amount = backupsType.pricePerMonth.toFiatValue(), level = SubscriptionsConfiguration.BACKUPS_LEVEL.toLong(), recipientId = Recipient.self().id.serialize(), - paymentMethodType = stateSnapshot.selectedPaymentMethod!!, + paymentMethodType = state.selectedPaymentMethod!!, redemption = InAppPaymentData.RedemptionState( stage = InAppPaymentData.RedemptionState.Stage.INIT ) @@ -155,10 +178,10 @@ class MessageBackupsFlowViewModel : ViewModel() { val inAppPayment = SignalDatabase.inAppPayments.getById(id)!! withContext(Dispatchers.Main) { - internalState.value = state.value.copy(inAppPayment = inAppPayment) + internalStateFlow.update { it.copy(inAppPayment = inAppPayment, screen = MessageBackupsScreen.PROCESS_PAYMENT) } } } - return MessageBackupsScreen.PROCESS_PAYMENT + return MessageBackupsScreen.CREATING_IN_APP_PAYMENT } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsScreen.kt index 66b1525ed0..eb9fb847c8 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsScreen.kt @@ -10,8 +10,11 @@ enum class MessageBackupsScreen { PIN_EDUCATION, PIN_CONFIRMATION, TYPE_SELECTION, + CANCELLATION_DIALOG, CHECKOUT_SHEET, + CREATING_IN_APP_PAYMENT, PROCESS_PAYMENT, + PROCESS_CANCELLATION, COMPLETED; fun isAfter(other: MessageBackupsScreen): Boolean = ordinal > other.ordinal diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsTypeSelectionScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsTypeSelectionScreen.kt index cee46ef94e..d0430d6451 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsTypeSelectionScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsTypeSelectionScreen.kt @@ -9,6 +9,7 @@ import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement.Absolute.spacedBy +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth @@ -18,8 +19,10 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.ClickableText +import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -32,6 +35,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.dimensionResource import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.ExperimentalTextApi import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.buildAnnotatedString @@ -39,6 +43,7 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.withAnnotation import androidx.compose.ui.text.withStyle import androidx.compose.ui.unit.dp +import kotlinx.collections.immutable.persistentListOf import org.signal.core.ui.Buttons import org.signal.core.ui.Previews import org.signal.core.ui.Scaffolds @@ -49,6 +54,7 @@ import org.thoughtcrime.securesms.R import org.thoughtcrime.securesms.backup.v2.MessageBackupTier import org.thoughtcrime.securesms.payments.FiatMoneyUtil import java.math.BigDecimal +import java.util.Currency /** * Screen which allows the user to select their preferred backup type. @@ -56,12 +62,14 @@ import java.math.BigDecimal @OptIn(ExperimentalTextApi::class) @Composable fun MessageBackupsTypeSelectionScreen( + currentBackupTier: MessageBackupTier?, selectedBackupTier: MessageBackupTier?, availableBackupTypes: List, onMessageBackupsTierSelected: (MessageBackupTier) -> Unit, onNavigationClick: () -> Unit, onReadMoreClicked: () -> Unit, - onNextClicked: () -> Unit + onNextClicked: () -> Unit, + onCancelSubscriptionClicked: () -> Unit ) { Scaffolds.Settings( title = "", @@ -130,6 +138,7 @@ fun MessageBackupsTypeSelectionScreen( ) { index, item -> MessageBackupsTypeBlock( messageBackupsType = item, + isCurrent = item.tier == currentBackupTier, isSelected = item.tier == selectedBackupTier, onSelected = { onMessageBackupsTierSelected(item.tier) }, modifier = Modifier.padding(top = if (index == 0) 20.dp else 18.dp) @@ -137,17 +146,36 @@ fun MessageBackupsTypeSelectionScreen( } } + val hasSelectedBackupTier = currentBackupTier != null + Buttons.LargePrimary( onClick = onNextClicked, enabled = selectedBackupTier != null, modifier = Modifier .fillMaxWidth() - .padding(vertical = 16.dp) + .padding(vertical = if (hasSelectedBackupTier) 10.dp else 16.dp) ) { Text( - text = "Next" // TODO [message-backups] Finalized copy + text = stringResource( + id = if (currentBackupTier == null) { + R.string.MessageBackupsTypeSelectionScreen__next + } else { + R.string.MessageBackupsTypeSelectionScreen__change_backup_type + } + ) ) } + + if (hasSelectedBackupTier) { + TextButton( + onClick = onCancelSubscriptionClicked, + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 14.dp) + ) { + Text(text = stringResource(id = R.string.MessageBackupsTypeSelectionScreen__cancel_subscription)) + } + } } } } @@ -160,11 +188,32 @@ private fun MessageBackupsTypeSelectionScreenPreview() { Previews.Preview { MessageBackupsTypeSelectionScreen( selectedBackupTier = MessageBackupTier.FREE, - availableBackupTypes = emptyList(), + availableBackupTypes = testBackupTypes(), onMessageBackupsTierSelected = { selectedBackupsType = it }, onNavigationClick = {}, onReadMoreClicked = {}, - onNextClicked = {} + onNextClicked = {}, + onCancelSubscriptionClicked = {}, + currentBackupTier = null + ) + } +} + +@SignalPreview +@Composable +private fun MessageBackupsTypeSelectionScreenWithCurrentTierPreview() { + var selectedBackupsType by remember { mutableStateOf(MessageBackupTier.FREE) } + + Previews.Preview { + MessageBackupsTypeSelectionScreen( + selectedBackupTier = MessageBackupTier.FREE, + availableBackupTypes = testBackupTypes(), + onMessageBackupsTierSelected = { selectedBackupsType = it }, + onNavigationClick = {}, + onReadMoreClicked = {}, + onNextClicked = {}, + onCancelSubscriptionClicked = {}, + currentBackupTier = MessageBackupTier.PAID ) } } @@ -172,6 +221,7 @@ private fun MessageBackupsTypeSelectionScreenPreview() { @Composable fun MessageBackupsTypeBlock( messageBackupsType: MessageBackupsType, + isCurrent: Boolean, isSelected: Boolean, onSelected: () -> Unit, modifier: Modifier = Modifier, @@ -189,7 +239,7 @@ fun MessageBackupsTypeBlock( SignalTheme.colors.colorSurface2 } - Column( + Box( modifier = modifier .fillMaxWidth() .background(color = background, shape = RoundedCornerShape(18.dp)) @@ -198,26 +248,36 @@ fun MessageBackupsTypeBlock( .clickable(onClick = onSelected, enabled = enabled) .padding(vertical = 16.dp, horizontal = 20.dp) ) { - Text( - text = formatCostPerMonth(messageBackupsType.pricePerMonth), - style = MaterialTheme.typography.titleSmall - ) + Column { + Text( + text = formatCostPerMonth(messageBackupsType.pricePerMonth), + style = MaterialTheme.typography.titleSmall + ) - Text( - text = messageBackupsType.title, - style = MaterialTheme.typography.titleMedium - ) + Text( + text = messageBackupsType.title, + style = MaterialTheme.typography.titleMedium + ) - Column( - verticalArrangement = spacedBy(4.dp), - modifier = Modifier - .padding(top = 8.dp) - .padding(horizontal = 16.dp) - ) { - messageBackupsType.features.forEach { - MessageBackupsTypeFeatureRow(messageBackupsTypeFeature = it) + Column( + verticalArrangement = spacedBy(4.dp), + modifier = Modifier + .padding(top = 8.dp) + .padding(horizontal = 16.dp) + ) { + messageBackupsType.features.forEach { + MessageBackupsTypeFeatureRow(messageBackupsTypeFeature = it) + } } } + + if (isCurrent) { + Icon( + painter = painterResource(id = R.drawable.symbol_check_24), + contentDescription = null, + modifier = Modifier.align(Alignment.TopEnd) + ) + } } } @@ -229,3 +289,46 @@ private fun formatCostPerMonth(pricePerMonth: FiatMoney): String { "${FiatMoneyUtil.format(LocalContext.current.resources, pricePerMonth, FiatMoneyUtil.formatOptions().trimZerosAfterDecimal())}/month" } } + +private fun testBackupTypes(): List { + return listOf( + MessageBackupsType( + tier = MessageBackupTier.FREE, + pricePerMonth = FiatMoney(BigDecimal.ZERO, Currency.getInstance("USD")), + title = "Text + 30 days of media", + features = persistentListOf( + MessageBackupsTypeFeature( + iconResourceId = R.drawable.symbol_thread_compact_bold_16, + label = "Full text message backup" + ), + MessageBackupsTypeFeature( + iconResourceId = R.drawable.symbol_album_compact_bold_16, + label = "Last 30 days of media" + ) + ) + ), + MessageBackupsType( + tier = MessageBackupTier.PAID, + pricePerMonth = FiatMoney(BigDecimal.ONE, Currency.getInstance("USD")), + title = "Text + All your media", + features = persistentListOf( + MessageBackupsTypeFeature( + iconResourceId = R.drawable.symbol_thread_compact_bold_16, + label = "Full text message backup" + ), + MessageBackupsTypeFeature( + iconResourceId = R.drawable.symbol_album_compact_bold_16, + label = "Full media backup" + ), + MessageBackupsTypeFeature( + iconResourceId = R.drawable.symbol_thread_compact_bold_16, + label = "1TB of storage (~250K photos)" + ), + MessageBackupsTypeFeature( + iconResourceId = R.drawable.symbol_heart_compact_bold_16, + label = "Thanks for supporting Signal!" + ) + ) + ) + ) +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 7190bf4090..5bd4540c05 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -7130,5 +7130,23 @@ Please enter your device pin, password or pattern. + + Next + + Change backup type + + Cancel subscription + + + + Confirm cancellation + + You won\'t be charged again. Backups will be turned off at the end of your billing cycle. After that date you will have 30 days to download the media you\'re currently backing up. + + Confirm and download now + + Confirm and download later + + Keep subscription