Skip to content
152 changes: 131 additions & 21 deletions lib/features/account/providers/backup_reminder_provider.dart
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import 'package:flutter/foundation.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shared_preferences/shared_preferences.dart';

import 'package:mostro/src/rust/api/identity.dart' as identity_api;

const kBackupReminderDismissedKey = 'backupReminderDismissed';
const kBackupReminderActiveKey = 'backupReminderActive';

Expand All @@ -19,17 +22,17 @@ const kBackupSnoozedUntilKey = 'backupSnoozedUntilMillis';
/// Dismissed permanently after `confirmBackupComplete()` is called.
final backupReminderProvider =
StateNotifierProvider<BackupReminderNotifier, bool>(
(ref) => BackupReminderNotifier(),
);
(ref) => BackupReminderNotifier(),
);

/// Whether the user has ever completed a backup of the current identity.
///
/// Drives the "Backed up" badge on the Account screen. Reset when a new
/// identity is generated or imported.
final backupCompletedProvider =
StateNotifierProvider<BackupCompletedNotifier, bool>(
(ref) => BackupCompletedNotifier(),
);
(ref) => BackupCompletedNotifier(),
);

class BackupReminderNotifier extends StateNotifier<bool> {
/// When [initialValue] is provided the notifier starts with the correct
Expand Down Expand Up @@ -112,41 +115,148 @@ class BackupReminderNotifier extends StateNotifier<bool> {
}

class BackupCompletedNotifier extends StateNotifier<bool> {
BackupCompletedNotifier({bool? initialValue}) : super(initialValue ?? false) {
/// The three bridge calls are injectable so the notifier is testable without
/// a live Rust runtime; they default to the real identity-bridge functions
/// (issue #141).
BackupCompletedNotifier({
bool? initialValue,
Future<bool> Function()? getConfirmed,
Future<void> Function(bool confirmed)? setConfirmed,
Future<void> Function()? resetConfirmed,
// Test seam: force the web (SharedPreferences-authoritative) path off-web.
// Defaults to the real platform flag.
bool? isWebOverride,
}) : _getConfirmed = getConfirmed ?? identity_api.getBackupConfirmed,
_setConfirmed =
setConfirmed ??
((confirmed) =>
identity_api.setBackupConfirmed(confirmed: confirmed)),
_resetConfirmed = resetConfirmed ?? identity_api.resetBackupConfirmation,
_isWeb = isWebOverride ?? kIsWeb,
super(initialValue ?? false) {
if (initialValue == null) {
load();
} else {
_loaded = true;
}
}

final Future<bool> Function() _getConfirmed;
final Future<void> Function(bool confirmed) _setConfirmed;
final Future<void> Function() _resetConfirmed;
final bool _isWeb;

// Web has no durable Rust identity store until #233, but SharedPreferences
// (backed by localStorage) IS durable there. So on web the backup-confirmed
// flag is read/written/cleared directly in kBackupCompletedKey, and the Rust
// bridge is used only on native. This keeps a confirmed backup surviving a
// page reload on web, instead of resetting to the session-only Rust default.
// (#141 review — CodeRabbit)
Future<bool> _readConfirmed() async {
if (_isWeb) {
final prefs = await SharedPreferences.getInstance();
return prefs.getBool(kBackupCompletedKey) ?? false;
}
return _getConfirmed();
}

Future<void> _writeConfirmed(bool confirmed) async {
if (_isWeb) {
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(kBackupCompletedKey, confirmed);
return;
}
await _setConfirmed(confirmed);
}

Future<void> _clearConfirmed() async {
if (_isWeb) {
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(kBackupCompletedKey, false);
return;
}
await _resetConfirmed();
}

bool _loaded = false;

Future<void> load() async {
if (_loaded) return;
final prefs = await SharedPreferences.getInstance();
// Legacy installs only have the dismissed flag, which was set exclusively
// by the explicit "I have written down my secret words" confirmation —
// treat it as a completed backup.
state = prefs.getBool(kBackupCompletedKey) ??
prefs.getBool(kBackupReminderDismissedKey) ??
false;
_loaded = true;
/// Marks that the one-time SharedPreferences -> Rust migration has run, so
/// the legacy key is only ever read once (issue #141).
static const _kMigratedKey = 'backupCompletedMigratedToRust';

// Coalesce concurrent load()s: the constructor fires load() un-awaited, and a
// caller (or test) may await load() before it finishes. Without sharing the
// in-flight future, both could pass the _loaded check, run the one-time
// migration, and call _setConfirmed twice. Cleared on completion so a failed
// load (which leaves _loaded false) can be retried. (#141 review — CodeRabbit)
Future<void>? _loading;

Future<void> load() {
if (_loaded) return Future.value();
return _loading ??= _load().whenComplete(() => _loading = null);
}

Future<void> _load() async {
// The backup-confirmed flag now lives in the Rust identity record. On the
// first run after upgrading, copy the legacy SharedPreferences value into
// Rust once, then read from Rust exclusively.
try {
// #141 review: skip the migration entirely on web. There, initDb is
// never called (main.dart guards it with !kIsWeb), so set_backup_confirmed
// has no store and returns Ok WITHOUT persisting. Running the migration
// would burn the durable _kMigratedKey (localStorage) against that
// non-durable write, consuming the legacy SharedPreferences value and
// re-arming the reminder on every reload. Until IndexedDB save_identity
// lands (#233), the legacy SharedPreferences flag stays authoritative on
// web, so we neither migrate nor mark it migrated.
if (!_isWeb) {
final prefs = await SharedPreferences.getInstance();
final migrated = prefs.getBool(_kMigratedKey) ?? false;
if (!migrated) {
// Legacy installs only have the dismissed flag, which was set
// exclusively by the explicit "I have written down my secret words"
// confirmation — treat it as a completed backup.
final legacy =
prefs.getBool(kBackupCompletedKey) ??
prefs.getBool(kBackupReminderDismissedKey) ??
false;
if (legacy) {
// Best-effort: if no identity is loaded yet, the bridge throws and
// we simply leave Rust at its default (false); the reminder stays
// armed, which is safe. The marker is only set once the copy
// sticks — and on native the write is always durable here.
await _setConfirmed(true);
}
await prefs.setBool(_kMigratedKey, true);
}
}
state = await _readConfirmed();
// Only mark loaded once the read succeeded. If the bridge was not
// ready (no identity yet), leaving _loaded false lets the next load()
// retry instead of pinning the UI to `false` for the whole session.
_loaded = true;
} catch (e) {
// Rust unavailable (e.g. no identity yet, or tests without the bridge):
// fall back to unconfirmed so the reminder stays armed, and let a later
// load() retry (we deliberately do NOT set _loaded here).
debugPrint('[backup] load() failed, reminder stays armed: $e');
state = false;
}
}

/// Persist that the current identity has been backed up.
/// Persist that the current identity has been backed up (Rust identity
/// record, #141).
Future<void> markCompleted() async {
await load();
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(kBackupCompletedKey, true);
await _writeConfirmed(true);
state = true;
}

/// Clear the backed-up flag (new identity generated or imported).
/// Clear the backed-up flag (new identity generated or imported). The Rust
/// side is also reset in `create_identity`; this keeps the UI in sync (#141).
Future<void> reset() async {
await load();
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(kBackupCompletedKey, false);
await _clearConfirmed();
state = false;
}
}
36 changes: 17 additions & 19 deletions lib/features/account/screens/account_screen.dart
Original file line number Diff line number Diff line change
Expand Up @@ -44,11 +44,9 @@ class _AccountScreenState extends ConsumerState<AccountScreen> {

if (!mounted) return;
if (words.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(l10n.noIdentityFoundMessage),
),
);
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(l10n.noIdentityFoundMessage)));
return;
}
final backupPending = ref.read(backupReminderProvider);
Expand Down Expand Up @@ -79,8 +77,12 @@ class _AccountScreenState extends ConsumerState<AccountScreen> {
Future<void> _confirmBackup() async {
final l10n = AppLocalizations.of(context);
try {
await ref.read(backupReminderProvider.notifier).confirmBackupComplete();
// Authoritative Rust write first; only dismiss the reminder locally once
// it succeeds. If markCompleted() throws, the catch below fires before the
// permanent local dismissal, keeping the reminder armed and consistent
// with backup_confirmed=false. (#141 review)
await ref.read(backupCompletedProvider.notifier).markCompleted();
await ref.read(backupReminderProvider.notifier).confirmBackupComplete();
if (mounted) setState(() => _showBackupCheckbox = false);
} catch (e) {
debugPrint('[account] _confirmBackup error: $e');
Expand Down Expand Up @@ -129,9 +131,7 @@ class _AccountScreenState extends ConsumerState<AccountScreen> {
// ── Backup ritual banner — entry point for the 3-step backup
// flow while the backup reminder is active.
if (backupPending) ...[
_BackupRitualBanner(
onTap: () => showBackupTriggerSheet(context),
),
_BackupRitualBanner(onTap: () => showBackupTriggerSheet(context)),
const SizedBox(height: AppSpacing.lg),
],

Expand Down Expand Up @@ -212,7 +212,9 @@ class _AccountScreenState extends ConsumerState<AccountScreen> {
color: green,
),
label: Text(
_wordsVisible ? l10n.hideButtonLabel : l10n.showButtonLabel,
_wordsVisible
? l10n.hideButtonLabel
: l10n.showButtonLabel,
style: TextStyle(color: green),
),
),
Expand Down Expand Up @@ -449,9 +451,7 @@ class _AccountScreenState extends ConsumerState<AccountScreen> {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
kDebugMode
? 'Import failed: $e'
: l10n.invalidMnemonicMessage,
kDebugMode ? 'Import failed: $e' : l10n.invalidMnemonicMessage,
),
),
);
Expand Down Expand Up @@ -552,10 +552,7 @@ class _CardHeader extends StatelessWidget {
context,
).textTheme.titleMedium!.copyWith(fontWeight: FontWeight.w600),
),
if (badge != null) ...[
const SizedBox(width: AppSpacing.sm),
badge!,
],
if (badge != null) ...[const SizedBox(width: AppSpacing.sm), badge!],
const Spacer(),
IconButton(
onPressed: onInfo,
Expand Down Expand Up @@ -617,8 +614,9 @@ class _BackupRitualBanner extends StatelessWidget {
const SizedBox(height: 2),
Text(
l10n.backupBannerSubtitle,
style:
theme.textTheme.bodySmall!.copyWith(color: textSec),
style: theme.textTheme.bodySmall!.copyWith(
color: textSec,
),
),
],
),
Expand Down
Loading
Loading