option to restore from backup during onboarding

This commit is contained in:
Fabian Freund
2026-04-18 04:56:03 +02:00
parent dcb0c8d5de
commit 5e6cc7a0f1
8 changed files with 145 additions and 47 deletions
@@ -18,4 +18,4 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>. * along with this program. If not, see <http://www.gnu.org/licenses/>.
*/ */
enum OnboardingMode { express, detailed } enum OnboardingMode { express, detailed, restore }
@@ -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/toolbar_layout.dart';
import 'package:weblibre/features/onboarding/presentation/pages/ublock_opt_in.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/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/onboarding.dart';
import 'package:weblibre/features/user/domain/repositories/profile.dart';
import 'package:weblibre/utils/exit_app.dart';
class OnboardingScreen extends HookConsumerWidget { class OnboardingScreen extends HookConsumerWidget {
final int currentRevision; final int currentRevision;
@@ -66,6 +70,9 @@ class OnboardingScreen extends HookConsumerWidget {
case 1: case 1:
return [const AiConfigurationPage()]; return [const AiConfigurationPage()];
default: default:
if (onboardingMode == OnboardingMode.restore && !isReturningUser) {
return [WelcomePage(isReturningUser: isReturningUser)];
}
return [ return [
WelcomePage(isReturningUser: isReturningUser), WelcomePage(isReturningUser: isReturningUser),
const DefaultSearchPage(), const DefaultSearchPage(),
@@ -78,7 +85,7 @@ class OnboardingScreen extends HookConsumerWidget {
PermissionsPage(formKey: GlobalKey<FormState>()), PermissionsPage(formKey: GlobalKey<FormState>()),
]; ];
} }
}, [currentRevision, targetRevision, onboardingMode]); }, [currentRevision, targetRevision, onboardingMode, isReturningUser]);
final lastPage = useRef(pageController.initialPage); final lastPage = useRef(pageController.initialPage);
final currentPage = useState(pageController.initialPage); final currentPage = useState(pageController.initialPage);
@@ -175,6 +182,63 @@ class OnboardingScreen extends HookConsumerWidget {
label: const Text('Next'), label: const Text('Next'),
), ),
) )
else if (onboardingMode == OnboardingMode.restore &&
!isReturningUser)
Expanded(
child: TextButton.icon(
onPressed: eulaAccepted
? () async {
await Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => ProfileBackupListScreen(
onBackupSelected: (context, uri) {
unawaited(
Navigator.of(context).push(
MaterialPageRoute<void>(
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 else
Expanded( Expanded(
child: TextButton.icon( child: TextButton.icon(
@@ -91,6 +91,17 @@ class WelcomePage extends ConsumerWidget {
.read(onboardingModeProvider.notifier) .read(onboardingModeProvider.notifier)
.select(OnboardingMode.detailed), .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), const SizedBox(height: 40),
_EulaCheckbox( _EulaCheckbox(
@@ -33,7 +33,20 @@ final _filenamePattern = RegExp(
); );
class ProfileBackupListScreen extends HookConsumerWidget { class ProfileBackupListScreen extends HookConsumerWidget {
const ProfileBackupListScreen({super.key}); final void Function(BuildContext context, Uri backupFileUri)?
onBackupSelected;
const ProfileBackupListScreen({super.key, this.onBackupSelected});
Future<void> _handleSelection(BuildContext context, Uri backupFileUri) async {
if (onBackupSelected != null) {
onBackupSelected!(context, backupFileUri);
} else {
await RestoreProfileRoute(
backupFileUri: backupFileUri.toString(),
).push(context);
}
}
Future<void> _pickDirectory(WidgetRef ref) async { Future<void> _pickDirectory(WidgetRef ref) async {
final dir = await SafUtil().pickDirectory( final dir = await SafUtil().pickDirectory(
@@ -128,21 +141,15 @@ class ProfileBackupListScreen extends HookConsumerWidget {
.read(formatProvider.notifier) .read(formatProvider.notifier)
.fullDateTime(dateTime), .fullDateTime(dateTime),
), ),
onTap: () async { onTap: () =>
await RestoreProfileRoute( _handleSelection(context, Uri.parse(file.uri)),
backupFileUri: file.uri,
).push(context);
},
); );
} else { } else {
return ListTile( return ListTile(
key: ValueKey(file.uri), key: ValueKey(file.uri),
title: Text(file.name), title: Text(file.name),
onTap: () async { onTap: () =>
await RestoreProfileRoute( _handleSelection(context, Uri.parse(file.uri)),
backupFileUri: file.uri,
).push(context);
},
); );
} }
}, },
@@ -22,6 +22,7 @@ import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:flutter_material_design_icons/flutter_material_design_icons.dart'; import 'package:flutter_material_design_icons/flutter_material_design_icons.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:weblibre/core/routing/routes.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/presentation/dialogs/override_profile_dialog.dart';
import 'package:weblibre/features/user/domain/services/user_backup.dart'; import 'package:weblibre/features/user/domain/services/user_backup.dart';
import 'package:weblibre/utils/form_validators.dart'; import 'package:weblibre/utils/form_validators.dart';
@@ -31,8 +32,16 @@ enum RestoreTarget { createOrOverride, createNew }
class ProfileRestoreScreen extends HookConsumerWidget { class ProfileRestoreScreen extends HookConsumerWidget {
final Uri backupFileUri; 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 @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
@@ -41,10 +50,10 @@ class ProfileRestoreScreen extends HookConsumerWidget {
final passwordTextController = useTextEditingController(); final passwordTextController = useTextEditingController();
final nameTextController = useTextEditingController(); final nameTextController = useTextEditingController();
final restoreFuture = useState<Future<bool>?>(null); final restoreFuture = useState<Future<Profile?>?>(null);
final restoreState = useFuture(restoreFuture.value); final restoreState = useFuture(restoreFuture.value);
final restoreTarget = useState(RestoreTarget.createNew); final restoreTarget = useState(forcedTarget ?? RestoreTarget.createNew);
useEffect(() { useEffect(() {
if (restoreState.hasError) { if (restoreState.hasError) {
@@ -54,7 +63,11 @@ class ProfileRestoreScreen extends HookConsumerWidget {
} else if (restoreState.hasData) { } else if (restoreState.hasData) {
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
showInfoMessage(context, 'Backup restored successfully'); 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), const SizedBox(height: 16),
RadioGroup( if (forcedTarget == null)
groupValue: restoreTarget.value, RadioGroup(
onChanged: (value) { groupValue: restoreTarget.value,
if (value != null) { onChanged: (value) {
restoreTarget.value = value; if (value != null) {
} restoreTarget.value = value;
}, }
child: Column( },
children: [ child: Column(
RadioListTile( children: [
enabled: !disableInteraction, RadioListTile(
value: RestoreTarget.createNew, enabled: !disableInteraction,
title: const Text('Create New User'), value: RestoreTarget.createNew,
subtitle: const Text('Restore backup as a new user'), 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',
), ),
), 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) if (restoreTarget.value == RestoreTarget.createNew)
TextFormField( TextFormField(
controller: nameTextController, controller: nameTextController,
@@ -156,7 +170,8 @@ class ProfileRestoreScreen extends HookConsumerWidget {
throw Exception('Override failed'); throw Exception('Override failed');
} }
}, },
), )
.then<Profile?>((_) => null),
RestoreTarget.createNew => RestoreTarget.createNew =>
ref ref
.read(userBackupServiceProvider.notifier) .read(userBackupServiceProvider.notifier)
@@ -202,7 +202,7 @@ class UserBackupService extends _$UserBackupService {
} }
} }
Future<bool> restoreAndCreateNew( Future<Profile> restoreAndCreateNew(
Uri backupFileUri, { Uri backupFileUri, {
required String profileName, required String profileName,
required String password, required String password,
@@ -222,17 +222,18 @@ class UserBackupService extends _$UserBackupService {
outputDirectory: outputDirectory, outputDirectory: outputDirectory,
argon2Params: Argon2Params.memoryConstrained(), argon2Params: Argon2Params.memoryConstrained(),
); );
await backup.unpack(password).then((_) async { final newProfile = await backup.unpack(password).then((_) async {
final newProfile = Profile.create(name: profileName); final newProfile = Profile.create(name: profileName);
final newPath = filesystem.getProfileDir(newProfile.uuidValue); final newPath = filesystem.getProfileDir(newProfile.uuidValue);
await outputDirectory.rename(newPath.path); await outputDirectory.rename(newPath.path);
await filesystem.updateProfileMetadata(newProfile); await filesystem.updateProfileMetadata(newProfile);
await filesystem.healProfile(newPath); await filesystem.healProfile(newPath);
return newProfile;
}); });
ref.invalidate(profileRepositoryProvider); ref.invalidate(profileRepositoryProvider);
return true; return newProfile;
} finally { } finally {
try { try {
if (await tempFile.exists()) { if (await tempFile.exists()) {
@@ -41,7 +41,7 @@ final class UserBackupServiceProvider
} }
} }
String _$userBackupServiceHash() => r'3746ae59d490e25a44815c125f30cd0981de555d'; String _$userBackupServiceHash() => r'52c7d3df3ce2d603692a035c77987727b840a359';
abstract class _$UserBackupService extends $Notifier<void> { abstract class _$UserBackupService extends $Notifier<void> {
void build(); void build();
+1 -1
View File
@@ -71,7 +71,7 @@ dependencies:
rss_dart: ^1.0.14 rss_dart: ^1.0.14
rxdart: ^0.28.0 rxdart: ^0.28.0
saf_stream: ^2.0.0 saf_stream: ^2.0.0
saf_util: ^2.0.0 saf_util: ^2.1.0
secure_archive: secure_archive:
git: git:
url: https://github.com/FaFre/secure_archive.git url: https://github.com/FaFre/secure_archive.git