Implements a bunch of missing things in the backup checkout flow stuff.

This commit is contained in:
Alex Hart
2024-06-27 09:55:53 -03:00
committed by Cody Henthorne
parent 079a3d4fee
commit 8bbb7d56e0
7 changed files with 368 additions and 71 deletions

View File

@@ -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 = {}
)
}
}

View File

@@ -101,6 +101,7 @@ private fun SheetContent(
MessageBackupsTypeBlock( MessageBackupsTypeBlock(
messageBackupsType = messageBackupsType, messageBackupsType = messageBackupsType,
isCurrent = false,
isSelected = false, isSelected = false,
onSelected = {}, onSelected = {},
enabled = false, enabled = false,

View File

@@ -9,6 +9,7 @@ import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.navigation.compose.composable import androidx.navigation.compose.composable
@@ -36,7 +37,7 @@ class MessageBackupsFlowFragment : ComposeFragment(), DonationCheckoutDelegate.C
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
override fun FragmentContent() { override fun FragmentContent() {
val state by viewModel.state val state by viewModel.stateFlow.collectAsState()
val navController = rememberNavController() val navController = rememberNavController()
val checkoutDelegate = remember { val checkoutDelegate = remember {
@@ -93,11 +94,13 @@ class MessageBackupsFlowFragment : ComposeFragment(), DonationCheckoutDelegate.C
composable(route = MessageBackupsScreen.TYPE_SELECTION.name) { composable(route = MessageBackupsScreen.TYPE_SELECTION.name) {
MessageBackupsTypeSelectionScreen( MessageBackupsTypeSelectionScreen(
currentBackupTier = state.currentMessageBackupTier,
selectedBackupTier = state.selectedMessageBackupTier, selectedBackupTier = state.selectedMessageBackupTier,
availableBackupTypes = state.availableBackupTypes, availableBackupTypes = state.availableBackupTypes,
onMessageBackupsTierSelected = viewModel::onMessageBackupTierUpdated, onMessageBackupsTierSelected = viewModel::onMessageBackupTierUpdated,
onNavigationClick = viewModel::goToPreviousScreen, onNavigationClick = viewModel::goToPreviousScreen,
onReadMoreClicked = {}, onReadMoreClicked = {},
onCancelSubscriptionClicked = viewModel::displayCancellationDialog,
onNextClicked = viewModel::goToNextScreen 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 return@LaunchedEffect
} }
if (state.screen == MessageBackupsScreen.CREATING_IN_APP_PAYMENT) {
return@LaunchedEffect
}
if (state.screen == MessageBackupsScreen.PROCESS_PAYMENT) { if (state.screen == MessageBackupsScreen.PROCESS_PAYMENT) {
checkoutDelegate.handleGatewaySelectionResponse(state.inAppPayment!!) checkoutDelegate.handleGatewaySelectionResponse(state.inAppPayment!!)
viewModel.goToPreviousScreen() viewModel.goToPreviousScreen()
return@LaunchedEffect return@LaunchedEffect
} }
if (state.screen == MessageBackupsScreen.PROCESS_CANCELLATION) {
cancelSubscription()
viewModel.goToPreviousScreen()
return@LaunchedEffect
}
if (state.screen == MessageBackupsScreen.CHECKOUT_SHEET) { if (state.screen == MessageBackupsScreen.CHECKOUT_SHEET) {
return@LaunchedEffect return@LaunchedEffect
} }
if (state.screen == MessageBackupsScreen.CANCELLATION_DIALOG) {
return@LaunchedEffect
}
val routeScreen = MessageBackupsScreen.valueOf(route) val routeScreen = MessageBackupsScreen.valueOf(route)
if (routeScreen.isAfter(state.screen)) { if (routeScreen.isAfter(state.screen)) {
navController.popBackStack() 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) { override fun navigateToStripePaymentInProgress(inAppPayment: InAppPaymentTable.InAppPayment) {
findNavController().safeNavigate( findNavController().safeNavigate(
MessageBackupsFlowFragmentDirections.actionDonateToSignalFragmentToStripePaymentInProgressFragment( 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 override fun onProcessorActionProcessed() = Unit

View File

@@ -7,10 +7,12 @@ package org.thoughtcrime.securesms.backup.v2.ui.subscription
import android.text.TextUtils import android.text.TextUtils
import androidx.compose.runtime.State import androidx.compose.runtime.State
import androidx.compose.runtime.mutableStateOf
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Dispatchers 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.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.signal.donations.InAppPaymentType import org.signal.donations.InAppPaymentType
@@ -32,7 +34,7 @@ import org.whispersystems.signalservice.api.kbs.PinHashUtil.verifyLocalPinHash
import org.whispersystems.signalservice.internal.push.SubscriptionsConfiguration import org.whispersystems.signalservice.internal.push.SubscriptionsConfiguration
class MessageBackupsFlowViewModel : ViewModel() { class MessageBackupsFlowViewModel : ViewModel() {
private val internalState = mutableStateOf( private val internalStateFlow = MutableStateFlow(
MessageBackupsFlowState( MessageBackupsFlowState(
availableBackupTypes = emptyList(), availableBackupTypes = emptyList(),
selectedMessageBackupTier = SignalStore.backup.backupTier, selectedMessageBackupTier = SignalStore.backup.backupTier,
@@ -41,70 +43,90 @@ class MessageBackupsFlowViewModel : ViewModel() {
) )
) )
val state: State<MessageBackupsFlowState> = internalState val stateFlow: StateFlow<MessageBackupsFlowState> = internalStateFlow
init { init {
viewModelScope.launch { viewModelScope.launch {
internalState.value = internalState.value.copy( internalStateFlow.update {
availableBackupTypes = BackupRepository.getAvailableBackupsTypes( it.copy(
if (!RemoteConfig.messageBackups) emptyList() else listOf(MessageBackupTier.FREE, MessageBackupTier.PAID) availableBackupTypes = BackupRepository.getAvailableBackupsTypes(
if (!RemoteConfig.messageBackups) emptyList() else listOf(MessageBackupTier.FREE, MessageBackupTier.PAID)
)
) )
) }
} }
} }
fun goToNextScreen() { fun goToNextScreen() {
val nextScreen = when (internalState.value.screen) { internalStateFlow.update {
MessageBackupsScreen.EDUCATION -> MessageBackupsScreen.PIN_EDUCATION val nextScreen = when (it.screen) {
MessageBackupsScreen.PIN_EDUCATION -> MessageBackupsScreen.PIN_CONFIRMATION MessageBackupsScreen.EDUCATION -> MessageBackupsScreen.PIN_EDUCATION
MessageBackupsScreen.PIN_CONFIRMATION -> validatePinAndUpdateState() MessageBackupsScreen.PIN_EDUCATION -> MessageBackupsScreen.PIN_CONFIRMATION
MessageBackupsScreen.TYPE_SELECTION -> validateTypeAndUpdateState() MessageBackupsScreen.PIN_CONFIRMATION -> validatePinAndUpdateState(it.pin)
MessageBackupsScreen.CHECKOUT_SHEET -> validateGatewayAndUpdateState() MessageBackupsScreen.TYPE_SELECTION -> validateTypeAndUpdateState(it.selectedMessageBackupTier!!)
MessageBackupsScreen.PROCESS_PAYMENT -> MessageBackupsScreen.COMPLETED MessageBackupsScreen.CHECKOUT_SHEET -> validateGatewayAndUpdateState(it)
MessageBackupsScreen.COMPLETED -> error("Unsupported state transition from terminal state COMPLETED") 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() { fun goToPreviousScreen() {
if (internalState.value.screen == internalState.value.startScreen) { internalStateFlow.update {
internalState.value = state.value.copy(screen = MessageBackupsScreen.COMPLETED) if (it.screen == it.startScreen) {
return 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) { it.copy(screen = previousScreen)
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")
} }
}
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) { 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) { fun onPinKeyboardTypeUpdated(pinKeyboardType: PinKeyboardType) {
internalState.value = state.value.copy(pinKeyboardType = pinKeyboardType) internalStateFlow.update { it.copy(pinKeyboardType = pinKeyboardType) }
} }
fun onPaymentMethodUpdated(paymentMethod: InAppPaymentData.PaymentMethodType) { fun onPaymentMethodUpdated(paymentMethod: InAppPaymentData.PaymentMethodType) {
internalState.value = state.value.copy(selectedPaymentMethod = paymentMethod) internalStateFlow.update { it.copy(selectedPaymentMethod = paymentMethod) }
} }
fun onMessageBackupTierUpdated(messageBackupTier: MessageBackupTier) { 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 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 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 return MessageBackupsScreen.TYPE_SELECTION
} }
private fun validateTypeAndUpdateState(): MessageBackupsScreen { private fun validateTypeAndUpdateState(tier: MessageBackupTier): MessageBackupsScreen {
SignalStore.backup.areBackupsEnabled = true SignalStore.backup.areBackupsEnabled = true
SignalStore.backup.backupTier = state.value.selectedMessageBackupTier!! SignalStore.backup.backupTier = tier
// TODO [message-backups] - Does anything need to be kicked off? // TODO [message-backups] - Does anything need to be kicked off?
return when (state.value.selectedMessageBackupTier!!) { return when (tier) {
MessageBackupTier.FREE -> MessageBackupsScreen.COMPLETED MessageBackupTier.FREE -> MessageBackupsScreen.COMPLETED
MessageBackupTier.PAID -> MessageBackupsScreen.CHECKOUT_SHEET MessageBackupTier.PAID -> MessageBackupsScreen.CHECKOUT_SHEET
} }
} }
private fun validateGatewayAndUpdateState(): MessageBackupsScreen { private fun validateGatewayAndUpdateState(state: MessageBackupsFlowState): MessageBackupsScreen {
val stateSnapshot = state.value val backupsType = state.availableBackupTypes.first { it.tier == state.selectedMessageBackupTier }
val backupsType = stateSnapshot.availableBackupTypes.first { it.tier == stateSnapshot.selectedMessageBackupTier }
internalState.value = state.value.copy(inAppPayment = null)
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
withContext(Dispatchers.Main) {
internalStateFlow.update { it.copy(inAppPayment = null) }
}
SignalDatabase.inAppPayments.clearCreated() SignalDatabase.inAppPayments.clearCreated()
val id = SignalDatabase.inAppPayments.insert( val id = SignalDatabase.inAppPayments.insert(
type = InAppPaymentType.RECURRING_BACKUP, type = InAppPaymentType.RECURRING_BACKUP,
@@ -145,7 +168,7 @@ class MessageBackupsFlowViewModel : ViewModel() {
amount = backupsType.pricePerMonth.toFiatValue(), amount = backupsType.pricePerMonth.toFiatValue(),
level = SubscriptionsConfiguration.BACKUPS_LEVEL.toLong(), level = SubscriptionsConfiguration.BACKUPS_LEVEL.toLong(),
recipientId = Recipient.self().id.serialize(), recipientId = Recipient.self().id.serialize(),
paymentMethodType = stateSnapshot.selectedPaymentMethod!!, paymentMethodType = state.selectedPaymentMethod!!,
redemption = InAppPaymentData.RedemptionState( redemption = InAppPaymentData.RedemptionState(
stage = InAppPaymentData.RedemptionState.Stage.INIT stage = InAppPaymentData.RedemptionState.Stage.INIT
) )
@@ -155,10 +178,10 @@ class MessageBackupsFlowViewModel : ViewModel() {
val inAppPayment = SignalDatabase.inAppPayments.getById(id)!! val inAppPayment = SignalDatabase.inAppPayments.getById(id)!!
withContext(Dispatchers.Main) { 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
} }
} }

View File

@@ -10,8 +10,11 @@ enum class MessageBackupsScreen {
PIN_EDUCATION, PIN_EDUCATION,
PIN_CONFIRMATION, PIN_CONFIRMATION,
TYPE_SELECTION, TYPE_SELECTION,
CANCELLATION_DIALOG,
CHECKOUT_SHEET, CHECKOUT_SHEET,
CREATING_IN_APP_PAYMENT,
PROCESS_PAYMENT, PROCESS_PAYMENT,
PROCESS_CANCELLATION,
COMPLETED; COMPLETED;
fun isAfter(other: MessageBackupsScreen): Boolean = ordinal > other.ordinal fun isAfter(other: MessageBackupsScreen): Boolean = ordinal > other.ordinal

View File

@@ -9,6 +9,7 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.border import androidx.compose.foundation.border
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement.Absolute.spacedBy 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.Column
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth 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.lazy.itemsIndexed
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.ClickableText import androidx.compose.foundation.text.ClickableText
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf 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.platform.LocalContext
import androidx.compose.ui.res.dimensionResource import androidx.compose.ui.res.dimensionResource
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.ExperimentalTextApi import androidx.compose.ui.text.ExperimentalTextApi
import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString 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.withAnnotation
import androidx.compose.ui.text.withStyle import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import kotlinx.collections.immutable.persistentListOf
import org.signal.core.ui.Buttons import org.signal.core.ui.Buttons
import org.signal.core.ui.Previews import org.signal.core.ui.Previews
import org.signal.core.ui.Scaffolds 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.backup.v2.MessageBackupTier
import org.thoughtcrime.securesms.payments.FiatMoneyUtil import org.thoughtcrime.securesms.payments.FiatMoneyUtil
import java.math.BigDecimal import java.math.BigDecimal
import java.util.Currency
/** /**
* Screen which allows the user to select their preferred backup type. * Screen which allows the user to select their preferred backup type.
@@ -56,12 +62,14 @@ import java.math.BigDecimal
@OptIn(ExperimentalTextApi::class) @OptIn(ExperimentalTextApi::class)
@Composable @Composable
fun MessageBackupsTypeSelectionScreen( fun MessageBackupsTypeSelectionScreen(
currentBackupTier: MessageBackupTier?,
selectedBackupTier: MessageBackupTier?, selectedBackupTier: MessageBackupTier?,
availableBackupTypes: List<MessageBackupsType>, availableBackupTypes: List<MessageBackupsType>,
onMessageBackupsTierSelected: (MessageBackupTier) -> Unit, onMessageBackupsTierSelected: (MessageBackupTier) -> Unit,
onNavigationClick: () -> Unit, onNavigationClick: () -> Unit,
onReadMoreClicked: () -> Unit, onReadMoreClicked: () -> Unit,
onNextClicked: () -> Unit onNextClicked: () -> Unit,
onCancelSubscriptionClicked: () -> Unit
) { ) {
Scaffolds.Settings( Scaffolds.Settings(
title = "", title = "",
@@ -130,6 +138,7 @@ fun MessageBackupsTypeSelectionScreen(
) { index, item -> ) { index, item ->
MessageBackupsTypeBlock( MessageBackupsTypeBlock(
messageBackupsType = item, messageBackupsType = item,
isCurrent = item.tier == currentBackupTier,
isSelected = item.tier == selectedBackupTier, isSelected = item.tier == selectedBackupTier,
onSelected = { onMessageBackupsTierSelected(item.tier) }, onSelected = { onMessageBackupsTierSelected(item.tier) },
modifier = Modifier.padding(top = if (index == 0) 20.dp else 18.dp) modifier = Modifier.padding(top = if (index == 0) 20.dp else 18.dp)
@@ -137,17 +146,36 @@ fun MessageBackupsTypeSelectionScreen(
} }
} }
val hasSelectedBackupTier = currentBackupTier != null
Buttons.LargePrimary( Buttons.LargePrimary(
onClick = onNextClicked, onClick = onNextClicked,
enabled = selectedBackupTier != null, enabled = selectedBackupTier != null,
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(vertical = 16.dp) .padding(vertical = if (hasSelectedBackupTier) 10.dp else 16.dp)
) { ) {
Text( 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 { Previews.Preview {
MessageBackupsTypeSelectionScreen( MessageBackupsTypeSelectionScreen(
selectedBackupTier = MessageBackupTier.FREE, selectedBackupTier = MessageBackupTier.FREE,
availableBackupTypes = emptyList(), availableBackupTypes = testBackupTypes(),
onMessageBackupsTierSelected = { selectedBackupsType = it }, onMessageBackupsTierSelected = { selectedBackupsType = it },
onNavigationClick = {}, onNavigationClick = {},
onReadMoreClicked = {}, 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 @Composable
fun MessageBackupsTypeBlock( fun MessageBackupsTypeBlock(
messageBackupsType: MessageBackupsType, messageBackupsType: MessageBackupsType,
isCurrent: Boolean,
isSelected: Boolean, isSelected: Boolean,
onSelected: () -> Unit, onSelected: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
@@ -189,7 +239,7 @@ fun MessageBackupsTypeBlock(
SignalTheme.colors.colorSurface2 SignalTheme.colors.colorSurface2
} }
Column( Box(
modifier = modifier modifier = modifier
.fillMaxWidth() .fillMaxWidth()
.background(color = background, shape = RoundedCornerShape(18.dp)) .background(color = background, shape = RoundedCornerShape(18.dp))
@@ -198,26 +248,36 @@ fun MessageBackupsTypeBlock(
.clickable(onClick = onSelected, enabled = enabled) .clickable(onClick = onSelected, enabled = enabled)
.padding(vertical = 16.dp, horizontal = 20.dp) .padding(vertical = 16.dp, horizontal = 20.dp)
) { ) {
Text( Column {
text = formatCostPerMonth(messageBackupsType.pricePerMonth), Text(
style = MaterialTheme.typography.titleSmall text = formatCostPerMonth(messageBackupsType.pricePerMonth),
) style = MaterialTheme.typography.titleSmall
)
Text( Text(
text = messageBackupsType.title, text = messageBackupsType.title,
style = MaterialTheme.typography.titleMedium style = MaterialTheme.typography.titleMedium
) )
Column( Column(
verticalArrangement = spacedBy(4.dp), verticalArrangement = spacedBy(4.dp),
modifier = Modifier modifier = Modifier
.padding(top = 8.dp) .padding(top = 8.dp)
.padding(horizontal = 16.dp) .padding(horizontal = 16.dp)
) { ) {
messageBackupsType.features.forEach { messageBackupsType.features.forEach {
MessageBackupsTypeFeatureRow(messageBackupsTypeFeature = it) 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" "${FiatMoneyUtil.format(LocalContext.current.resources, pricePerMonth, FiatMoneyUtil.formatOptions().trimZerosAfterDecimal())}/month"
} }
} }
private fun testBackupTypes(): List<MessageBackupsType> {
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!"
)
)
)
)
}

View File

@@ -7130,5 +7130,23 @@
<!-- The body of an alert dialog shown when we detect the user may be confused by the lock screen --> <!-- The body of an alert dialog shown when we detect the user may be confused by the lock screen -->
<string name="PassphrasePromptActivity_help_prompt_body">Please enter your device pin, password or pattern.</string> <string name="PassphrasePromptActivity_help_prompt_body">Please enter your device pin, password or pattern.</string>
<!-- Primary action button label when selecting a backup tier without a current selection -->
<string name="MessageBackupsTypeSelectionScreen__next">Next</string>
<!-- Primary action button label when selecting a backup tier with a current selection -->
<string name="MessageBackupsTypeSelectionScreen__change_backup_type">Change backup type</string>
<!-- Secondary action button label when selecting a backup tier with a current selection -->
<string name="MessageBackupsTypeSelectionScreen__cancel_subscription">Cancel subscription</string>
<!-- ConfirmBackupCancellationDialog -->
<!-- Dialog title -->
<string name="ConfirmBackupCancellationDialog__confirm_cancellation">Confirm cancellation</string>
<!-- Dialog body -->
<string name="ConfirmBackupCancellationDialog__you_wont_be_charged_again">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.</string>
<!-- Action button label to cancel subscription and download now -->
<string name="ConfirmBackupCancellationDialog__confirm_and_download_now">Confirm and download now</string>
<!-- Action button label to cancel subscription and download later -->
<string name="ConfirmBackupCancellationDialog__confirm_and_download_later">Confirm and download later</string>
<!-- Action button label to keep current subscription -->
<string name="ConfirmBackupCancellationDialog__keep_subscription">Keep subscription</string>
<!-- EOF --> <!-- EOF -->
</resources> </resources>