From 6da70abc0faead0456dfa6f3a438125fb8ee01de Mon Sep 17 00:00:00 2001 From: otsmr Date: Fri, 4 Sep 2026 13:42:44 +0200 Subject: [PATCH] add recovery view --- .../generated/app_localizations.dart | 54 +++ .../generated/app_localizations_de.dart | 31 ++ .../generated/app_localizations_en.dart | 30 ++ lib/src/localization/translations/de.arb | 9 + lib/src/localization/translations/en.arb | 9 + lib/src/services/backup.service.dart | 39 +- .../permissions_view.dart | 58 +-- .../onboarding/recover_password.view.dart | 62 ++-- .../onboarding/recover_passwordless.view.dart | 45 ++- .../onboarding/recovery_progress.view.dart | 343 ++++++++++++++++++ 10 files changed, 585 insertions(+), 95 deletions(-) create mode 100644 lib/src/visual/views/onboarding/recovery_progress.view.dart diff --git a/lib/src/localization/generated/app_localizations.dart b/lib/src/localization/generated/app_localizations.dart index df74c536..e17592d7 100644 --- a/lib/src/localization/generated/app_localizations.dart +++ b/lib/src/localization/generated/app_localizations.dart @@ -194,6 +194,18 @@ abstract class AppLocalizations { /// **'Send to'** String get cameraPreviewSendTo; + /// No description provided for @cameraPermissionsBody. + /// + /// In en, this message translates to: + /// **'twonly needs access to the camera and microphone.'** + String get cameraPermissionsBody; + + /// No description provided for @cameraPermissionsRequestButton. + /// + /// In en, this message translates to: + /// **'Request permissions'** + String get cameraPermissionsRequestButton; + /// No description provided for @shareImageTitle. /// /// In en, this message translates to: @@ -3356,6 +3368,48 @@ abstract class AppLocalizations { /// **'Click here to open the app again'** String get recoverSuccessBody; + /// No description provided for @recoverProgressTitle. + /// + /// In en, this message translates to: + /// **'Restoring your backup'** + String get recoverProgressTitle; + + /// No description provided for @recoverProgressSubtitle. + /// + /// In en, this message translates to: + /// **'This might take a moment, please keep the app open.'** + String get recoverProgressSubtitle; + + /// No description provided for @recoverProgressResolvingAccount. + /// + /// In en, this message translates to: + /// **'Verifying account'** + String get recoverProgressResolvingAccount; + + /// No description provided for @recoverProgressRestoringIdentity. + /// + /// In en, this message translates to: + /// **'Restoring your identity'** + String get recoverProgressRestoringIdentity; + + /// No description provided for @recoverProgressDownloadingArchive. + /// + /// In en, this message translates to: + /// **'Downloading and decrypting messages and contacts'** + String get recoverProgressDownloadingArchive; + + /// No description provided for @recoverProgressExtractingData. + /// + /// In en, this message translates to: + /// **'Extracting data'** + String get recoverProgressExtractingData; + + /// No description provided for @recoverProgressFinishing. + /// + /// In en, this message translates to: + /// **'Finishing up'** + String get recoverProgressFinishing; + /// No description provided for @iosRecoveryWelcomeBack. /// /// In en, this message translates to: diff --git a/lib/src/localization/generated/app_localizations_de.dart b/lib/src/localization/generated/app_localizations_de.dart index a15aeadc..7a731f0b 100644 --- a/lib/src/localization/generated/app_localizations_de.dart +++ b/lib/src/localization/generated/app_localizations_de.dart @@ -62,6 +62,13 @@ class AppLocalizationsDe extends AppLocalizations { @override String get cameraPreviewSendTo => 'Senden an'; + @override + String get cameraPermissionsBody => + 'twonly benötigt Zugriff auf die Kamera und das Mikrofon.'; + + @override + String get cameraPermissionsRequestButton => 'Berechtigungen anfragen'; + @override String get shareImageTitle => 'Teilen mit'; @@ -1931,6 +1938,30 @@ class AppLocalizationsDe extends AppLocalizations { @override String get recoverSuccessBody => 'Klicke hier, um die App wieder zu öffnen'; + @override + String get recoverProgressTitle => 'Backup wird wiederhergestellt'; + + @override + String get recoverProgressSubtitle => + 'Dies kann einen Moment dauern, bitte lasse die App geöffnet.'; + + @override + String get recoverProgressResolvingAccount => 'Konto wird überprüft'; + + @override + String get recoverProgressRestoringIdentity => + 'Identität wird wiederhergestellt'; + + @override + String get recoverProgressDownloadingArchive => + 'Nachrichten und Kontakte werden heruntergeladen und entschlüsselt'; + + @override + String get recoverProgressExtractingData => 'Daten werden extrahiert'; + + @override + String get recoverProgressFinishing => 'Wird abgeschlossen'; + @override String get iosRecoveryWelcomeBack => 'Willkommen zurück'; diff --git a/lib/src/localization/generated/app_localizations_en.dart b/lib/src/localization/generated/app_localizations_en.dart index d2c93972..5b17fbd3 100644 --- a/lib/src/localization/generated/app_localizations_en.dart +++ b/lib/src/localization/generated/app_localizations_en.dart @@ -61,6 +61,13 @@ class AppLocalizationsEn extends AppLocalizations { @override String get cameraPreviewSendTo => 'Send to'; + @override + String get cameraPermissionsBody => + 'twonly needs access to the camera and microphone.'; + + @override + String get cameraPermissionsRequestButton => 'Request permissions'; + @override String get shareImageTitle => 'Share with'; @@ -1917,6 +1924,29 @@ class AppLocalizationsEn extends AppLocalizations { @override String get recoverSuccessBody => 'Click here to open the app again'; + @override + String get recoverProgressTitle => 'Restoring your backup'; + + @override + String get recoverProgressSubtitle => + 'This might take a moment, please keep the app open.'; + + @override + String get recoverProgressResolvingAccount => 'Verifying account'; + + @override + String get recoverProgressRestoringIdentity => 'Restoring your identity'; + + @override + String get recoverProgressDownloadingArchive => + 'Downloading and decrypting messages and contacts'; + + @override + String get recoverProgressExtractingData => 'Extracting data'; + + @override + String get recoverProgressFinishing => 'Finishing up'; + @override String get iosRecoveryWelcomeBack => 'Welcome Back'; diff --git a/lib/src/localization/translations/de.arb b/lib/src/localization/translations/de.arb index 097d5b61..36b78e22 100644 --- a/lib/src/localization/translations/de.arb +++ b/lib/src/localization/translations/de.arb @@ -16,6 +16,8 @@ "registerSubmitButton": "Jetzt registrieren!", "chatsTapToSend": "Klicke, um dein erstes Bild zu teilen.", "cameraPreviewSendTo": "Senden an", + "cameraPermissionsBody": "twonly benötigt Zugriff auf die Kamera und das Mikrofon.", + "cameraPermissionsRequestButton": "Berechtigungen anfragen", "shareImageTitle": "Teilen mit", "shareImageBestFriends": "Beste Freunde", "shareImagePinnedContacts": "Angeheftet", @@ -681,6 +683,13 @@ "recoverErrorUnknown": "Ein unbekannter Fehler ist aufgetreten. Bitte versuche es erneut.", "recoverSuccessTitle": "Backup erfolgreich wiederhergestellt.", "recoverSuccessBody": "Klicke hier, um die App wieder zu öffnen", + "recoverProgressTitle": "Backup wird wiederhergestellt", + "recoverProgressSubtitle": "Dies kann einen Moment dauern, bitte lasse die App geöffnet.", + "recoverProgressResolvingAccount": "Konto wird überprüft", + "recoverProgressRestoringIdentity": "Identität wird wiederhergestellt", + "recoverProgressDownloadingArchive": "Nachrichten und Kontakte werden heruntergeladen und entschlüsselt", + "recoverProgressExtractingData": "Daten werden extrahiert", + "recoverProgressFinishing": "Wird abgeschlossen", "iosRecoveryWelcomeBack": "Willkommen zurück", "iosRecoveryPrompt": "Wir haben eine zuvor gesicherte twonly-Identität auf diesem Gerät erkannt. Möchtest du deine Kontakte, Nachrichten und Einstellungen automatisch aus deinem Cloud-Archiv herunterladen und wiederherstellen?", "iosRecoveryNoBackupFound": "Für dieses Gerät konnte kein Backup-Archiv vom Server abgerufen werden.\n\nFehler: {error}\n\nBitte fahre mit der Registrierung eines neuen twonly-Kontos fort.", diff --git a/lib/src/localization/translations/en.arb b/lib/src/localization/translations/en.arb index afa668c4..1eb55c8e 100644 --- a/lib/src/localization/translations/en.arb +++ b/lib/src/localization/translations/en.arb @@ -16,6 +16,8 @@ "registerSubmitButton": "Register now!", "chatsTapToSend": "Click to send your first image", "cameraPreviewSendTo": "Send to", + "cameraPermissionsBody": "twonly needs access to the camera and microphone.", + "cameraPermissionsRequestButton": "Request permissions", "shareImageTitle": "Share with", "shareImageBestFriends": "Best friends", "shareImagePinnedContacts": "Pinnded", @@ -691,6 +693,13 @@ "recoverErrorUnknown": "An unknown error occurred. Please try again.", "recoverSuccessTitle": "Backup successfully recovered.", "recoverSuccessBody": "Click here to open the app again", + "recoverProgressTitle": "Restoring your backup", + "recoverProgressSubtitle": "This might take a moment, please keep the app open.", + "recoverProgressResolvingAccount": "Verifying account", + "recoverProgressRestoringIdentity": "Restoring your identity", + "recoverProgressDownloadingArchive": "Downloading and decrypting messages and contacts", + "recoverProgressExtractingData": "Extracting data", + "recoverProgressFinishing": "Finishing up", "iosRecoveryWelcomeBack": "Welcome Back", "iosRecoveryPrompt": "We detected a previously secured twonly identity on this device. Would you like to automatically download and restore your contacts, messages, and settings from your cloud archive?", "iosRecoveryNoBackupFound": "No backup archive could be retrieved from the server for this device.\n\nError: {error}\n\nPlease proceed to register a new twonly account.", diff --git a/lib/src/services/backup.service.dart b/lib/src/services/backup.service.dart index 1cc64ee2..08809efe 100644 --- a/lib/src/services/backup.service.dart +++ b/lib/src/services/backup.service.dart @@ -264,12 +264,15 @@ class BackupService { return BackupRecovery.fromJson(stateJson); } - static Future _nextBackupStage() async { + static Future _nextBackupStage({ + void Function(RecoveryProgress)? onProgress, + }) async { return _protected.protect(() async { final recoveryData = await getBackupRecoveryData(); if (recoveryData == null) return null; if (recoveryData.state == BackupRecoveryState.identityBackupStarted) { + onProgress?.call(RecoveryProgress.restoringIdentity); // First start to download the identity to restore the KeyManager final backupKeys = await RustBackupIdentity.getBackupPasswordKeys( userId: recoveryData.userId, @@ -304,6 +307,7 @@ class BackupService { if (recoveryData.state == BackupRecoveryState.archiveBackupStarted) { // The KeyManager was restored successfully, restore the archive now. + onProgress?.call(RecoveryProgress.downloadingArchive); try { final downloadToken = await RustBackupArchive.getBackupDownloadToken(); @@ -326,6 +330,8 @@ class BackupService { return backupArchive.$2; } + onProgress?.call(RecoveryProgress.extractingData); + final archiveFile = File('${AppEnvironment.cacheDir}/archive.bin') ..writeAsBytesSync(backupArchive.$1!); @@ -348,7 +354,9 @@ class BackupService { }); } - static Future tryToReinstallTheArchive() async { + static Future tryToReinstallTheArchive({ + void Function(RecoveryProgress)? onProgress, + }) async { final userId = await RustKeyManager.getUserId(); if (userId == null) return null; @@ -358,13 +366,15 @@ class BackupService { password: '', )..state = BackupRecoveryState.archiveBackupStarted; await KeyValueStore.put(KeyValueKeys.backupRecoveryState, state.toJson()); - return _nextBackupStage(); + return _nextBackupStage(onProgress: onProgress); } static Future startFullBackupRecovery( String username, - String password, - ) async { + String password, { + void Function(RecoveryProgress)? onProgress, + }) async { + onProgress?.call(RecoveryProgress.resolvingAccount); late final int userId; try { userId = await RustApi.getUserIdFromUsername(username: username); @@ -381,27 +391,29 @@ class BackupService { await deleteLocalUserData(removeCredentials: true); await KeyValueStore.put(KeyValueKeys.backupRecoveryState, state.toJson()); - return _nextBackupStage(); + return _nextBackupStage(onProgress: onProgress); } static Future startPasswordlessBackupRecovery( int userId, String username, - Uint8List keyManagerBytes, - ) async { + Uint8List keyManagerBytes, { + void Function(RecoveryProgress)? onProgress, + }) async { final state = BackupRecovery( username: username, password: '', userId: userId, )..state = BackupRecoveryState.archiveBackupStarted; + onProgress?.call(RecoveryProgress.restoringIdentity); await deleteLocalUserData(removeCredentials: true); // Import KeyManager keys into secure storage & in-memory key manager await RustKeyManager.importSerialized(serializedBytes: keyManagerBytes); await KeyValueStore.put(KeyValueKeys.backupRecoveryState, state.toJson()); - return _nextBackupStage(); + return _nextBackupStage(onProgress: onProgress); } static Future<(Uint8List?, RecoveryError?)> _downloadBackup( @@ -441,3 +453,12 @@ enum RecoveryError { noInternet, unkownError, } + +/// Granular steps of a backup recovery, reported via the `onProgress` +/// callback so the UI can show the user what is currently happening. +enum RecoveryProgress { + resolvingAccount, + restoringIdentity, + downloadingArchive, + extractingData, +} diff --git a/lib/src/visual/views/camera/camera_preview_components/permissions_view.dart b/lib/src/visual/views/camera/camera_preview_components/permissions_view.dart index 9fe87d5a..7adbe228 100644 --- a/lib/src/visual/views/camera/camera_preview_components/permissions_view.dart +++ b/lib/src/visual/views/camera/camera_preview_components/permissions_view.dart @@ -4,6 +4,8 @@ import 'package:flutter/material.dart'; import 'package:permission_handler/permission_handler.dart'; import 'package:twonly/src/services/user.service.dart'; import 'package:twonly/src/utils/log.dart'; +import 'package:twonly/src/utils/misc.dart'; +import 'package:twonly/src/visual/elements/my_button.element.dart'; class PermissionHandlerView extends StatefulWidget { const PermissionHandlerView({ @@ -124,32 +126,44 @@ class PermissionHandlerViewState extends State Widget build(BuildContext context) { return Scaffold( body: Center( - child: Container( - padding: const EdgeInsets.all(100), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Text( - 'twonly needs access to the camera and microphone.', + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Padding( + padding: const EdgeInsets.only( + left: 100, + right: 100, + top: 100, + bottom: 50, + ), + child: Text( + context.lang.cameraPermissionsBody, textAlign: TextAlign.center, ), - const SizedBox(height: 50), - FilledButton.icon( - label: const Text('Request permissions'), - icon: const Icon(Icons.perm_camera_mic), - onPressed: () async { - try { - await permissionServices(); - if (await checkPermissions()) { - _handleSuccess(); - } - } catch (e) { - Log.error(e); + ), + MyButton( + variant: MyButtonVariant.primaryMiddle, + onPressed: () async { + try { + await permissionServices(); + if (await checkPermissions()) { + _handleSuccess(); } - }, + } catch (e) { + Log.error(e); + } + }, + child: Row( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.perm_camera_mic, size: 18), + const SizedBox(width: 8), + Text(context.lang.cameraPermissionsRequestButton), + ], ), - ], - ), + ), + ], ), ), ); diff --git a/lib/src/visual/views/onboarding/recover_password.view.dart b/lib/src/visual/views/onboarding/recover_password.view.dart index b0304ef8..0b1b0f99 100644 --- a/lib/src/visual/views/onboarding/recover_password.view.dart +++ b/lib/src/visual/views/onboarding/recover_password.view.dart @@ -1,17 +1,16 @@ import 'package:flutter/material.dart'; import 'package:font_awesome_flutter/font_awesome_flutter.dart'; import 'package:go_router/go_router.dart'; -import 'package:restart_app/restart_app.dart'; import 'package:twonly/src/constants/keyvalue.keys.dart'; import 'package:twonly/src/constants/routes.keys.dart'; import 'package:twonly/src/model/json/onboarding_state.model.dart'; import 'package:twonly/src/services/backup.service.dart'; import 'package:twonly/src/utils/keyvalue.dart'; import 'package:twonly/src/utils/misc.dart'; -import 'package:twonly/src/visual/components/snackbar.dart'; import 'package:twonly/src/visual/elements/my_button.element.dart'; import 'package:twonly/src/visual/elements/my_input.element.dart'; import 'package:twonly/src/visual/views/onboarding/components/link_logo_animation.dart'; +import 'package:twonly/src/visual/views/onboarding/recovery_progress.view.dart'; import 'package:twonly/src/visual/views/settings/backup/components/backup_setup.comp.dart'; class BackupRecoveryView extends StatefulWidget { @@ -23,7 +22,6 @@ class BackupRecoveryView extends StatefulWidget { class _BackupRecoveryViewState extends State { bool obscureText = true; - bool isLoading = false; final TextEditingController usernameCtrl = TextEditingController(); final TextEditingController passwordCtrl = TextEditingController(); @@ -35,32 +33,27 @@ class _BackupRecoveryViewState extends State { } Future _recoverTwonlySafe() async { - setState(() { - isLoading = true; - }); + final username = usernameCtrl.text; + final password = passwordCtrl.text; - final error = await BackupService.startFullBackupRecovery( - usernameCtrl.text, - passwordCtrl.text, + await Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => RecoveryProgressView( + username: username, + steps: const [ + RecoveryProgress.resolvingAccount, + RecoveryProgress.restoringIdentity, + RecoveryProgress.downloadingArchive, + RecoveryProgress.extractingData, + ], + runRecovery: (onProgress) => BackupService.startFullBackupRecovery( + username, + password, + onProgress: onProgress, + ), + ), + ), ); - if (!mounted) return; - - if (error != null) { - setState(() { - isLoading = false; - }); - return showSnackbar(context, error.toLocalizedString(context)); - } - - await Restart.restartApp( - notificationTitle: context.lang.recoverSuccessTitle, - notificationBody: context.lang.recoverSuccessBody, - forceKill: true, - ); - - setState(() { - isLoading = false; - }); } @override @@ -159,19 +152,8 @@ class _BackupRecoveryViewState extends State { ), const SizedBox(height: 32), MyButton( - onPressed: (!isLoading) ? _recoverTwonlySafe : null, - child: isLoading - ? const SizedBox( - height: 24, - width: 24, - child: CircularProgressIndicator.adaptive( - valueColor: AlwaysStoppedAnimation( - Colors.white, - ), - strokeWidth: 3, - ), - ) - : Text(context.lang.twonlySafeRecoverBtn), + onPressed: _recoverTwonlySafe, + child: Text(context.lang.twonlySafeRecoverBtn), ), const SizedBox(height: 16), MyButton( diff --git a/lib/src/visual/views/onboarding/recover_passwordless.view.dart b/lib/src/visual/views/onboarding/recover_passwordless.view.dart index 884fef4b..3f5b4337 100644 --- a/lib/src/visual/views/onboarding/recover_passwordless.view.dart +++ b/lib/src/visual/views/onboarding/recover_passwordless.view.dart @@ -9,7 +9,6 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:hashlib/random.dart'; import 'package:qr_flutter/qr_flutter.dart'; -import 'package:restart_app/restart_app.dart'; import 'package:share_plus/share_plus.dart'; import 'package:twonly/core/bridge/wrapper.dart' show RustUtils; import 'package:twonly/locator.dart'; @@ -27,6 +26,7 @@ import 'package:twonly/src/visual/elements/contact_chip.element.dart'; import 'package:twonly/src/visual/elements/my_button.element.dart'; import 'package:twonly/src/visual/elements/my_input.element.dart'; import 'package:twonly/src/visual/views/onboarding/components/animated_bell_icon.comp.dart'; +import 'package:twonly/src/visual/views/onboarding/recovery_progress.view.dart'; class RecoverPasswordless extends StatefulWidget { const RecoverPasswordless({this.initialEmailToken, super.key}); @@ -398,31 +398,28 @@ class _RecoverPasswordlessState extends State { final recoveryData = RecoveryData.fromBuffer(recoveryDataBytes); - // Start full passwordless recovery - final error = await BackupService.startPasswordlessBackupRecovery( - recoveryData.userId.toInt(), - shares.first.myDisplayName, - Uint8List.fromList(recoveryData.keyManager), - ); - if (!mounted) return; - if (error != null) { - showSnackbar( - context, - error.toLocalizedString(context), - ); - setState(() { - _isRecovering = false; - }); - return; - } - - // Successful! Restart the app to apply restored keymanager/archive database - await Restart.restartApp( - notificationTitle: context.lang.recoverSuccessTitle, - notificationBody: context.lang.recoverSuccessBody, - forceKill: true, + // Start full passwordless recovery. The progress view reports every + // step and restarts the app once the recovery succeeded. + await Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => RecoveryProgressView( + username: shares.first.myDisplayName, + steps: const [ + RecoveryProgress.restoringIdentity, + RecoveryProgress.downloadingArchive, + RecoveryProgress.extractingData, + ], + runRecovery: (onProgress) => + BackupService.startPasswordlessBackupRecovery( + recoveryData.userId.toInt(), + shares.first.myDisplayName, + Uint8List.fromList(recoveryData.keyManager), + onProgress: onProgress, + ), + ), + ), ); } catch (e) { Log.error('Failed to recover passwordless: $e'); diff --git a/lib/src/visual/views/onboarding/recovery_progress.view.dart b/lib/src/visual/views/onboarding/recovery_progress.view.dart new file mode 100644 index 00000000..66435415 --- /dev/null +++ b/lib/src/visual/views/onboarding/recovery_progress.view.dart @@ -0,0 +1,343 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:restart_app/restart_app.dart'; +import 'package:twonly/src/services/backup.service.dart'; +import 'package:twonly/src/utils/misc.dart'; +import 'package:twonly/src/visual/elements/my_button.element.dart'; +import 'package:twonly/src/visual/views/onboarding/components/link_logo_animation.dart'; + +/// Runs the actual recovery and reports every step it enters. +typedef RecoveryRunner = + Future Function(void Function(RecoveryProgress) onProgress); + +enum _StepStatus { pending, active, done, error } + +/// Shows the user what the recovery is currently doing. Used by both the +/// password based and the passwordless recovery. On success the app is +/// restarted so the restored key manager and archive database are loaded. +class RecoveryProgressView extends StatefulWidget { + const RecoveryProgressView({ + required this.steps, + required this.runRecovery, + this.username, + super.key, + }); + + /// The steps the recovery will report, in the order they will happen. + final List steps; + final RecoveryRunner runRecovery; + + /// Username or display name of the account being restored. + final String? username; + + @override + State createState() => _RecoveryProgressViewState(); +} + +class _RecoveryProgressViewState extends State { + /// Index into [RecoveryProgressView.steps], -1 until the first step starts. + int _currentIndex = -1; + bool _isFinishing = false; + RecoveryError? _error; + + /// The last step is a UI only step shown while the app restarts. + int get _stepCount => widget.steps.length + 1; + + bool get _hasUsername => + widget.username != null && widget.username!.isNotEmpty; + + /// The username belongs under the account check, or under the first step + /// when the recovery does not look up the account at all. + int get _usernameStepIndex { + final index = widget.steps.indexOf(RecoveryProgress.resolvingAccount); + return index >= 0 ? index : 0; + } + + @override + void initState() { + super.initState(); + unawaited(_startRecovery()); + } + + Future _startRecovery() async { + setState(() { + _currentIndex = -1; + _isFinishing = false; + _error = null; + }); + + final error = await widget.runRecovery((progress) { + final index = widget.steps.indexOf(progress); + if (mounted && index >= 0) { + setState(() => _currentIndex = index); + } + }); + if (!mounted) return; + + if (error != null) { + setState(() => _error = error); + return; + } + + setState(() => _isFinishing = true); + + await Restart.restartApp( + notificationTitle: context.lang.recoverSuccessTitle, + notificationBody: context.lang.recoverSuccessBody, + forceKill: true, + ); + } + + _StepStatus _statusFor(int index) { + if (_error != null) { + if (index < _currentIndex) return _StepStatus.done; + if (index == _currentIndex) return _StepStatus.error; + return _StepStatus.pending; + } + + if (_isFinishing) { + return index < widget.steps.length + ? _StepStatus.done + : _StepStatus.active; + } + + if (index < _currentIndex) return _StepStatus.done; + if (index == _currentIndex) return _StepStatus.active; + return _StepStatus.pending; + } + + String _labelFor(BuildContext context, int index) { + if (index >= widget.steps.length) { + return context.lang.recoverProgressFinishing; + } + switch (widget.steps[index]) { + case RecoveryProgress.resolvingAccount: + return context.lang.recoverProgressResolvingAccount; + case RecoveryProgress.restoringIdentity: + return context.lang.recoverProgressRestoringIdentity; + case RecoveryProgress.downloadingArchive: + return context.lang.recoverProgressDownloadingArchive; + case RecoveryProgress.extractingData: + return context.lang.recoverProgressExtractingData; + } + } + + Widget _buildStatusIcon(BuildContext context, _StepStatus status) { + final isDark = isDarkMode(context); + switch (status) { + case _StepStatus.done: + return const Icon( + Icons.check_circle_rounded, + color: Colors.green, + size: 24, + ); + case _StepStatus.active: + return SizedBox( + width: 24, + height: 24, + child: Padding( + padding: const EdgeInsets.all(3), + child: CircularProgressIndicator( + strokeWidth: 2.5, + color: context.color.primary, + ), + ), + ); + case _StepStatus.error: + return const Icon( + Icons.error_rounded, + color: Colors.redAccent, + size: 24, + ); + case _StepStatus.pending: + return Icon( + Icons.circle_outlined, + size: 24, + color: isDark ? Colors.white24 : Colors.black26, + ); + } + } + + TextStyle _labelStyle(BuildContext context, _StepStatus status) { + final isDark = isDarkMode(context); + const base = TextStyle(fontSize: 15, height: 1.3); + switch (status) { + case _StepStatus.active: + return base.copyWith( + fontWeight: FontWeight.w600, + color: isDark ? Colors.white : Colors.black87, + ); + case _StepStatus.error: + return base.copyWith( + fontWeight: FontWeight.w600, + color: Colors.redAccent, + ); + case _StepStatus.done: + return base.copyWith(color: isDark ? Colors.white60 : Colors.black54); + case _StepStatus.pending: + return base.copyWith(color: isDark ? Colors.white30 : Colors.black38); + } + } + + Widget _buildStepRow(BuildContext context, int index) { + final isDark = isDarkMode(context); + final isLast = index == _stepCount - 1; + final status = _statusFor(index); + + return IntrinsicHeight( + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Column( + children: [ + _buildStatusIcon(context, status), + if (!isLast) + Expanded( + child: Container( + width: 2, + margin: const EdgeInsets.symmetric(vertical: 4), + color: status == _StepStatus.done + ? Colors.green.withValues(alpha: 0.4) + : (isDark ? Colors.white12 : Colors.black12), + ), + ), + ], + ), + const SizedBox(width: 14), + Expanded( + child: Padding( + padding: EdgeInsets.only(bottom: isLast ? 0 : 24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + _labelFor(context, index), + style: _labelStyle(context, status), + ), + if (index == _usernameStepIndex && _hasUsername) ...[ + const SizedBox(height: 2), + Text( + widget.username!, + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + color: context.color.primary, + ), + ), + ], + ], + ), + ), + ), + ], + ), + ); + } + + @override + Widget build(BuildContext context) { + final isDark = isDarkMode(context); + final titleColor = isDark ? Colors.white : Colors.black87; + final hasFailed = _error != null; + + return PopScope( + // While the recovery is running the local data is already replaced, + // so the user should not be able to leave in between. + canPop: hasFailed, + child: Scaffold( + backgroundColor: Theme.of(context).scaffoldBackgroundColor, + body: SafeArea( + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + IconButton( + onPressed: hasFailed + ? () => Navigator.of(context).pop() + : null, + icon: const Icon(Icons.arrow_back_ios_new_rounded), + color: isDark ? Colors.white70 : Colors.black54, + iconSize: 20, + ), + ], + ), + const SizedBox(height: 12), + Center( + child: Padding( + padding: const EdgeInsets.all(12), + child: LinkLogoAnimation( + size: 90, + color: isDark ? Colors.white : Colors.black, + ), + ), + ), + const SizedBox(height: 12), + Text( + context.lang.recoverProgressTitle, + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 22, + fontWeight: FontWeight.w800, + color: titleColor, + letterSpacing: -0.5, + ), + ), + const SizedBox(height: 8), + Text( + context.lang.recoverProgressSubtitle, + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 13, + color: isDark ? Colors.white54 : Colors.black45, + ), + ), + const SizedBox(height: 32), + Container( + decoration: BoxDecoration( + color: isDark + ? Colors.white.withValues(alpha: 0.05) + : Colors.black.withValues(alpha: 0.04), + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: isDark + ? Colors.white.withValues(alpha: 0.08) + : Colors.black.withValues(alpha: 0.06), + ), + ), + padding: const EdgeInsets.fromLTRB(16, 20, 16, 20), + child: Column( + children: [ + for (var i = 0; i < _stepCount; i++) + _buildStepRow(context, i), + ], + ), + ), + if (hasFailed) ...[ + const SizedBox(height: 24), + Text( + _error!.toLocalizedString(context), + textAlign: TextAlign.center, + style: const TextStyle( + color: Colors.redAccent, + fontSize: 14, + ), + ), + const SizedBox(height: 20), + MyButton( + onPressed: _startRecovery, + child: Text(context.lang.tryRestoreAgain), + ), + ], + const SizedBox(height: 40), + ], + ), + ), + ), + ), + ); + } +}