Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,23 +1,29 @@
import 'package:bb_mobile/core/themes/app_theme.dart';
import 'package:bb_mobile/core/utils/build_context_x.dart';
import 'package:bb_mobile/core/widgets/buttons/button.dart';
import 'package:bb_mobile/core/widgets/navbar/top_bar.dart';
import 'package:bb_mobile/core/widgets/snackbar_utils.dart';
import 'package:bb_mobile/features/backup_settings/presentation/backup_settings_failure_l10n.dart';
import 'package:bb_mobile/features/backup_settings/presentation/cubit/wallet_backup_settings_cubit.dart';
import 'package:bb_mobile/features/backup_settings/presentation/cubit/wallet_backup_settings_state.dart';
import 'package:bb_mobile/features/backup_settings/ui/widgets/wallet_backup_controls.dart';
import 'package:bb_mobile/features/wallet_backup/public/wallet_backup_facade.dart';
import 'package:bb_mobile/locator.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:gap/gap.dart';
import 'package:go_router/go_router.dart';
import 'package:intl/intl.dart';

/// Everything the Get Paid metadata backup can be told to do, on its own
/// surface: the automatic-backup toggle, an immediate write, deletion of the
/// remote copy, the last result, and the recovery retry.
///
/// The Backup Settings screen keeps only the one-line summary; every action
/// lives here. Pushed as its own route, so it resolves its own cubit rather
/// than reading the one the parent screen provides. This is also the landing
/// surface for the unified backup contract work, so it stays self-contained.
/// Laid out like the Backup Settings screen it is opened from — the state stated
/// first as a label/value line with the one fact that matters under it, then any
/// notice that needs an answer, then the actions as full-width buttons. The
/// Backup Settings screen keeps only the one-line summary; every action lives
/// here. Pushed as its own route, so it resolves its own cubit rather than
/// reading the one the parent screen provides.
class MetadataBackupOptionsScreen extends StatelessWidget {
const MetadataBackupOptionsScreen({super.key});

Expand Down Expand Up @@ -52,15 +58,340 @@ class _Screen extends StatelessWidget {
onBack: () => context.pop(),
),
),
body: const SafeArea(
child: SingleChildScrollView(
child: Padding(
padding: EdgeInsets.all(16),
child: WalletBackupControls(),
body: const SafeArea(child: _Body()),
),
);
}
}

class _Body extends StatelessWidget {
const _Body();

@override
Widget build(BuildContext context) {
return BlocBuilder<WalletBackupSettingsCubit, WalletBackupSettingsState>(
builder: (context, state) {
final backup = state.backup;
final busy = state.busy;
final recoveryRetryResult = state.recoveryRetryResult;
return SingleChildScrollView(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Column(
crossAxisAlignment: .stretch,
children: [
Text(
context.loc.walletBackupSettingsDescription,
style: context.font.bodyMedium?.copyWith(
color: context.appColors.textMuted,
),
),
const Gap(24),
// Nothing is claimed until the first read resolves: an unread
// backup is neither on nor off.
if (state.loading && backup == null)
const LinearProgressIndicator()
else ...[
_StatusLine(state: state, backup: backup),
if (backup?.recoveryBlocked == true) ...[
const Gap(24),
_RecoveryNotice(
recovering:
state.operation ==
WalletBackupSettingsOperation.recovering,
onRetry: state.canRetryRecovery
? context
.read<WalletBackupSettingsCubit>()
.retryRecovery
: null,
),
],
if (recoveryRetryResult != null) ...[
const Gap(16),
_RecoveryRetryResult(result: recoveryRetryResult),
],
const Gap(24),
_AutomaticBackupSwitch(backup: backup, busy: busy),
const Gap(24),
BBButton.big(
label: context.loc.walletBackupSettingsBackupNow,
iconData: Icons.backup,
iconFirst: true,
onPressed: () =>
context.read<WalletBackupSettingsCubit>().backupNow(),
disabled: !_canBackUpNow(backup: backup, busy: busy),
bgColor: context.appColors.primary,
textColor: context.appColors.onPrimary,
),
const Gap(12),
BBButton.big(
label: context.loc.walletBackupSettingsDelete,
iconData: Icons.delete_outline,
iconFirst: true,
onPressed: () => _confirmDelete(context),
disabled: !_canDelete(backup: backup, busy: busy),
outlined: true,
bgColor: context.appColors.surface,
textColor: context.appColors.error,
borderColor: context.appColors.error,
),
if (busy) ...[const Gap(16), const LinearProgressIndicator()],
],
],
),
),
);
},
);
}

/// An immediate write only makes sense while automatic backup is on and the
/// backup is neither blocked on recovery nor written by a newer app version.
bool _canBackUpNow({
required WalletBackupState? backup,
required bool busy,
}) =>
backup?.enabled == true &&
!busy &&
backup?.recoveryBlocked != true &&
backup?.unsupportedVersion == null;

/// The remote copy is only removable once automatic backup is off, so a
/// deletion cannot race the next scheduled write.
bool _canDelete({required WalletBackupState? backup, required bool busy}) =>
backup != null && !backup.enabled && !busy;

Future<void> _confirmDelete(BuildContext context) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (dialogContext) => AlertDialog(
title: Text(context.loc.walletBackupSettingsDeleteTitle),
content: Text(context.loc.walletBackupSettingsDeleteDescription),
actions: [
TextButton(
onPressed: () => Navigator.of(dialogContext).pop(false),
child: Text(context.loc.walletBackupSettingsCancel),
),
FilledButton(
onPressed: () => Navigator.of(dialogContext).pop(true),
child: Text(context.loc.walletBackupSettingsDeleteConfirm),
),
],
),
);
if (confirmed != true || !context.mounted) return;
await context.read<WalletBackupSettingsCubit>().deleteRemoteBackup();
}
}

/// On or off, and the one fact that changes what to do next — the same
/// label / coloured value / muted sub-line shape the Backup Settings status
/// rows use.
class _StatusLine extends StatelessWidget {
final WalletBackupSettingsState state;
final WalletBackupState? backup;

const _StatusLine({required this.state, required this.backup});

@override
Widget build(BuildContext context) {
final isOn = backup?.enabled ?? false;
return Column(
crossAxisAlignment: .start,
children: [
Row(
children: [
Text(
context.loc.walletBackupSettingsTitle,
style: context.font.bodyMedium,
),
const Spacer(),
Text(
isOn
? context.loc.backupSettingsMetadataTurnedOn
: context.loc.backupSettingsMetadataTurnedOff,
style: context.font.bodyMedium?.copyWith(
color: isOn
? context.appColors.success
: context.appColors.error,
),
),
],
),
const Gap(4),
Text(
_statusText(context, backup),
style: context.font.bodySmall?.copyWith(
color: context.appColors.textMuted,
),
),
],
);
}

String _statusText(BuildContext context, WalletBackupState? backup) {
if (backup == null) return context.loc.walletBackupSettingsFailed;
if (backup.unsupportedVersion != null) {
return context.loc.walletBackupSettingsUpdateRequired;
}
if (backup.recoveryBlocked) {
return context.loc.walletBackupSettingsRecoveryBlocked;
}
if (!backup.enabled) return context.loc.walletBackupSettingsOff;
if (backup.dirty) return context.loc.walletBackupSettingsPending;
final succeededAt = backup.lastSucceededAt;
if (succeededAt == null) {
return context.loc.walletBackupSettingsNeverBackedUp;
}
final date = DateTime.fromMillisecondsSinceEpoch(
succeededAt * 1000,
isUtc: true,
).toLocal();
return context.loc.walletBackupSettingsLastBackup(
DateFormat('MMM d, y HH:mm').format(date),
);
}
}

class _AutomaticBackupSwitch extends StatelessWidget {
final WalletBackupState? backup;
final bool busy;

const _AutomaticBackupSwitch({required this.backup, required this.busy});

@override
Widget build(BuildContext context) {
final current = backup;
// A backup written by a newer app version must not be turned back on from
// here, but it can always be turned off.
final locked =
current == null ||
busy ||
(current.unsupportedVersion != null && !current.enabled);
return SwitchListTile(
contentPadding: EdgeInsets.zero,
title: Text(context.loc.walletBackupSettingsEnabled),
value: current?.enabled ?? false,
onChanged: locked
? null
: context.read<WalletBackupSettingsCubit>().setEnabled,
);
}
}

/// A recovery that stopped half-way needs an answer before anything else on this
/// screen is worth doing, so it is stated as its own bordered notice with the
/// single action that resolves it.
class _RecoveryNotice extends StatelessWidget {
final bool recovering;
final VoidCallback? onRetry;

const _RecoveryNotice({required this.recovering, required this.onRetry});

@override
Widget build(BuildContext context) {
final accent = context.appColors.error;
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: context.appColors.surfaceContainer,
border: Border.all(color: accent, width: 2),
borderRadius: BorderRadius.circular(2),
),
child: Column(
crossAxisAlignment: .start,
children: [
Text(
context.loc.metadataBackupStateRecoveryIncomplete,
style: context.font.titleMedium?.copyWith(
fontWeight: .bold,
color: accent,
),
),
const Gap(8),
Text(
context.loc.metadataBackupStateRecoveryIncompleteBody,
style: context.font.bodyMedium,
),
const Gap(16),
BBButton.big(
label: context.loc.metadataBackupRetryRecovery,
iconData: Icons.restore,
iconFirst: true,
onPressed: onRetry ?? () {},
disabled: onRetry == null || recovering,
bgColor: accent,
textColor: context.appColors.surface,
),
],
),
);
}
}

/// A manual retry is an explicit user action, so its result remains visible
/// instead of disappearing into state or relying on a transient snackbar.
class _RecoveryRetryResult extends StatelessWidget {
final WalletBackupRecoveryResultSummary result;

const _RecoveryRetryResult({required this.result});

@override
Widget build(BuildContext context) {
final accent = result.failed
? context.appColors.error
: context.appColors.success;
return Semantics(
container: true,
liveRegion: true,
child: Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: context.appColors.surfaceContainer,
border: Border.all(color: accent),
borderRadius: BorderRadius.circular(2),
),
child: Column(
crossAxisAlignment: .start,
children: [
Text(
context.loc.metadataBackupRecoveryRetryResult,
style: context.font.bodySmall?.copyWith(
color: context.appColors.textMuted,
),
),
const Gap(4),
Text(
_title(context),
style: context.font.titleMedium?.copyWith(
color: accent,
fontWeight: .bold,
),
),
const Gap(8),
Text(
context.loc.metadataBackupRecoveryRetryCounts(
result.restoredCount,
result.failedCount,
),
style: context.font.bodyMedium,
),
],
),
),
);
}

String _title(BuildContext context) => switch (result.kind) {
WalletBackupRecoveryResultKind.restored =>
context.loc.metadataBackupRecoveryRetryComplete,
WalletBackupRecoveryResultKind.nothingToRestore =>
context.loc.metadataBackupRecoveryRetryNothingToRestore,
WalletBackupRecoveryResultKind.noBackup =>
context.loc.metadataBackupRecoveryRetryNoBackup,
WalletBackupRecoveryResultKind.incomplete =>
context.loc.metadataBackupStateRecoveryIncomplete,
WalletBackupRecoveryResultKind.failed =>
context.loc.metadataBackupRecoveryRetryFailed,
};
}
Loading