Polish deletion UX.

This commit is contained in:
Alex Hart
2025-05-30 15:33:44 -03:00
committed by Cody Henthorne
parent ccce37d023
commit df170dac32
12 changed files with 315 additions and 109 deletions

View File

@@ -50,7 +50,6 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.Stable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
@@ -275,6 +274,10 @@ class RemoteBackupsSettingsFragment : ComposeFragment() {
override fun onRedemptionErrorDetailsClick() {
BackupAlertBottomSheet.create(BackupAlert.CouldNotRedeemBackup).show(parentFragmentManager, null)
}
override fun onDisplayProgressDialog() {
viewModel.requestDialog(RemoteBackupsSettingsState.Dialog.PROGRESS_SPINNER)
}
}
private fun displayBackupKey() {
@@ -361,6 +364,7 @@ private interface ContentCallbacks {
fun onRestoreUsingCellularConfirm() = Unit
fun onRestoreUsingCellularClick() = Unit
fun onRedemptionErrorDetailsClick() = Unit
fun onDisplayProgressDialog() = Unit
object Emtpy : ContentCallbacks
}
@@ -390,6 +394,12 @@ private fun RemoteBackupsSettingsContent(
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
LaunchedEffect(backupDeleteState) {
if (backupDeleteState != DeletionState.NONE && backupDeleteState != DeletionState.CLEAR_LOCAL_STATE) {
contentCallbacks.onDialogDismissed()
}
}
Scaffold(
topBar = {
Scaffolds.DefaultTopAppBar(
@@ -449,7 +459,7 @@ private fun RemoteBackupsSettingsContent(
state = backupState,
onLearnMoreClick = contentCallbacks::onLearnMoreAboutLostSubscription,
onRenewClick = contentCallbacks::onRenewLostSubscription,
isRenewEnabled = backupDeleteState != DeletionState.RUNNING
isRenewEnabled = backupDeleteState != DeletionState.DELETE_BACKUPS
)
}
@@ -459,7 +469,7 @@ private fun RemoteBackupsSettingsContent(
BackupCard(
backupState = backupState,
onBackupTypeActionButtonClicked = contentCallbacks::onBackupTypeActionClick,
buttonsEnabled = backupDeleteState != DeletionState.RUNNING
buttonsEnabled = backupDeleteState != DeletionState.DELETE_BACKUPS
)
}
@@ -468,14 +478,19 @@ private fun RemoteBackupsSettingsContent(
title = stringResource(R.string.RemoteBackupsSettingsFragment__your_subscription_was_not_found),
onRenewClick = contentCallbacks::onRenewLostSubscription,
onLearnMoreClick = contentCallbacks::onLearnMoreAboutLostSubscription,
isRenewEnabled = backupDeleteState != DeletionState.RUNNING
isRenewEnabled = backupDeleteState != DeletionState.DELETE_BACKUPS
)
}
}
}
if (backupDeleteState != DeletionState.NONE) {
appendBackupDeletionState(backupDeleteState, contentCallbacks)
if (backupDeleteState != DeletionState.NONE && backupDeleteState != DeletionState.CLEAR_LOCAL_STATE) {
appendBackupDeletionItems(
backupDeleteState = backupDeleteState,
backupRestoreState = backupRestoreState,
canRestoreUsingCellular = canRestoreUsingCellular,
contentCallbacks = contentCallbacks
)
} else if (backupsEnabled) {
appendBackupDetailsItems(
backupState = backupState,
@@ -619,22 +634,42 @@ private fun ReenableBackupsButton(contentCallbacks: ContentCallbacks) {
}
}
private fun LazyListScope.appendBackupDeletionState(
private fun LazyListScope.appendRestoreFromBackupStatusData(
backupRestoreState: BackupRestoreState.FromBackupStatusData,
canRestoreUsingCellular: Boolean,
contentCallbacks: ContentCallbacks
) {
item {
BackupStatusRow(
backupStatusData = backupRestoreState.backupStatusData,
onCancelClick = contentCallbacks::onCancelMediaRestore,
onSkipClick = contentCallbacks::onSkipMediaRestore,
onLearnMoreClick = contentCallbacks::onLearnMoreAboutBackupFailure
)
}
if (!canRestoreUsingCellular) {
item {
Rows.TextRow(
text = stringResource(R.string.RemoteBackupsSettingsFragment__resume_download),
icon = painterResource(R.drawable.symbol_arrow_circle_down_24),
onClick = contentCallbacks::onRestoreUsingCellularConfirm
)
}
}
}
private fun LazyListScope.appendBackupDeletionItems(
backupDeleteState: DeletionState,
backupRestoreState: BackupRestoreState,
canRestoreUsingCellular: Boolean,
contentCallbacks: ContentCallbacks
) {
when (backupDeleteState) {
DeletionState.NONE -> return
DeletionState.FAILED -> {
item {
Text(
text = stringResource(R.string.RemoteBackupsSettingsFragment__backups_have_been_turned_off_but_there_was_an_error),
color = MaterialTheme.colorScheme.onSurfaceVariant,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier
.padding(horizontal = 16.dp)
.padding(top = 16.dp)
)
DescriptionText(text = stringResource(R.string.RemoteBackupsSettingsFragment__backups_have_been_turned_off_but_there_was_an_error))
}
item {
@@ -665,18 +700,33 @@ private fun LazyListScope.appendBackupDeletionState(
}
}
DeletionState.RUNNING -> {
DeletionState.AWAITING_MEDIA_DOWNLOAD -> {
item {
Text(
text = stringResource(R.string.RemoteBackupsSettingsFragment__backups_have_been_turned_off_and_your_data),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier
.padding(horizontal = 16.dp)
.padding(top = 16.dp)
DescriptionText(
text = stringResource(R.string.RemoteBackupsSettingsFragment__backups_have_been_turned_off_your_data_will_be)
)
}
if (backupRestoreState is BackupRestoreState.FromBackupStatusData) {
appendRestoreFromBackupStatusData(
backupRestoreState = backupRestoreState,
canRestoreUsingCellular = canRestoreUsingCellular,
contentCallbacks = contentCallbacks
)
} else {
item {
LinearProgressIndicator(
modifier = Modifier.fillMaxWidth()
)
}
}
}
DeletionState.DELETE_BACKUPS -> {
item {
DescriptionText(text = stringResource(R.string.RemoteBackupsSettingsFragment__backups_have_been_turned_off_and_your_data))
}
item {
Column(
verticalArrangement = spacedBy(12.dp),
@@ -696,9 +746,40 @@ private fun LazyListScope.appendBackupDeletionState(
}
}
}
DeletionState.COMPLETE -> {
item {
DescriptionText(
text = stringResource(R.string.RemoteBackupsSettingsFragment__backups_have_been_turned_off_and_your_data_has_been_deleted),
modifier = Modifier.padding(bottom = 12.dp)
)
}
item {
ReenableBackupsButton(contentCallbacks)
}
}
DeletionState.CLEAR_LOCAL_STATE -> Unit
}
}
@Composable
private fun DescriptionText(
text: String,
modifier: Modifier = Modifier
) {
Text(
text = text,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier
.padding(horizontal = 16.dp)
.padding(top = 16.dp)
.then(modifier)
)
}
private fun LazyListScope.appendBackupDetailsItems(
backupState: RemoteBackupsSettingsState.BackupState,
backupRestoreState: BackupRestoreState,
@@ -720,24 +801,11 @@ private fun LazyListScope.appendBackupDetailsItems(
if (backupRestoreState !is BackupRestoreState.None) {
if (backupRestoreState is BackupRestoreState.FromBackupStatusData) {
item {
BackupStatusRow(
backupStatusData = backupRestoreState.backupStatusData,
onCancelClick = contentCallbacks::onCancelMediaRestore,
onSkipClick = contentCallbacks::onSkipMediaRestore,
onLearnMoreClick = contentCallbacks::onLearnMoreAboutBackupFailure
)
}
if (!canRestoreUsingCellular) {
item {
Rows.TextRow(
text = stringResource(R.string.RemoteBackupsSettingsFragment__resume_download),
icon = painterResource(R.drawable.symbol_arrow_circle_down_24),
onClick = contentCallbacks::onRestoreUsingCellularConfirm
)
}
}
appendRestoreFromBackupStatusData(
backupRestoreState = backupRestoreState,
canRestoreUsingCellular = canRestoreUsingCellular,
contentCallbacks = contentCallbacks
)
} else if (backupRestoreState is BackupRestoreState.Ready) {
item {
BackupReadyToDownloadRow(
@@ -1363,7 +1431,7 @@ private fun TurnOffAndDeleteBackupsDialog(
) {
Dialogs.SimpleAlertDialog(
title = stringResource(id = R.string.RemoteBackupsSettingsFragment__turn_off_and_delete_backups),
body = stringResource(id = R.string.RemoteBackupsSettingsFragment__you_will_not_be_charged_again),
body = stringResource(id = R.string.RemoteBackupsSettingsFragment__your_backup_will_be_deleted_and_no_new_backups_will_be_created),
confirm = stringResource(id = R.string.RemoteBackupsSettingsFragment__turn_off_and_delete),
dismiss = stringResource(id = android.R.string.cancel),
confirmColor = MaterialTheme.colorScheme.error,
@@ -1879,28 +1947,31 @@ private fun BackupFrequencyDialogPreview() {
@SignalPreview
@Composable
private fun BackupDeletionCardPreview() {
var backupDeletionState by remember { mutableStateOf(DeletionState.NONE) }
Previews.Preview {
LazyColumn {
item {
Buttons.MediumTonal(
onClick = {
backupDeletionState = when (backupDeletionState) {
DeletionState.FAILED -> DeletionState.NONE
DeletionState.NONE -> DeletionState.RUNNING
DeletionState.RUNNING -> DeletionState.FAILED
}
}
) {
Text("Next Deletion State")
for (state in DeletionState.entries.filter { it.hasUx() }) {
appendBackupDeletionItems(
backupDeleteState = state,
backupRestoreState = BackupRestoreState.FromBackupStatusData(
backupStatusData = BackupStatusData.RestoringMedia(
bytesDownloaded = 80.mebiBytes,
bytesTotal = 3.gibiBytes
)
),
contentCallbacks = ContentCallbacks.Emtpy,
canRestoreUsingCellular = true
)
item {
Dividers.Default()
}
}
appendBackupDeletionState(backupDeletionState, contentCallbacks = ContentCallbacks.Emtpy)
}
}
}
private fun DeletionState.hasUx() = this !in setOf(DeletionState.NONE, DeletionState.CLEAR_LOCAL_STATE)
private fun ArchiveUploadProgressState.frameExportProgress(): Float {
return if (this.frameTotalCount == 0L) {
0f

View File

@@ -14,7 +14,6 @@ import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.takeWhile
import kotlinx.coroutines.flow.update
@@ -47,6 +46,7 @@ import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.keyvalue.protos.ArchiveUploadProgressState
import org.thoughtcrime.securesms.service.MessageBackupListener
import java.util.Currency
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds
/**
@@ -78,11 +78,9 @@ class RemoteBackupsSettingsViewModel : ViewModel() {
init {
viewModelScope.launch(Dispatchers.IO) {
SignalStore.backup.deletionStateFlow
.filter { it == DeletionState.NONE }
.collect {
refresh()
}
SignalStore.backup.deletionStateFlow.collectLatest {
refresh()
}
}
viewModelScope.launch(Dispatchers.IO) {
@@ -164,6 +162,10 @@ class RemoteBackupsSettingsViewModel : ViewModel() {
fun skipMediaRestore() {
BackupRepository.skipMediaRestore()
if (SignalStore.backup.deletionState == DeletionState.AWAITING_MEDIA_DOWNLOAD) {
BackupRepository.continueTurningOffAndDisablingBackups()
}
}
fun requestDialog(dialog: RemoteBackupsSettingsState.Dialog) {
@@ -187,6 +189,8 @@ class RemoteBackupsSettingsViewModel : ViewModel() {
}
fun turnOffAndDeleteBackups() {
requestDialog(RemoteBackupsSettingsState.Dialog.PROGRESS_SPINNER)
viewModelScope.launch(Dispatchers.IO) {
BackupRepository.turnOffAndDisableBackups()
}
@@ -202,6 +206,7 @@ class RemoteBackupsSettingsViewModel : ViewModel() {
private suspend fun refreshState(lastPurchase: InAppPaymentTable.InAppPayment?) {
try {
Log.i(TAG, "Performing a state refresh.")
performStateRefresh(lastPurchase)
} catch (e: Exception) {
Log.w(TAG, "State refresh failed", e)
@@ -241,7 +246,7 @@ class RemoteBackupsSettingsViewModel : ViewModel() {
Log.d(TAG, "[subscriptionStateMismatchDetected] A mismatch was detected.")
val hasActiveGooglePlayBillingSubscription = when (val purchaseResult = AppDependencies.billingApi.queryPurchases()) {
is BillingPurchaseResult.Success -> purchaseResult.isAcknowledged && purchaseResult.isWithinTheLastMonth()
is BillingPurchaseResult.Success -> purchaseResult.isAcknowledged && purchaseResult.isWithinTheLastMonth() && purchaseResult.isAutoRenewing
else -> false
} || SignalStore.backup.backupTierInternalOverride == MessageBackupTier.PAID
@@ -339,6 +344,15 @@ class RemoteBackupsSettingsViewModel : ViewModel() {
backupState = RemoteBackupsSettingsState.BackupState.NotFound
)
}
} else if (lastPurchase != null && lastPurchase.endOfPeriod > System.currentTimeMillis().milliseconds) {
_state.update {
it.copy(
backupState = RemoteBackupsSettingsState.BackupState.Canceled(
messageBackupsType = type,
renewalTime = lastPurchase.endOfPeriod
)
)
}
} else {
_state.update {
it.copy(