mirror of
https://github.com/twonlyapp/twonly-app.git
synced 2026-09-23 14:54:07 +00:00
add recovery view
This commit is contained in:
parent
9207a34994
commit
6da70abc0f
10 changed files with 585 additions and 95 deletions
|
|
@ -194,6 +194,18 @@ abstract class AppLocalizations {
|
||||||
/// **'Send to'**
|
/// **'Send to'**
|
||||||
String get cameraPreviewSendTo;
|
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.
|
/// No description provided for @shareImageTitle.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
|
|
@ -3356,6 +3368,48 @@ abstract class AppLocalizations {
|
||||||
/// **'Click here to open the app again'**
|
/// **'Click here to open the app again'**
|
||||||
String get recoverSuccessBody;
|
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.
|
/// No description provided for @iosRecoveryWelcomeBack.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
|
|
|
||||||
|
|
@ -62,6 +62,13 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||||
@override
|
@override
|
||||||
String get cameraPreviewSendTo => 'Senden an';
|
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
|
@override
|
||||||
String get shareImageTitle => 'Teilen mit';
|
String get shareImageTitle => 'Teilen mit';
|
||||||
|
|
||||||
|
|
@ -1931,6 +1938,30 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||||
@override
|
@override
|
||||||
String get recoverSuccessBody => 'Klicke hier, um die App wieder zu öffnen';
|
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
|
@override
|
||||||
String get iosRecoveryWelcomeBack => 'Willkommen zurück';
|
String get iosRecoveryWelcomeBack => 'Willkommen zurück';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -61,6 +61,13 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||||
@override
|
@override
|
||||||
String get cameraPreviewSendTo => 'Send to';
|
String get cameraPreviewSendTo => 'Send to';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cameraPermissionsBody =>
|
||||||
|
'twonly needs access to the camera and microphone.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cameraPermissionsRequestButton => 'Request permissions';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get shareImageTitle => 'Share with';
|
String get shareImageTitle => 'Share with';
|
||||||
|
|
||||||
|
|
@ -1917,6 +1924,29 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||||
@override
|
@override
|
||||||
String get recoverSuccessBody => 'Click here to open the app again';
|
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
|
@override
|
||||||
String get iosRecoveryWelcomeBack => 'Welcome Back';
|
String get iosRecoveryWelcomeBack => 'Welcome Back';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,8 @@
|
||||||
"registerSubmitButton": "Jetzt registrieren!",
|
"registerSubmitButton": "Jetzt registrieren!",
|
||||||
"chatsTapToSend": "Klicke, um dein erstes Bild zu teilen.",
|
"chatsTapToSend": "Klicke, um dein erstes Bild zu teilen.",
|
||||||
"cameraPreviewSendTo": "Senden an",
|
"cameraPreviewSendTo": "Senden an",
|
||||||
|
"cameraPermissionsBody": "twonly benötigt Zugriff auf die Kamera und das Mikrofon.",
|
||||||
|
"cameraPermissionsRequestButton": "Berechtigungen anfragen",
|
||||||
"shareImageTitle": "Teilen mit",
|
"shareImageTitle": "Teilen mit",
|
||||||
"shareImageBestFriends": "Beste Freunde",
|
"shareImageBestFriends": "Beste Freunde",
|
||||||
"shareImagePinnedContacts": "Angeheftet",
|
"shareImagePinnedContacts": "Angeheftet",
|
||||||
|
|
@ -681,6 +683,13 @@
|
||||||
"recoverErrorUnknown": "Ein unbekannter Fehler ist aufgetreten. Bitte versuche es erneut.",
|
"recoverErrorUnknown": "Ein unbekannter Fehler ist aufgetreten. Bitte versuche es erneut.",
|
||||||
"recoverSuccessTitle": "Backup erfolgreich wiederhergestellt.",
|
"recoverSuccessTitle": "Backup erfolgreich wiederhergestellt.",
|
||||||
"recoverSuccessBody": "Klicke hier, um die App wieder zu öffnen",
|
"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",
|
"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?",
|
"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.",
|
"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.",
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,8 @@
|
||||||
"registerSubmitButton": "Register now!",
|
"registerSubmitButton": "Register now!",
|
||||||
"chatsTapToSend": "Click to send your first image",
|
"chatsTapToSend": "Click to send your first image",
|
||||||
"cameraPreviewSendTo": "Send to",
|
"cameraPreviewSendTo": "Send to",
|
||||||
|
"cameraPermissionsBody": "twonly needs access to the camera and microphone.",
|
||||||
|
"cameraPermissionsRequestButton": "Request permissions",
|
||||||
"shareImageTitle": "Share with",
|
"shareImageTitle": "Share with",
|
||||||
"shareImageBestFriends": "Best friends",
|
"shareImageBestFriends": "Best friends",
|
||||||
"shareImagePinnedContacts": "Pinnded",
|
"shareImagePinnedContacts": "Pinnded",
|
||||||
|
|
@ -691,6 +693,13 @@
|
||||||
"recoverErrorUnknown": "An unknown error occurred. Please try again.",
|
"recoverErrorUnknown": "An unknown error occurred. Please try again.",
|
||||||
"recoverSuccessTitle": "Backup successfully recovered.",
|
"recoverSuccessTitle": "Backup successfully recovered.",
|
||||||
"recoverSuccessBody": "Click here to open the app again",
|
"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",
|
"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?",
|
"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.",
|
"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.",
|
||||||
|
|
|
||||||
|
|
@ -264,12 +264,15 @@ class BackupService {
|
||||||
return BackupRecovery.fromJson(stateJson);
|
return BackupRecovery.fromJson(stateJson);
|
||||||
}
|
}
|
||||||
|
|
||||||
static Future<RecoveryError?> _nextBackupStage() async {
|
static Future<RecoveryError?> _nextBackupStage({
|
||||||
|
void Function(RecoveryProgress)? onProgress,
|
||||||
|
}) async {
|
||||||
return _protected.protect(() async {
|
return _protected.protect(() async {
|
||||||
final recoveryData = await getBackupRecoveryData();
|
final recoveryData = await getBackupRecoveryData();
|
||||||
if (recoveryData == null) return null;
|
if (recoveryData == null) return null;
|
||||||
|
|
||||||
if (recoveryData.state == BackupRecoveryState.identityBackupStarted) {
|
if (recoveryData.state == BackupRecoveryState.identityBackupStarted) {
|
||||||
|
onProgress?.call(RecoveryProgress.restoringIdentity);
|
||||||
// First start to download the identity to restore the KeyManager
|
// First start to download the identity to restore the KeyManager
|
||||||
final backupKeys = await RustBackupIdentity.getBackupPasswordKeys(
|
final backupKeys = await RustBackupIdentity.getBackupPasswordKeys(
|
||||||
userId: recoveryData.userId,
|
userId: recoveryData.userId,
|
||||||
|
|
@ -304,6 +307,7 @@ class BackupService {
|
||||||
|
|
||||||
if (recoveryData.state == BackupRecoveryState.archiveBackupStarted) {
|
if (recoveryData.state == BackupRecoveryState.archiveBackupStarted) {
|
||||||
// The KeyManager was restored successfully, restore the archive now.
|
// The KeyManager was restored successfully, restore the archive now.
|
||||||
|
onProgress?.call(RecoveryProgress.downloadingArchive);
|
||||||
try {
|
try {
|
||||||
final downloadToken =
|
final downloadToken =
|
||||||
await RustBackupArchive.getBackupDownloadToken();
|
await RustBackupArchive.getBackupDownloadToken();
|
||||||
|
|
@ -326,6 +330,8 @@ class BackupService {
|
||||||
return backupArchive.$2;
|
return backupArchive.$2;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onProgress?.call(RecoveryProgress.extractingData);
|
||||||
|
|
||||||
final archiveFile = File('${AppEnvironment.cacheDir}/archive.bin')
|
final archiveFile = File('${AppEnvironment.cacheDir}/archive.bin')
|
||||||
..writeAsBytesSync(backupArchive.$1!);
|
..writeAsBytesSync(backupArchive.$1!);
|
||||||
|
|
||||||
|
|
@ -348,7 +354,9 @@ class BackupService {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
static Future<RecoveryError?> tryToReinstallTheArchive() async {
|
static Future<RecoveryError?> tryToReinstallTheArchive({
|
||||||
|
void Function(RecoveryProgress)? onProgress,
|
||||||
|
}) async {
|
||||||
final userId = await RustKeyManager.getUserId();
|
final userId = await RustKeyManager.getUserId();
|
||||||
if (userId == null) return null;
|
if (userId == null) return null;
|
||||||
|
|
||||||
|
|
@ -358,13 +366,15 @@ class BackupService {
|
||||||
password: '',
|
password: '',
|
||||||
)..state = BackupRecoveryState.archiveBackupStarted;
|
)..state = BackupRecoveryState.archiveBackupStarted;
|
||||||
await KeyValueStore.put(KeyValueKeys.backupRecoveryState, state.toJson());
|
await KeyValueStore.put(KeyValueKeys.backupRecoveryState, state.toJson());
|
||||||
return _nextBackupStage();
|
return _nextBackupStage(onProgress: onProgress);
|
||||||
}
|
}
|
||||||
|
|
||||||
static Future<RecoveryError?> startFullBackupRecovery(
|
static Future<RecoveryError?> startFullBackupRecovery(
|
||||||
String username,
|
String username,
|
||||||
String password,
|
String password, {
|
||||||
) async {
|
void Function(RecoveryProgress)? onProgress,
|
||||||
|
}) async {
|
||||||
|
onProgress?.call(RecoveryProgress.resolvingAccount);
|
||||||
late final int userId;
|
late final int userId;
|
||||||
try {
|
try {
|
||||||
userId = await RustApi.getUserIdFromUsername(username: username);
|
userId = await RustApi.getUserIdFromUsername(username: username);
|
||||||
|
|
@ -381,27 +391,29 @@ class BackupService {
|
||||||
|
|
||||||
await deleteLocalUserData(removeCredentials: true);
|
await deleteLocalUserData(removeCredentials: true);
|
||||||
await KeyValueStore.put(KeyValueKeys.backupRecoveryState, state.toJson());
|
await KeyValueStore.put(KeyValueKeys.backupRecoveryState, state.toJson());
|
||||||
return _nextBackupStage();
|
return _nextBackupStage(onProgress: onProgress);
|
||||||
}
|
}
|
||||||
|
|
||||||
static Future<RecoveryError?> startPasswordlessBackupRecovery(
|
static Future<RecoveryError?> startPasswordlessBackupRecovery(
|
||||||
int userId,
|
int userId,
|
||||||
String username,
|
String username,
|
||||||
Uint8List keyManagerBytes,
|
Uint8List keyManagerBytes, {
|
||||||
) async {
|
void Function(RecoveryProgress)? onProgress,
|
||||||
|
}) async {
|
||||||
final state = BackupRecovery(
|
final state = BackupRecovery(
|
||||||
username: username,
|
username: username,
|
||||||
password: '',
|
password: '',
|
||||||
userId: userId,
|
userId: userId,
|
||||||
)..state = BackupRecoveryState.archiveBackupStarted;
|
)..state = BackupRecoveryState.archiveBackupStarted;
|
||||||
|
|
||||||
|
onProgress?.call(RecoveryProgress.restoringIdentity);
|
||||||
await deleteLocalUserData(removeCredentials: true);
|
await deleteLocalUserData(removeCredentials: true);
|
||||||
|
|
||||||
// Import KeyManager keys into secure storage & in-memory key manager
|
// Import KeyManager keys into secure storage & in-memory key manager
|
||||||
await RustKeyManager.importSerialized(serializedBytes: keyManagerBytes);
|
await RustKeyManager.importSerialized(serializedBytes: keyManagerBytes);
|
||||||
|
|
||||||
await KeyValueStore.put(KeyValueKeys.backupRecoveryState, state.toJson());
|
await KeyValueStore.put(KeyValueKeys.backupRecoveryState, state.toJson());
|
||||||
return _nextBackupStage();
|
return _nextBackupStage(onProgress: onProgress);
|
||||||
}
|
}
|
||||||
|
|
||||||
static Future<(Uint8List?, RecoveryError?)> _downloadBackup(
|
static Future<(Uint8List?, RecoveryError?)> _downloadBackup(
|
||||||
|
|
@ -441,3 +453,12 @@ enum RecoveryError {
|
||||||
noInternet,
|
noInternet,
|
||||||
unkownError,
|
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,
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,8 @@ import 'package:flutter/material.dart';
|
||||||
import 'package:permission_handler/permission_handler.dart';
|
import 'package:permission_handler/permission_handler.dart';
|
||||||
import 'package:twonly/src/services/user.service.dart';
|
import 'package:twonly/src/services/user.service.dart';
|
||||||
import 'package:twonly/src/utils/log.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 {
|
class PermissionHandlerView extends StatefulWidget {
|
||||||
const PermissionHandlerView({
|
const PermissionHandlerView({
|
||||||
|
|
@ -124,19 +126,23 @@ class PermissionHandlerViewState extends State<PermissionHandlerView>
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
body: Center(
|
body: Center(
|
||||||
child: Container(
|
|
||||||
padding: const EdgeInsets.all(100),
|
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
const Text(
|
Padding(
|
||||||
'twonly needs access to the camera and microphone.',
|
padding: const EdgeInsets.only(
|
||||||
|
left: 100,
|
||||||
|
right: 100,
|
||||||
|
top: 100,
|
||||||
|
bottom: 50,
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
context.lang.cameraPermissionsBody,
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 50),
|
),
|
||||||
FilledButton.icon(
|
MyButton(
|
||||||
label: const Text('Request permissions'),
|
variant: MyButtonVariant.primaryMiddle,
|
||||||
icon: const Icon(Icons.perm_camera_mic),
|
|
||||||
onPressed: () async {
|
onPressed: () async {
|
||||||
try {
|
try {
|
||||||
await permissionServices();
|
await permissionServices();
|
||||||
|
|
@ -147,10 +153,18 @@ class PermissionHandlerViewState extends State<PermissionHandlerView>
|
||||||
Log.error(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),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,16 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
|
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
|
||||||
import 'package:go_router/go_router.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/keyvalue.keys.dart';
|
||||||
import 'package:twonly/src/constants/routes.keys.dart';
|
import 'package:twonly/src/constants/routes.keys.dart';
|
||||||
import 'package:twonly/src/model/json/onboarding_state.model.dart';
|
import 'package:twonly/src/model/json/onboarding_state.model.dart';
|
||||||
import 'package:twonly/src/services/backup.service.dart';
|
import 'package:twonly/src/services/backup.service.dart';
|
||||||
import 'package:twonly/src/utils/keyvalue.dart';
|
import 'package:twonly/src/utils/keyvalue.dart';
|
||||||
import 'package:twonly/src/utils/misc.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_button.element.dart';
|
||||||
import 'package:twonly/src/visual/elements/my_input.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/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';
|
import 'package:twonly/src/visual/views/settings/backup/components/backup_setup.comp.dart';
|
||||||
|
|
||||||
class BackupRecoveryView extends StatefulWidget {
|
class BackupRecoveryView extends StatefulWidget {
|
||||||
|
|
@ -23,7 +22,6 @@ class BackupRecoveryView extends StatefulWidget {
|
||||||
|
|
||||||
class _BackupRecoveryViewState extends State<BackupRecoveryView> {
|
class _BackupRecoveryViewState extends State<BackupRecoveryView> {
|
||||||
bool obscureText = true;
|
bool obscureText = true;
|
||||||
bool isLoading = false;
|
|
||||||
final TextEditingController usernameCtrl = TextEditingController();
|
final TextEditingController usernameCtrl = TextEditingController();
|
||||||
final TextEditingController passwordCtrl = TextEditingController();
|
final TextEditingController passwordCtrl = TextEditingController();
|
||||||
|
|
||||||
|
|
@ -35,32 +33,27 @@ class _BackupRecoveryViewState extends State<BackupRecoveryView> {
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _recoverTwonlySafe() async {
|
Future<void> _recoverTwonlySafe() async {
|
||||||
setState(() {
|
final username = usernameCtrl.text;
|
||||||
isLoading = true;
|
final password = passwordCtrl.text;
|
||||||
});
|
|
||||||
|
|
||||||
final error = await BackupService.startFullBackupRecovery(
|
await Navigator.of(context).push(
|
||||||
usernameCtrl.text,
|
MaterialPageRoute<void>(
|
||||||
passwordCtrl.text,
|
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
|
@override
|
||||||
|
|
@ -159,19 +152,8 @@ class _BackupRecoveryViewState extends State<BackupRecoveryView> {
|
||||||
),
|
),
|
||||||
const SizedBox(height: 32),
|
const SizedBox(height: 32),
|
||||||
MyButton(
|
MyButton(
|
||||||
onPressed: (!isLoading) ? _recoverTwonlySafe : null,
|
onPressed: _recoverTwonlySafe,
|
||||||
child: isLoading
|
child: Text(context.lang.twonlySafeRecoverBtn),
|
||||||
? const SizedBox(
|
|
||||||
height: 24,
|
|
||||||
width: 24,
|
|
||||||
child: CircularProgressIndicator.adaptive(
|
|
||||||
valueColor: AlwaysStoppedAnimation(
|
|
||||||
Colors.white,
|
|
||||||
),
|
|
||||||
strokeWidth: 3,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
: Text(context.lang.twonlySafeRecoverBtn),
|
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
MyButton(
|
MyButton(
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,6 @@ import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:hashlib/random.dart';
|
import 'package:hashlib/random.dart';
|
||||||
import 'package:qr_flutter/qr_flutter.dart';
|
import 'package:qr_flutter/qr_flutter.dart';
|
||||||
import 'package:restart_app/restart_app.dart';
|
|
||||||
import 'package:share_plus/share_plus.dart';
|
import 'package:share_plus/share_plus.dart';
|
||||||
import 'package:twonly/core/bridge/wrapper.dart' show RustUtils;
|
import 'package:twonly/core/bridge/wrapper.dart' show RustUtils;
|
||||||
import 'package:twonly/locator.dart';
|
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_button.element.dart';
|
||||||
import 'package:twonly/src/visual/elements/my_input.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/components/animated_bell_icon.comp.dart';
|
||||||
|
import 'package:twonly/src/visual/views/onboarding/recovery_progress.view.dart';
|
||||||
|
|
||||||
class RecoverPasswordless extends StatefulWidget {
|
class RecoverPasswordless extends StatefulWidget {
|
||||||
const RecoverPasswordless({this.initialEmailToken, super.key});
|
const RecoverPasswordless({this.initialEmailToken, super.key});
|
||||||
|
|
@ -398,31 +398,28 @@ class _RecoverPasswordlessState extends State<RecoverPasswordless> {
|
||||||
|
|
||||||
final recoveryData = RecoveryData.fromBuffer(recoveryDataBytes);
|
final recoveryData = RecoveryData.fromBuffer(recoveryDataBytes);
|
||||||
|
|
||||||
// Start full passwordless recovery
|
if (!mounted) return;
|
||||||
final error = await BackupService.startPasswordlessBackupRecovery(
|
|
||||||
|
// Start full passwordless recovery. The progress view reports every
|
||||||
|
// step and restarts the app once the recovery succeeded.
|
||||||
|
await Navigator.of(context).push(
|
||||||
|
MaterialPageRoute<void>(
|
||||||
|
builder: (_) => RecoveryProgressView(
|
||||||
|
username: shares.first.myDisplayName,
|
||||||
|
steps: const [
|
||||||
|
RecoveryProgress.restoringIdentity,
|
||||||
|
RecoveryProgress.downloadingArchive,
|
||||||
|
RecoveryProgress.extractingData,
|
||||||
|
],
|
||||||
|
runRecovery: (onProgress) =>
|
||||||
|
BackupService.startPasswordlessBackupRecovery(
|
||||||
recoveryData.userId.toInt(),
|
recoveryData.userId.toInt(),
|
||||||
shares.first.myDisplayName,
|
shares.first.myDisplayName,
|
||||||
Uint8List.fromList(recoveryData.keyManager),
|
Uint8List.fromList(recoveryData.keyManager),
|
||||||
);
|
onProgress: onProgress,
|
||||||
|
),
|
||||||
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,
|
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
Log.error('Failed to recover passwordless: $e');
|
Log.error('Failed to recover passwordless: $e');
|
||||||
|
|
|
||||||
343
lib/src/visual/views/onboarding/recovery_progress.view.dart
Normal file
343
lib/src/visual/views/onboarding/recovery_progress.view.dart
Normal file
|
|
@ -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<RecoveryError?> 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<RecoveryProgress> steps;
|
||||||
|
final RecoveryRunner runRecovery;
|
||||||
|
|
||||||
|
/// Username or display name of the account being restored.
|
||||||
|
final String? username;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<RecoveryProgressView> createState() => _RecoveryProgressViewState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _RecoveryProgressViewState extends State<RecoveryProgressView> {
|
||||||
|
/// 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<void> _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),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue