update onboarding; add missing safe areas

This commit is contained in:
Fabian Freund
2026-03-17 08:46:09 +01:00
parent 79b68f137e
commit 25f4d27f0d
49 changed files with 3938 additions and 2430 deletions
@@ -239,7 +239,7 @@ class GeneralSettings with FastEquatable {
tabBarShowContextualBar = tabBarShowContextualBar ?? true,
tabBarShowQuickTabSwitcherBar = tabBarShowQuickTabSwitcherBar ?? true,
tabBarPosition = tabBarPosition ?? TabBarPosition.bottom,
tabBarLayout = tabBarLayout ?? TabBarLayout.withTitle,
tabBarLayout = tabBarLayout ?? TabBarLayout.compact,
quickTabSwitcherMode =
quickTabSwitcherMode ?? QuickTabSwitcherMode.lastUsedTabs,
pullToRefreshEnabled = pullToRefreshEnabled ?? true,
@@ -66,104 +66,109 @@ class ProfileBackupScreen extends HookConsumerWidget {
return Scaffold(
appBar: AppBar(title: const Text('Create Backup')),
body: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8),
child: Form(
key: formKey,
child: ListView(
children: [
FancyPasswordField(
controller: passwordTextController,
enabled: !disableInteraction,
passwordController: passwordController,
enableSuggestions: false,
autocorrect: false,
enableIMEPersonalizedLearning: false,
keyboardType: TextInputType.visiblePassword,
decoration: const InputDecoration(
labelText: 'Password',
floatingLabelBehavior: FloatingLabelBehavior.always,
),
validationRules: {MinCharactersValidationRule(5)},
validator: (value) {
//Make sure since onChange is sometimes unreliable
passwordController.onChange(value ?? '');
return passwordController.areAllRulesValidated
? null
: 'Not Validated';
},
),
const SizedBox(height: 16),
SwitchListTile(
contentPadding: EdgeInsets.zero,
value: integrityVerification.value,
onChanged: disableInteraction
? null
: (value) {
integrityVerification.value = value;
},
title: const Text('Verify Backup Integrity'),
subtitle: const Text(
'Automatically check that backups are complete and restorable',
),
),
ExpansionTile(
enabled: !disableInteraction,
childrenPadding: EdgeInsets.zero,
tilePadding: EdgeInsets.zero,
title: const Text('Advanced'),
children: [
SwitchListTile(
contentPadding: EdgeInsets.zero,
value: skipPasswordConfirmation.value,
onChanged: disableInteraction
? null
: (value) {
skipPasswordConfirmation.value = value;
},
title: const Text('Skip Password Confirmation Prompt'),
body: SafeArea(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8),
child: Form(
key: formKey,
child: ListView(
children: [
FancyPasswordField(
controller: passwordTextController,
enabled: !disableInteraction,
passwordController: passwordController,
enableSuggestions: false,
autocorrect: false,
enableIMEPersonalizedLearning: false,
keyboardType: TextInputType.visiblePassword,
decoration: const InputDecoration(
labelText: 'Password',
floatingLabelBehavior: FloatingLabelBehavior.always,
),
],
),
const SizedBox(height: 16),
if (disableInteraction)
const Column(
children: [
LinearProgressIndicator(),
Text('Creating Backup'),
],
)
else
FilledButton.icon(
icon: const Icon(MdiIcons.safe),
onPressed: () async {
if (formKey.currentState?.validate() ?? false) {
if (!skipPasswordConfirmation.value) {
final confirmation =
await showPasswordConfirmationDialog(context);
validationRules: {MinCharactersValidationRule(5)},
validator: (value) {
//Make sure since onChange is sometimes unreliable
passwordController.onChange(value ?? '');
if (confirmation != passwordTextController.text) {
if (context.mounted) {
showErrorMessage(context, 'Passwords do not match');
}
return;
}
}
backupFuture.value = ref
.read(userBackupServiceProvider.notifier)
.createUserBackup(
profile,
password: passwordTextController.text,
integrityCheck: integrityVerification.value,
);
}
return passwordController.areAllRulesValidated
? null
: 'Not Validated';
},
label: const Text('Backup'),
),
],
const SizedBox(height: 16),
SwitchListTile(
contentPadding: EdgeInsets.zero,
value: integrityVerification.value,
onChanged: disableInteraction
? null
: (value) {
integrityVerification.value = value;
},
title: const Text('Verify Backup Integrity'),
subtitle: const Text(
'Automatically check that backups are complete and restorable',
),
),
ExpansionTile(
enabled: !disableInteraction,
childrenPadding: EdgeInsets.zero,
tilePadding: EdgeInsets.zero,
title: const Text('Advanced'),
children: [
SwitchListTile(
contentPadding: EdgeInsets.zero,
value: skipPasswordConfirmation.value,
onChanged: disableInteraction
? null
: (value) {
skipPasswordConfirmation.value = value;
},
title: const Text('Skip Password Confirmation Prompt'),
),
],
),
const SizedBox(height: 16),
if (disableInteraction)
const Column(
children: [
LinearProgressIndicator(),
Text('Creating Backup'),
],
)
else
FilledButton.icon(
icon: const Icon(MdiIcons.safe),
onPressed: () async {
if (formKey.currentState?.validate() ?? false) {
if (!skipPasswordConfirmation.value) {
final confirmation =
await showPasswordConfirmationDialog(context);
if (confirmation != passwordTextController.text) {
if (context.mounted) {
showErrorMessage(
context,
'Passwords do not match',
);
}
return;
}
}
backupFuture.value = ref
.read(userBackupServiceProvider.notifier)
.createUserBackup(
profile,
password: passwordTextController.text,
integrityCheck: integrityVerification.value,
);
}
},
label: const Text('Backup'),
),
],
),
),
),
),
@@ -39,57 +39,61 @@ class ProfileBackupListScreen extends HookConsumerWidget {
return Scaffold(
appBar: AppBar(title: const Text('Backups')),
body: backupListAsync.when(
data: (backupList) {
return ListView.builder(
itemCount: backupList.length,
itemBuilder: (context, index) {
final file = backupList[index];
final match = _filenamePattern.firstMatch(p.basename(file.path));
if (match != null) {
final profileName = match.group(1)!;
final datePart = match.group(2)!;
// Reparse into DateTime
final dateTime = UserBackupService.dateFormatter.decode(
datePart,
body: SafeArea(
child: backupListAsync.when(
data: (backupList) {
return ListView.builder(
itemCount: backupList.length,
itemBuilder: (context, index) {
final file = backupList[index];
final match = _filenamePattern.firstMatch(
p.basename(file.path),
);
return ListTile(
key: ValueKey(file.path),
title: Text(profileName),
subtitle: Text(
ref.read(formatProvider.notifier).fullDateTime(dateTime),
),
onTap: () async {
await RestoreProfileRoute(
backupFilePath: file.path,
).push(context);
},
);
} else {
return ListTile(
key: ValueKey(file.path),
title: Text(p.basename(file.path)),
onTap: () async {
await RestoreProfileRoute(
backupFilePath: file.path,
).push(context);
},
);
}
},
);
},
error: (error, stackTrace) => FailureWidget(
title: 'Failed to get backups',
exception: error,
onRetry: () {
ref.invalidate(backupListProvider);
if (match != null) {
final profileName = match.group(1)!;
final datePart = match.group(2)!;
// Reparse into DateTime
final dateTime = UserBackupService.dateFormatter.decode(
datePart,
);
return ListTile(
key: ValueKey(file.path),
title: Text(profileName),
subtitle: Text(
ref.read(formatProvider.notifier).fullDateTime(dateTime),
),
onTap: () async {
await RestoreProfileRoute(
backupFilePath: file.path,
).push(context);
},
);
} else {
return ListTile(
key: ValueKey(file.path),
title: Text(p.basename(file.path)),
onTap: () async {
await RestoreProfileRoute(
backupFilePath: file.path,
).push(context);
},
);
}
},
);
},
error: (error, stackTrace) => FailureWidget(
title: 'Failed to get backups',
exception: error,
onRetry: () {
ref.invalidate(backupListProvider);
},
),
loading: () => const Center(child: CircularProgressIndicator()),
),
loading: () => const Center(child: CircularProgressIndicator()),
),
);
}
@@ -124,29 +124,33 @@ class ProfileEditScreen extends HookConsumerWidget {
),
],
),
body: Form(
key: formKey,
child: ListView(
padding: const EdgeInsets.symmetric(horizontal: 12.0),
children: [
TextFormField(
controller: nameTextController,
decoration: const InputDecoration(
label: Text('Name'),
floatingLabelBehavior: FloatingLabelBehavior.always,
body: SafeArea(
child: Form(
key: formKey,
child: ListView(
padding: const EdgeInsets.symmetric(horizontal: 12.0),
children: [
TextFormField(
controller: nameTextController,
decoration: const InputDecoration(
label: Text('Name'),
floatingLabelBehavior: FloatingLabelBehavior.always,
),
validator: validateProfileName,
),
validator: validateProfileName,
),
const SizedBox(height: 24),
_AuthSection(
authSettings: authSettings.value,
onAuthSettingsChanged: (newSettings) {
authSettings.value = newSettings;
},
),
const SizedBox(height: 24),
if (profile != null) ...[_ProfileActionsSection(profile: profile!)],
],
const SizedBox(height: 24),
_AuthSection(
authSettings: authSettings.value,
onAuthSettingsChanged: (newSettings) {
authSettings.value = newSettings;
},
),
const SizedBox(height: 24),
if (profile != null) ...[
_ProfileActionsSection(profile: profile!),
],
],
),
),
),
);
@@ -46,35 +46,38 @@ class ProfileListScreen extends HookConsumerWidget {
),
],
),
body: usersAsync.when(
skipLoadingOnReload: true,
data: (profiles) => ListView.builder(
itemCount: profiles.length,
itemBuilder: (context, index) {
final profile = profiles[index];
final isSelected = filesystem.selectedProfile == profile.uuidValue;
body: SafeArea(
child: usersAsync.when(
skipLoadingOnReload: true,
data: (profiles) => ListView.builder(
itemCount: profiles.length,
itemBuilder: (context, index) {
final profile = profiles[index];
final isSelected =
filesystem.selectedProfile == profile.uuidValue;
return ListTile(
enabled: !isSelected,
leading: const Icon(Icons.person),
title: Text(profile.name),
subtitle: isSelected ? const Text('Active') : null,
trailing: const Icon(Icons.chevron_right),
onTap: () async {
await EditProfileRoute(
profile: jsonEncode(profile.toJson()),
).push(context);
},
);
},
),
error: (error, stackTrace) => Center(
child: FailureWidget(
title: 'Failed to load Profiles',
exception: error,
return ListTile(
enabled: !isSelected,
leading: const Icon(Icons.person),
title: Text(profile.name),
subtitle: isSelected ? const Text('Active') : null,
trailing: const Icon(Icons.chevron_right),
onTap: () async {
await EditProfileRoute(
profile: jsonEncode(profile.toJson()),
).push(context);
},
);
},
),
error: (error, stackTrace) => Center(
child: FailureWidget(
title: 'Failed to load Profiles',
exception: error,
),
),
loading: () => const Center(child: CircularProgressIndicator()),
),
loading: () => const Center(child: CircularProgressIndicator()),
),
floatingActionButton: FloatingActionButton(
onPressed: () async {
@@ -68,107 +68,112 @@ class ProfileRestoreScreen extends HookConsumerWidget {
return Scaffold(
appBar: AppBar(title: const Text('Restore Backup')),
body: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8),
child: Form(
key: formKey,
child: ListView(
children: [
TextFormField(
controller: passwordTextController,
enabled: !disableInteraction,
enableSuggestions: false,
autocorrect: false,
enableIMEPersonalizedLearning: false,
keyboardType: TextInputType.visiblePassword,
obscureText: true,
decoration: const InputDecoration(
labelText: 'Password',
floatingLabelBehavior: FloatingLabelBehavior.always,
),
validator: (value) {
return validateRequired(value, message: 'Password required');
},
),
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 (restoreTarget.value == RestoreTarget.createNew)
body: SafeArea(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8),
child: Form(
key: formKey,
child: ListView(
children: [
TextFormField(
controller: nameTextController,
controller: passwordTextController,
enabled: !disableInteraction,
enableSuggestions: false,
autocorrect: false,
enableIMEPersonalizedLearning: false,
keyboardType: TextInputType.visiblePassword,
obscureText: true,
decoration: const InputDecoration(
label: Text('Name'),
labelText: 'Password',
floatingLabelBehavior: FloatingLabelBehavior.always,
),
validator: validateProfileName,
validator: (value) {
return validateRequired(
value,
message: 'Password required',
);
},
),
const SizedBox(height: 16),
if (disableInteraction)
const Column(
children: [
LinearProgressIndicator(),
Text('Restoring Backup'),
],
)
else
FilledButton.icon(
icon: const Icon(MdiIcons.backupRestore),
onPressed: () {
if (formKey.currentState?.validate() ?? false) {
restoreFuture.value = switch (restoreTarget.value) {
RestoreTarget.createOrOverride =>
ref
.read(userBackupServiceProvider.notifier)
.restoreAndCreateOrOverride(
backupFile,
password: passwordTextController.text,
confirmOverrideCallback: () {
if (context.mounted) {
return showOverrideProfileDialog(context);
} else {
throw Exception('Override failed');
}
},
),
RestoreTarget.createNew =>
ref
.read(userBackupServiceProvider.notifier)
.restoreAndCreateNew(
backupFile,
profileName: nameTextController.text,
password: passwordTextController.text,
),
};
const SizedBox(height: 16),
RadioGroup(
groupValue: restoreTarget.value,
onChanged: (value) {
if (value != null) {
restoreTarget.value = value;
}
},
label: const Text('Restore'),
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,
enabled: !disableInteraction,
decoration: const InputDecoration(
label: Text('Name'),
floatingLabelBehavior: FloatingLabelBehavior.always,
),
validator: validateProfileName,
),
const SizedBox(height: 16),
if (disableInteraction)
const Column(
children: [
LinearProgressIndicator(),
Text('Restoring Backup'),
],
)
else
FilledButton.icon(
icon: const Icon(MdiIcons.backupRestore),
onPressed: () {
if (formKey.currentState?.validate() ?? false) {
restoreFuture.value = switch (restoreTarget.value) {
RestoreTarget.createOrOverride =>
ref
.read(userBackupServiceProvider.notifier)
.restoreAndCreateOrOverride(
backupFile,
password: passwordTextController.text,
confirmOverrideCallback: () {
if (context.mounted) {
return showOverrideProfileDialog(context);
} else {
throw Exception('Override failed');
}
},
),
RestoreTarget.createNew =>
ref
.read(userBackupServiceProvider.notifier)
.restoreAndCreateNew(
backupFile,
profileName: nameTextController.text,
password: passwordTextController.text,
),
};
}
},
label: const Text('Restore'),
),
],
),
),
),
),
@@ -59,21 +59,23 @@ class LockScreen extends HookConsumerWidget {
});
return Scaffold(
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(MdiIcons.lock, size: 64),
const SizedBox(height: 16),
const Text('Profile is locked'),
const SizedBox(height: 16),
FilledButton.icon(
style: FilledButton.styleFrom(minimumSize: const Size(160, 40)),
icon: const Icon(MdiIcons.fingerprint),
label: Text(isAuthenticating.value ? 'Unlocking...' : 'Unlock'),
onPressed: isAuthenticating.value ? null : authenticate,
),
],
body: SafeArea(
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(MdiIcons.lock, size: 64),
const SizedBox(height: 16),
const Text('Profile is locked'),
const SizedBox(height: 16),
FilledButton.icon(
style: FilledButton.styleFrom(minimumSize: const Size(160, 40)),
icon: const Icon(MdiIcons.fingerprint),
label: Text(isAuthenticating.value ? 'Unlocking...' : 'Unlock'),
onPressed: isAuthenticating.value ? null : authenticate,
),
],
),
),
),
);
@@ -24,7 +24,7 @@ part 'onboarding.g.dart';
@Riverpod(keepAlive: true)
class OnboardingRepository extends _$OnboardingRepository {
static const targetRevision = 2;
static const targetRevision = 3;
Future<int?> getCurrentRevision() {
return ref
@@ -43,7 +43,7 @@ class OnboardingRepository extends _$OnboardingRepository {
Future<bool> isOutdated() async {
final current = await getCurrentRevision();
return current != targetRevision;
return current == null || current < targetRevision;
}
@override
@@ -42,7 +42,7 @@ final class OnboardingRepositoryProvider
}
String _$onboardingRepositoryHash() =>
r'937a5fe08c09cc0475270d3707bfb6f087bcf123';
r'd16657963415392840cdba9f4bcc724963f6da63';
abstract class _$OnboardingRepository extends $Notifier<void> {
void build();