diff --git a/apps/weblibre/lib/features/onboarding/domain/entities/onboarding_mode.dart b/apps/weblibre/lib/features/onboarding/domain/entities/onboarding_mode.dart index 9c0ae2cd..18c6e7b2 100644 --- a/apps/weblibre/lib/features/onboarding/domain/entities/onboarding_mode.dart +++ b/apps/weblibre/lib/features/onboarding/domain/entities/onboarding_mode.dart @@ -18,4 +18,4 @@ * along with this program. If not, see . */ -enum OnboardingMode { express, detailed } +enum OnboardingMode { express, detailed, restore } diff --git a/apps/weblibre/lib/features/onboarding/presentation/onboarding.dart b/apps/weblibre/lib/features/onboarding/presentation/onboarding.dart index 48aa0a69..2e21d88b 100644 --- a/apps/weblibre/lib/features/onboarding/presentation/onboarding.dart +++ b/apps/weblibre/lib/features/onboarding/presentation/onboarding.dart @@ -36,7 +36,11 @@ import 'package:weblibre/features/onboarding/presentation/pages/privacy_hardenin import 'package:weblibre/features/onboarding/presentation/pages/toolbar_layout.dart'; import 'package:weblibre/features/onboarding/presentation/pages/ublock_opt_in.dart'; import 'package:weblibre/features/onboarding/presentation/pages/welcome.dart'; +import 'package:weblibre/features/user/domain/presentation/screens/profile_backup_list.dart'; +import 'package:weblibre/features/user/domain/presentation/screens/profile_restore.dart'; import 'package:weblibre/features/user/domain/repositories/onboarding.dart'; +import 'package:weblibre/features/user/domain/repositories/profile.dart'; +import 'package:weblibre/utils/exit_app.dart'; class OnboardingScreen extends HookConsumerWidget { final int currentRevision; @@ -66,6 +70,9 @@ class OnboardingScreen extends HookConsumerWidget { case 1: return [const AiConfigurationPage()]; default: + if (onboardingMode == OnboardingMode.restore && !isReturningUser) { + return [WelcomePage(isReturningUser: isReturningUser)]; + } return [ WelcomePage(isReturningUser: isReturningUser), const DefaultSearchPage(), @@ -78,7 +85,7 @@ class OnboardingScreen extends HookConsumerWidget { PermissionsPage(formKey: GlobalKey()), ]; } - }, [currentRevision, targetRevision, onboardingMode]); + }, [currentRevision, targetRevision, onboardingMode, isReturningUser]); final lastPage = useRef(pageController.initialPage); final currentPage = useState(pageController.initialPage); @@ -175,6 +182,63 @@ class OnboardingScreen extends HookConsumerWidget { label: const Text('Next'), ), ) + else if (onboardingMode == OnboardingMode.restore && + !isReturningUser) + Expanded( + child: TextButton.icon( + onPressed: eulaAccepted + ? () async { + await Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => ProfileBackupListScreen( + onBackupSelected: (context, uri) { + unawaited( + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => + ProfileRestoreScreen( + backupFileUri: uri, + forcedTarget: + RestoreTarget.createNew, + onRestoreSuccess: + (_, profile) async { + await ref + .read( + onboardingRepositoryProvider + .notifier, + ) + .pushRevision( + targetRevision, + ); + if (profile != null) { + await ref + .read( + profileRepositoryProvider + .notifier, + ) + .switchProfile( + profile.id, + ); + await exitApp( + ref.container, + ); + } + }, + ), + ), + ), + ); + }, + ), + ), + ); + } + : null, + iconAlignment: IconAlignment.end, + icon: const Icon(Icons.settings_backup_restore), + label: const Text('Restore'), + ), + ) else Expanded( child: TextButton.icon( diff --git a/apps/weblibre/lib/features/onboarding/presentation/pages/welcome.dart b/apps/weblibre/lib/features/onboarding/presentation/pages/welcome.dart index 9e3443a3..40ce0385 100644 --- a/apps/weblibre/lib/features/onboarding/presentation/pages/welcome.dart +++ b/apps/weblibre/lib/features/onboarding/presentation/pages/welcome.dart @@ -91,6 +91,17 @@ class WelcomePage extends ConsumerWidget { .read(onboardingModeProvider.notifier) .select(OnboardingMode.detailed), ), + const SizedBox(height: 12), + _ModeOption( + mode: OnboardingMode.restore, + title: 'Restore from Backup', + subtitle: 'Import a profile from an encrypted backup file.', + icon: Icons.settings_backup_restore, + selected: selectedMode == OnboardingMode.restore, + onTap: () => ref + .read(onboardingModeProvider.notifier) + .select(OnboardingMode.restore), + ), ], const SizedBox(height: 40), _EulaCheckbox( diff --git a/apps/weblibre/lib/features/user/domain/presentation/screens/profile_backup_list.dart b/apps/weblibre/lib/features/user/domain/presentation/screens/profile_backup_list.dart index 46863d32..acc010e6 100644 --- a/apps/weblibre/lib/features/user/domain/presentation/screens/profile_backup_list.dart +++ b/apps/weblibre/lib/features/user/domain/presentation/screens/profile_backup_list.dart @@ -33,7 +33,20 @@ final _filenamePattern = RegExp( ); class ProfileBackupListScreen extends HookConsumerWidget { - const ProfileBackupListScreen({super.key}); + final void Function(BuildContext context, Uri backupFileUri)? + onBackupSelected; + + const ProfileBackupListScreen({super.key, this.onBackupSelected}); + + Future _handleSelection(BuildContext context, Uri backupFileUri) async { + if (onBackupSelected != null) { + onBackupSelected!(context, backupFileUri); + } else { + await RestoreProfileRoute( + backupFileUri: backupFileUri.toString(), + ).push(context); + } + } Future _pickDirectory(WidgetRef ref) async { final dir = await SafUtil().pickDirectory( @@ -128,21 +141,15 @@ class ProfileBackupListScreen extends HookConsumerWidget { .read(formatProvider.notifier) .fullDateTime(dateTime), ), - onTap: () async { - await RestoreProfileRoute( - backupFileUri: file.uri, - ).push(context); - }, + onTap: () => + _handleSelection(context, Uri.parse(file.uri)), ); } else { return ListTile( key: ValueKey(file.uri), title: Text(file.name), - onTap: () async { - await RestoreProfileRoute( - backupFileUri: file.uri, - ).push(context); - }, + onTap: () => + _handleSelection(context, Uri.parse(file.uri)), ); } }, diff --git a/apps/weblibre/lib/features/user/domain/presentation/screens/profile_restore.dart b/apps/weblibre/lib/features/user/domain/presentation/screens/profile_restore.dart index 539581f2..7407c0e8 100644 --- a/apps/weblibre/lib/features/user/domain/presentation/screens/profile_restore.dart +++ b/apps/weblibre/lib/features/user/domain/presentation/screens/profile_restore.dart @@ -22,6 +22,7 @@ import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_material_design_icons/flutter_material_design_icons.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:weblibre/core/routing/routes.dart'; +import 'package:weblibre/domain/entities/profile.dart'; import 'package:weblibre/features/user/domain/presentation/dialogs/override_profile_dialog.dart'; import 'package:weblibre/features/user/domain/services/user_backup.dart'; import 'package:weblibre/utils/form_validators.dart'; @@ -31,8 +32,16 @@ enum RestoreTarget { createOrOverride, createNew } class ProfileRestoreScreen extends HookConsumerWidget { final Uri backupFileUri; + final RestoreTarget? forcedTarget; + final void Function(BuildContext context, Profile? restoredProfile)? + onRestoreSuccess; - const ProfileRestoreScreen({super.key, required this.backupFileUri}); + const ProfileRestoreScreen({ + super.key, + required this.backupFileUri, + this.forcedTarget, + this.onRestoreSuccess, + }); @override Widget build(BuildContext context, WidgetRef ref) { @@ -41,10 +50,10 @@ class ProfileRestoreScreen extends HookConsumerWidget { final passwordTextController = useTextEditingController(); final nameTextController = useTextEditingController(); - final restoreFuture = useState?>(null); + final restoreFuture = useState?>(null); final restoreState = useFuture(restoreFuture.value); - final restoreTarget = useState(RestoreTarget.createNew); + final restoreTarget = useState(forcedTarget ?? RestoreTarget.createNew); useEffect(() { if (restoreState.hasError) { @@ -54,7 +63,11 @@ class ProfileRestoreScreen extends HookConsumerWidget { } else if (restoreState.hasData) { WidgetsBinding.instance.addPostFrameCallback((_) { showInfoMessage(context, 'Backup restored successfully'); - ProfileListRoute().go(context); + if (onRestoreSuccess != null) { + onRestoreSuccess!(context, restoreState.data); + } else { + ProfileListRoute().go(context); + } }); } @@ -93,32 +106,33 @@ class ProfileRestoreScreen extends HookConsumerWidget { }, ), const SizedBox(height: 16), - RadioGroup( - groupValue: restoreTarget.value, - onChanged: (value) { - if (value != null) { - restoreTarget.value = value; - } - }, - child: Column( - children: [ - RadioListTile( - enabled: !disableInteraction, - value: RestoreTarget.createNew, - title: const Text('Create New User'), - subtitle: const Text('Restore backup as a new user'), - ), - RadioListTile( - enabled: !disableInteraction, - value: RestoreTarget.createOrOverride, - title: const Text('Restore & Replace'), - subtitle: const Text( - 'Restore backup and overwrite existing user if present', + if (forcedTarget == null) + RadioGroup( + groupValue: restoreTarget.value, + onChanged: (value) { + if (value != null) { + restoreTarget.value = value; + } + }, + child: Column( + children: [ + RadioListTile( + enabled: !disableInteraction, + value: RestoreTarget.createNew, + title: const Text('Create New User'), + subtitle: const Text('Restore backup as a new user'), ), - ), - ], + RadioListTile( + enabled: !disableInteraction, + value: RestoreTarget.createOrOverride, + title: const Text('Restore & Replace'), + subtitle: const Text( + 'Restore backup and overwrite existing user if present', + ), + ), + ], + ), ), - ), if (restoreTarget.value == RestoreTarget.createNew) TextFormField( controller: nameTextController, @@ -156,7 +170,8 @@ class ProfileRestoreScreen extends HookConsumerWidget { throw Exception('Override failed'); } }, - ), + ) + .then((_) => null), RestoreTarget.createNew => ref .read(userBackupServiceProvider.notifier) diff --git a/apps/weblibre/lib/features/user/domain/services/user_backup.dart b/apps/weblibre/lib/features/user/domain/services/user_backup.dart index 94f8a8c8..e3188566 100644 --- a/apps/weblibre/lib/features/user/domain/services/user_backup.dart +++ b/apps/weblibre/lib/features/user/domain/services/user_backup.dart @@ -202,7 +202,7 @@ class UserBackupService extends _$UserBackupService { } } - Future restoreAndCreateNew( + Future restoreAndCreateNew( Uri backupFileUri, { required String profileName, required String password, @@ -222,17 +222,18 @@ class UserBackupService extends _$UserBackupService { outputDirectory: outputDirectory, argon2Params: Argon2Params.memoryConstrained(), ); - await backup.unpack(password).then((_) async { + final newProfile = await backup.unpack(password).then((_) async { final newProfile = Profile.create(name: profileName); final newPath = filesystem.getProfileDir(newProfile.uuidValue); await outputDirectory.rename(newPath.path); await filesystem.updateProfileMetadata(newProfile); await filesystem.healProfile(newPath); + return newProfile; }); ref.invalidate(profileRepositoryProvider); - return true; + return newProfile; } finally { try { if (await tempFile.exists()) { diff --git a/apps/weblibre/lib/features/user/domain/services/user_backup.g.dart b/apps/weblibre/lib/features/user/domain/services/user_backup.g.dart index 06600ab3..eb45b06b 100644 --- a/apps/weblibre/lib/features/user/domain/services/user_backup.g.dart +++ b/apps/weblibre/lib/features/user/domain/services/user_backup.g.dart @@ -41,7 +41,7 @@ final class UserBackupServiceProvider } } -String _$userBackupServiceHash() => r'3746ae59d490e25a44815c125f30cd0981de555d'; +String _$userBackupServiceHash() => r'52c7d3df3ce2d603692a035c77987727b840a359'; abstract class _$UserBackupService extends $Notifier { void build(); diff --git a/apps/weblibre/pubspec.yaml b/apps/weblibre/pubspec.yaml index 44222d96..36f10f55 100644 --- a/apps/weblibre/pubspec.yaml +++ b/apps/weblibre/pubspec.yaml @@ -71,7 +71,7 @@ dependencies: rss_dart: ^1.0.14 rxdart: ^0.28.0 saf_stream: ^2.0.0 - saf_util: ^2.0.0 + saf_util: ^2.1.0 secure_archive: git: url: https://github.com/FaFre/secure_archive.git