From e442c275554611dc5baf2a7decd5982ff9f02388 Mon Sep 17 00:00:00 2001 From: Alex Hart Date: Thu, 17 Apr 2025 10:30:03 -0300 Subject: [PATCH] Separate sheet that requires payment flow. --- .../backup/v2/ui/BackupAlertBottomSheet.kt | 124 +++---------- .../v2/ui/MediaBackupsAreOffBottomSheet.kt | 171 ++++++++++++++++++ .../MessageBackupsFlowViewModel.kt | 3 +- 3 files changed, 201 insertions(+), 97 deletions(-) create mode 100644 app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/MediaBackupsAreOffBottomSheet.kt diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/BackupAlertBottomSheet.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/BackupAlertBottomSheet.kt index 566b1519c2..6a0d1f5d53 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/BackupAlertBottomSheet.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/BackupAlertBottomSheet.kt @@ -28,16 +28,11 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.Stable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.dimensionResource import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource @@ -47,6 +42,7 @@ import androidx.compose.ui.unit.dp import androidx.core.content.ContextCompat import androidx.core.os.BundleCompat import androidx.core.os.bundleOf +import androidx.fragment.app.DialogFragment import com.google.android.material.dialog.MaterialAlertDialogBuilder import kotlinx.parcelize.Parcelize import org.signal.core.ui.compose.BottomSheets @@ -56,25 +52,20 @@ import org.signal.core.ui.compose.SignalPreview import org.signal.core.ui.compose.theme.SignalTheme import org.thoughtcrime.securesms.R import org.thoughtcrime.securesms.backup.v2.BackupRepository -import org.thoughtcrime.securesms.backup.v2.ui.subscription.MessageBackupsType import org.thoughtcrime.securesms.billing.launchManageBackupsSubscription -import org.thoughtcrime.securesms.billing.upgrade.UpgradeToPaidTierBottomSheet import org.thoughtcrime.securesms.components.settings.app.AppSettingsActivity +import org.thoughtcrime.securesms.compose.ComposeBottomSheetDialogFragment import org.thoughtcrime.securesms.dependencies.AppDependencies import org.thoughtcrime.securesms.jobs.BackupMessagesJob import org.thoughtcrime.securesms.jobs.BackupRestoreMediaJob -import org.thoughtcrime.securesms.payments.FiatMoneyUtil import org.thoughtcrime.securesms.util.CommunicationActions import org.thoughtcrime.securesms.util.PlayStoreUtil -import kotlin.time.Duration -import kotlin.time.Duration.Companion.days -import kotlin.time.Duration.Companion.milliseconds import org.signal.core.ui.R as CoreUiR /** * Notifies the user of an issue with their backup. */ -class BackupAlertBottomSheet : UpgradeToPaidTierBottomSheet() { +class BackupAlertBottomSheet : ComposeBottomSheetDialogFragment() { override val peekHeightPercentage: Float = 0.75f @@ -82,8 +73,12 @@ class BackupAlertBottomSheet : UpgradeToPaidTierBottomSheet() { private const val ARG_ALERT = "alert" @JvmStatic - fun create(backupAlert: BackupAlert): BackupAlertBottomSheet { - return BackupAlertBottomSheet().apply { + fun create(backupAlert: BackupAlert): DialogFragment { + return if (backupAlert is BackupAlert.MediaBackupsAreOff) { + MediaBackupsAreOffBottomSheet() + } else { + BackupAlertBottomSheet() + }.apply { arguments = bundleOf(ARG_ALERT to backupAlert) } } @@ -94,34 +89,20 @@ class BackupAlertBottomSheet : UpgradeToPaidTierBottomSheet() { } @Composable - override fun UpgradeSheetContent( - paidBackupType: MessageBackupsType.Paid, - freeBackupType: MessageBackupsType.Free, - isSubscribeEnabled: Boolean, - onSubscribeClick: () -> Unit - ) { - var pricePerMonth by remember { mutableStateOf("-") } - val resources = LocalContext.current.resources - - LaunchedEffect(paidBackupType.pricePerMonth) { - pricePerMonth = FiatMoneyUtil.format(resources, paidBackupType.pricePerMonth, FiatMoneyUtil.formatOptions().trimZerosAfterDecimal()) - } - - val performPrimaryAction = remember(onSubscribeClick) { - createPrimaryAction(onSubscribeClick) + override fun SheetContent() { + val performPrimaryAction = remember(backupAlert) { + createPrimaryAction() } BackupAlertSheetContent( backupAlert = backupAlert, - isSubscribeEnabled = isSubscribeEnabled, - mediaTtl = paidBackupType.mediaTtl, onPrimaryActionClick = performPrimaryAction, onSecondaryActionClick = this::performSecondaryAction ) } @Stable - private fun createPrimaryAction(onSubscribeClick: () -> Unit): () -> Unit = { + private fun createPrimaryAction(): () -> Unit = { when (backupAlert) { is BackupAlert.CouldNotCompleteBackup -> { BackupMessagesJob.enqueue() @@ -129,9 +110,7 @@ class BackupAlertBottomSheet : UpgradeToPaidTierBottomSheet() { } BackupAlert.FailedToRenew -> launchManageBackupsSubscription() - is BackupAlert.MediaBackupsAreOff -> { - onSubscribeClick() - } + is BackupAlert.MediaBackupsAreOff -> error("Use MediaBackupsAreOffBottomSheet instead.") BackupAlert.MediaWillBeDeletedToday -> { performFullMediaDownload() @@ -152,7 +131,7 @@ class BackupAlertBottomSheet : UpgradeToPaidTierBottomSheet() { when (backupAlert) { is BackupAlert.CouldNotCompleteBackup -> Unit BackupAlert.FailedToRenew -> Unit - is BackupAlert.MediaBackupsAreOff -> Unit + is BackupAlert.MediaBackupsAreOff -> error("Use MediaBackupsAreOffBottomSheet instead.") BackupAlert.MediaWillBeDeletedToday -> { displayLastChanceDialog() } @@ -212,11 +191,8 @@ class BackupAlertBottomSheet : UpgradeToPaidTierBottomSheet() { } @Composable -private fun BackupAlertSheetContent( +fun BackupAlertSheetContent( backupAlert: BackupAlert, - pricePerMonth: String = "", - isSubscribeEnabled: Boolean = true, - mediaTtl: Duration, onPrimaryActionClick: () -> Unit = {}, onSecondaryActionClick: () -> Unit = {} ) { @@ -231,7 +207,8 @@ private fun BackupAlertSheetContent( Spacer(modifier = Modifier.size(26.dp)) when (backupAlert) { - BackupAlert.FailedToRenew, is BackupAlert.MediaBackupsAreOff -> { + is BackupAlert.MediaBackupsAreOff -> error("Use MediaBackupsAreOffBottomSheet instead.") + BackupAlert.FailedToRenew -> { Box { Image( imageVector = ImageVector.vectorResource(id = R.drawable.image_signal_backups), @@ -276,29 +253,27 @@ private fun BackupAlertSheetContent( ) BackupAlert.FailedToRenew -> PaymentProcessingBody() - is BackupAlert.MediaBackupsAreOff -> MediaBackupsAreOffBody(backupAlert.endOfPeriodSeconds, mediaTtl) BackupAlert.MediaWillBeDeletedToday -> MediaWillBeDeletedTodayBody() is BackupAlert.DiskFull -> DiskFullBody(requiredSpace = backupAlert.requiredSpace) BackupAlert.BackupFailed -> BackupFailedBody() BackupAlert.CouldNotRedeemBackup -> CouldNotRedeemBackup() + is BackupAlert.MediaBackupsAreOff -> error("Use MediaBackupsAreOffBottomSheet instead.") } val secondaryActionResource = rememberSecondaryActionResource(backupAlert = backupAlert) val padBottom = if (secondaryActionResource > 0) 16.dp else 56.dp Buttons.LargeTonal( - enabled = isSubscribeEnabled, onClick = onPrimaryActionClick, modifier = Modifier .defaultMinSize(minWidth = 220.dp) .padding(bottom = padBottom) ) { - Text(text = primaryActionString(backupAlert = backupAlert, pricePerMonth = pricePerMonth)) + Text(text = primaryActionString(backupAlert = backupAlert)) } if (secondaryActionResource > 0) { TextButton( - enabled = isSubscribeEnabled, onClick = onSecondaryActionClick, modifier = Modifier.padding(bottom = 32.dp) ) { @@ -381,28 +356,6 @@ private fun PaymentProcessingBody() { ) } -@Composable -private fun MediaBackupsAreOffBody( - endOfPeriodSeconds: Long, - mediaTtl: Duration -) { - val daysUntilDeletion = remember { endOfPeriodSeconds.days + mediaTtl }.inWholeDays.toInt() - - Text( - text = pluralStringResource(id = R.plurals.BackupAlertBottomSheet__your_backup_plan_has_expired, daysUntilDeletion, daysUntilDeletion), - textAlign = TextAlign.Center, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(bottom = 24.dp) - ) - - Text( - text = stringResource(id = R.string.BackupAlertBottomSheet__you_can_begin_paying_for_backups_again), - textAlign = TextAlign.Center, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(bottom = 36.dp) - ) -} - @Composable private fun MediaWillBeDeletedTodayBody() { Text( @@ -473,13 +426,12 @@ private fun titleString(backupAlert: BackupAlert): String { @Composable private fun primaryActionString( - backupAlert: BackupAlert, - pricePerMonth: String + backupAlert: BackupAlert ): String { return when (backupAlert) { is BackupAlert.CouldNotCompleteBackup -> stringResource(R.string.BackupAlertBottomSheet__back_up_now) BackupAlert.FailedToRenew -> stringResource(R.string.BackupAlertBottomSheet__manage_subscription) - is BackupAlert.MediaBackupsAreOff -> stringResource(R.string.BackupAlertBottomSheet__subscribe_for_s_month, pricePerMonth) + is BackupAlert.MediaBackupsAreOff -> error("Not supported.") BackupAlert.MediaWillBeDeletedToday -> stringResource(R.string.BackupAlertBottomSheet__download_media_now) is BackupAlert.DiskFull -> stringResource(R.string.BackupAlertBottomSheet__got_it) is BackupAlert.BackupFailed -> stringResource(R.string.BackupAlertBottomSheet__check_for_update) @@ -493,7 +445,7 @@ private fun rememberSecondaryActionResource(backupAlert: BackupAlert): Int { when (backupAlert) { is BackupAlert.CouldNotCompleteBackup -> R.string.BackupAlertBottomSheet__try_later BackupAlert.FailedToRenew -> R.string.BackupAlertBottomSheet__not_now - is BackupAlert.MediaBackupsAreOff -> R.string.BackupAlertBottomSheet__not_now + is BackupAlert.MediaBackupsAreOff -> error("Not supported.") BackupAlert.MediaWillBeDeletedToday -> R.string.BackupAlertBottomSheet__dont_download_media is BackupAlert.DiskFull -> R.string.BackupAlertBottomSheet__skip_restore is BackupAlert.BackupFailed -> R.string.BackupAlertBottomSheet__learn_more @@ -507,8 +459,7 @@ private fun rememberSecondaryActionResource(backupAlert: BackupAlert): Int { private fun BackupAlertSheetContentPreviewGeneric() { Previews.BottomSheetPreview { BackupAlertSheetContent( - backupAlert = BackupAlert.CouldNotCompleteBackup(daysSinceLastBackup = 7), - mediaTtl = 60.days + backupAlert = BackupAlert.CouldNotCompleteBackup(daysSinceLastBackup = 7) ) } } @@ -518,20 +469,7 @@ private fun BackupAlertSheetContentPreviewGeneric() { private fun BackupAlertSheetContentPreviewPayment() { Previews.BottomSheetPreview { BackupAlertSheetContent( - backupAlert = BackupAlert.FailedToRenew, - mediaTtl = 60.days - ) - } -} - -@SignalPreview -@Composable -private fun BackupAlertSheetContentPreviewMedia() { - Previews.BottomSheetPreview { - BackupAlertSheetContent( - backupAlert = BackupAlert.MediaBackupsAreOff(endOfPeriodSeconds = System.currentTimeMillis().milliseconds.inWholeSeconds), - pricePerMonth = "$2.99", - mediaTtl = 60.days + backupAlert = BackupAlert.FailedToRenew ) } } @@ -541,8 +479,7 @@ private fun BackupAlertSheetContentPreviewMedia() { private fun BackupAlertSheetContentPreviewDelete() { Previews.BottomSheetPreview { BackupAlertSheetContent( - backupAlert = BackupAlert.MediaWillBeDeletedToday, - mediaTtl = 60.days + backupAlert = BackupAlert.MediaWillBeDeletedToday ) } } @@ -552,8 +489,7 @@ private fun BackupAlertSheetContentPreviewDelete() { private fun BackupAlertSheetContentPreviewDiskFull() { Previews.BottomSheetPreview { BackupAlertSheetContent( - backupAlert = BackupAlert.DiskFull(requiredSpace = "12GB"), - mediaTtl = 60.days + backupAlert = BackupAlert.DiskFull(requiredSpace = "12GB") ) } } @@ -563,8 +499,7 @@ private fun BackupAlertSheetContentPreviewDiskFull() { private fun BackupAlertSheetContentPreviewBackupFailed() { Previews.BottomSheetPreview { BackupAlertSheetContent( - backupAlert = BackupAlert.BackupFailed, - mediaTtl = 60.days + backupAlert = BackupAlert.BackupFailed ) } } @@ -574,8 +509,7 @@ private fun BackupAlertSheetContentPreviewBackupFailed() { private fun BackupAlertSheetContentPreviewCouldNotRedeemBackup() { Previews.BottomSheetPreview { BackupAlertSheetContent( - backupAlert = BackupAlert.CouldNotRedeemBackup, - mediaTtl = 60.days + backupAlert = BackupAlert.CouldNotRedeemBackup ) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/MediaBackupsAreOffBottomSheet.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/MediaBackupsAreOffBottomSheet.kt new file mode 100644 index 0000000000..e7ea9132e2 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/MediaBackupsAreOffBottomSheet.kt @@ -0,0 +1,171 @@ +/* + * Copyright 2025 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.backup.v2.ui + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +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.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.dimensionResource +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.core.os.BundleCompat +import org.signal.core.ui.R +import org.signal.core.ui.compose.BottomSheets +import org.signal.core.ui.compose.Buttons +import org.signal.core.ui.compose.Previews +import org.signal.core.ui.compose.SignalPreview +import org.signal.core.util.gibiBytes +import org.signal.core.util.money.FiatMoney +import org.thoughtcrime.securesms.backup.v2.ui.subscription.MessageBackupsType +import org.thoughtcrime.securesms.billing.upgrade.UpgradeToPaidTierBottomSheet +import org.thoughtcrime.securesms.payments.FiatMoneyUtil +import java.math.BigDecimal +import java.util.Currency +import kotlin.time.Duration.Companion.days +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.seconds + +class MediaBackupsAreOffBottomSheet : UpgradeToPaidTierBottomSheet() { + + companion object { + private const val ARG_ALERT = "alert" + } + + private val backupAlert: BackupAlert by lazy(LazyThreadSafetyMode.NONE) { + BundleCompat.getParcelable(requireArguments(), ARG_ALERT, BackupAlert::class.java)!! + } + + @Composable + override fun UpgradeSheetContent( + paidBackupType: MessageBackupsType.Paid, + freeBackupType: MessageBackupsType.Free, + isSubscribeEnabled: Boolean, + onSubscribeClick: () -> Unit + ) { + SheetContent( + backupAlert as BackupAlert.MediaBackupsAreOff, + paidBackupType, + isSubscribeEnabled, + onSubscribeClick, + onNotNowClick = { dismissAllowingStateLoss() } + ) + } +} + +@Composable +private fun SheetContent( + mediaBackupsAreOff: BackupAlert.MediaBackupsAreOff, + paidBackupType: MessageBackupsType.Paid, + isSubscribeEnabled: Boolean, + onSubscribeClick: () -> Unit, + onNotNowClick: () -> Unit +) { + val resources = LocalContext.current.resources + val pricePerMonth = remember(paidBackupType) { + FiatMoneyUtil.format(resources, paidBackupType.pricePerMonth, FiatMoneyUtil.formatOptions().trimZerosAfterDecimal()) + } + + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = dimensionResource(id = R.dimen.gutter)) + ) { + BottomSheets.Handle() + + Spacer(modifier = Modifier.size(26.dp)) + + Box { + Image( + imageVector = ImageVector.vectorResource(id = org.thoughtcrime.securesms.R.drawable.image_signal_backups), + contentDescription = null, + modifier = Modifier + .size(80.dp) + .padding(2.dp) + ) + Icon( + imageVector = ImageVector.vectorResource(org.thoughtcrime.securesms.R.drawable.symbol_error_circle_fill_24), + contentDescription = null, + tint = MaterialTheme.colorScheme.error, + modifier = Modifier.align(Alignment.TopEnd) + ) + } + + val daysUntilDeletion = remember(mediaBackupsAreOff.endOfPeriodSeconds, paidBackupType.mediaTtl) { + ((System.currentTimeMillis().milliseconds - mediaBackupsAreOff.endOfPeriodSeconds.seconds) + paidBackupType.mediaTtl).inWholeDays.toInt() + } + + Text( + text = pluralStringResource(id = org.thoughtcrime.securesms.R.plurals.BackupAlertBottomSheet__your_backup_plan_has_expired, daysUntilDeletion, daysUntilDeletion), + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(bottom = 24.dp) + ) + + Text( + text = stringResource(id = org.thoughtcrime.securesms.R.string.BackupAlertBottomSheet__you_can_begin_paying_for_backups_again), + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(bottom = 36.dp) + ) + + Buttons.LargeTonal( + enabled = isSubscribeEnabled, + onClick = onSubscribeClick, + modifier = Modifier + .defaultMinSize(minWidth = 220.dp) + .padding(bottom = 16.dp) + ) { + Text(text = stringResource(org.thoughtcrime.securesms.R.string.BackupAlertBottomSheet__subscribe_for_s_month, pricePerMonth)) + } + + TextButton( + enabled = isSubscribeEnabled, + onClick = onNotNowClick, + modifier = Modifier.padding(bottom = 32.dp) + ) { + Text(text = stringResource(id = org.thoughtcrime.securesms.R.string.BackupAlertBottomSheet__not_now)) + } + } +} + +@SignalPreview +@Composable +private fun BackupAlertSheetContentPreviewMedia() { + Previews.BottomSheetPreview { + SheetContent( + mediaBackupsAreOff = BackupAlert.MediaBackupsAreOff(endOfPeriodSeconds = System.currentTimeMillis().milliseconds.inWholeSeconds), + paidBackupType = MessageBackupsType.Paid( + pricePerMonth = FiatMoney(BigDecimal.ONE, Currency.getInstance("USD")), + mediaTtl = 30.days, + storageAllowanceBytes = 1.gibiBytes.inWholeBytes + ), + isSubscribeEnabled = true, + onSubscribeClick = {}, + onNotNowClick = {} + ) + } +} 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 62436d8f5b..f3a92d4754 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 @@ -65,8 +65,7 @@ class MessageBackupsFlowViewModel( val stateFlow: StateFlow = internalStateFlow init { - // TODO this is not correct for all usages. See BackupAlertBottomSheet. -// check(SignalStore.backup.backupTier != MessageBackupTier.PAID) { "This screen does not support cancellation or downgrades." } + check(SignalStore.backup.backupTier != MessageBackupTier.PAID) { "This screen does not support cancellation or downgrades." } viewModelScope.launch { val result = withContext(SignalDispatchers.IO) {