move auth feature from container to profile

This commit is contained in:
Fabian Freund
2026-02-12 10:25:44 +01:00
parent 9f7637dd06
commit 45f54ef092
28 changed files with 903 additions and 455 deletions
@@ -27,20 +27,82 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:weblibre/core/filesystem.dart';
import 'package:weblibre/core/routing/routes.dart';
import 'package:weblibre/domain/entities/profile.dart';
import 'package:weblibre/features/settings/presentation/widgets/sections.dart';
import 'package:weblibre/features/user/data/models/auth_settings.dart';
import 'package:weblibre/features/user/domain/presentation/dialogs/delete_profile_dialog.dart';
import 'package:weblibre/features/user/domain/presentation/utils/profile_switch_handler.dart';
import 'package:weblibre/features/user/domain/providers/profile_auth.dart';
import 'package:weblibre/features/user/domain/repositories/profile.dart';
import 'package:weblibre/features/user/domain/services/local_authentication.dart';
import 'package:weblibre/utils/form_validators.dart';
const _timeoutOptions = <DropdownMenuItem<Duration?>>[
DropdownMenuItem(value: Duration(minutes: 1), child: Text('1 minute')),
DropdownMenuItem(value: Duration(minutes: 5), child: Text('5 minutes')),
DropdownMenuItem(value: Duration(minutes: 15), child: Text('15 minutes')),
DropdownMenuItem(value: Duration(hours: 1), child: Text('1 hour')),
];
class ProfileEditScreen extends HookConsumerWidget {
final Profile? profile;
const ProfileEditScreen({required this.profile});
Future<void> _handleSave(
BuildContext context,
WidgetRef ref,
GlobalKey<FormState> formKey,
String name,
AuthSettings authSettings,
) async {
if (!(formKey.currentState?.validate() ?? false)) {
return;
}
// Require biometric confirmation when enabling/changing auth
if (profile != null &&
(profile!.authSettings.authenticationRequired ||
authSettings.authenticationRequired)) {
final authResult = await ref
.read(localAuthenticationServiceProvider.notifier)
.authenticate(
authKey: profileAccessAuthKey(profile!.id),
localizedReason: 'Require authentication for profile',
);
if (!authResult) {
return;
}
}
if (profile != null) {
await ref
.read(profileRepositoryProvider.notifier)
.updateProfileMetadata(
profile!.copyWith(name: name, authSettings: authSettings),
);
if (context.mounted) {
context.pop();
}
} else {
await ref
.read(profileRepositoryProvider.notifier)
.createProfile(name: name, authSettings: authSettings);
if (context.mounted) {
context.pop();
}
}
}
@override
Widget build(BuildContext context, WidgetRef ref) {
final formKey = useMemoized(() => GlobalKey<FormState>());
final nameTextController = useTextEditingController(text: profile?.name);
final authSettings = useState(
profile?.authSettings ?? AuthSettings.withDefaults(),
);
return Scaffold(
appBar: AppBar(
@@ -50,27 +112,13 @@ class ProfileEditScreen extends HookConsumerWidget {
actions: [
IconButton(
onPressed: () async {
if (formKey.currentState?.validate() ?? false) {
if (profile != null) {
await ref
.read(profileRepositoryProvider.notifier)
.updateProfileMetadata(
profile!.copyWith.name(nameTextController.text),
);
if (context.mounted) {
context.pop();
}
} else {
await ref
.read(profileRepositoryProvider.notifier)
.createProfile(name: nameTextController.text);
if (context.mounted) {
context.pop();
}
}
}
await _handleSave(
context,
ref,
formKey,
nameTextController.text,
authSettings.value,
);
},
icon: const Icon(Icons.check),
),
@@ -78,77 +126,191 @@ class ProfileEditScreen extends HookConsumerWidget {
),
body: Form(
key: formKey,
child: Padding(
child: ListView(
padding: const EdgeInsets.symmetric(horizontal: 12.0),
child: ListView(
children: [
TextFormField(
controller: nameTextController,
decoration: const InputDecoration(
label: Text('Name'),
floatingLabelBehavior: FloatingLabelBehavior.always,
),
validator: validateProfileName,
children: [
TextFormField(
controller: nameTextController,
decoration: const InputDecoration(
label: Text('Name'),
floatingLabelBehavior: FloatingLabelBehavior.always,
),
const SizedBox(height: 16),
if (profile != null) ...[
SizedBox(
width: double.infinity,
child: OutlinedButton.icon(
label: const Text('Backup'),
icon: const Icon(MdiIcons.safe),
onPressed: () async {
await BackupProfileRoute(
profile: jsonEncode(profile!.toJson()),
).push(context);
},
),
),
const SizedBox(height: 16),
if (filesystem.selectedProfile != profile!.uuidValue)
SizedBox(
width: double.infinity,
child: OutlinedButton.icon(
label: const Text('Switch to this Profile'),
icon: const Icon(MdiIcons.accountSwitch),
onPressed: () async {
await handleSwitchProfile(context, ref, profile!);
},
),
),
const SizedBox(height: 16),
SizedBox(
width: double.infinity,
child: OutlinedButton.icon(
style: OutlinedButton.styleFrom(
side: BorderSide(
color: Theme.of(context).colorScheme.error,
),
foregroundColor: Theme.of(context).colorScheme.error,
iconColor: Theme.of(context).colorScheme.error,
),
label: const Text('Delete'),
icon: const Icon(Icons.delete),
onPressed: () async {
final result = await showDeleteProfileDialog(context);
if (result == true) {
await ref
.read(profileRepositoryProvider.notifier)
.deleteProfile(profile!.uuidValue.uuid);
if (context.mounted) {
context.pop();
}
}
},
),
),
],
],
),
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!)],
],
),
),
);
}
}
class _AuthSection extends StatelessWidget {
final AuthSettings authSettings;
final ValueChanged<AuthSettings> onAuthSettingsChanged;
const _AuthSection({
required this.authSettings,
required this.onAuthSettingsChanged,
});
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SettingSection(name: 'Authentication'),
SwitchListTile.adaptive(
value: authSettings.authenticationRequired,
title: const Text('Require Authentication'),
subtitle: const Text(
'Lock this profile when switching away from the app',
),
secondary: const Icon(MdiIcons.fingerprint),
contentPadding: EdgeInsets.zero,
onChanged: (value) {
onAuthSettingsChanged(
authSettings.copyWith.authenticationRequired(value),
);
},
),
if (authSettings.authenticationRequired) ...[
const SizedBox(height: 8),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const ListTile(
title: Text('Auto-lock Behavior'),
subtitle: Text('Choose when to lock the profile'),
contentPadding: EdgeInsets.zero,
leading: Icon(MdiIcons.lockClock),
),
RadioGroup<AutoLockMode>(
groupValue: authSettings.autoLockMode,
onChanged: (value) {
if (value != null) {
onAuthSettingsChanged(
authSettings.copyWith.autoLockMode(value),
);
}
},
child: const Column(
children: [
RadioListTile.adaptive(
value: AutoLockMode.background,
title: Text('Lock on Background'),
subtitle: Text(
'Lock immediately when app goes to background',
),
),
RadioListTile.adaptive(
value: AutoLockMode.timeout,
title: Text('Lock After Timeout'),
subtitle: Text('Lock after a period of inactivity'),
),
],
),
),
],
),
),
if (authSettings.autoLockMode == AutoLockMode.timeout)
ListTile(
title: const Text('Timeout Duration'),
subtitle: const Text('How long to wait before locking'),
leading: const Icon(MdiIcons.timerOutline),
contentPadding: const EdgeInsets.symmetric(horizontal: 16.0),
trailing: DropdownButton<Duration?>(
value: authSettings.timeout,
items: _timeoutOptions,
underline: const SizedBox.shrink(),
onChanged: (Duration? value) {
if (value != null) {
onAuthSettingsChanged(authSettings.copyWith.timeout(value));
}
},
),
),
],
],
);
}
}
class _ProfileActionsSection extends ConsumerWidget {
final Profile profile;
const _ProfileActionsSection({required this.profile});
@override
Widget build(BuildContext context, WidgetRef ref) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SettingSection(name: 'Profile Actions'),
const SizedBox(height: 8),
SizedBox(
width: double.infinity,
child: OutlinedButton.icon(
label: const Text('Backup'),
icon: const Icon(MdiIcons.safe),
onPressed: () async {
await BackupProfileRoute(
profile: jsonEncode(profile.toJson()),
).push(context);
},
),
),
const SizedBox(height: 12),
if (filesystem.selectedProfile != profile.uuidValue)
SizedBox(
width: double.infinity,
child: OutlinedButton.icon(
label: const Text('Switch to this Profile'),
icon: const Icon(MdiIcons.accountSwitch),
onPressed: () async {
await handleSwitchProfile(context, ref, profile);
},
),
),
if (filesystem.selectedProfile != profile.uuidValue)
const SizedBox(height: 12),
SizedBox(
width: double.infinity,
child: OutlinedButton.icon(
style: OutlinedButton.styleFrom(
side: BorderSide(color: Theme.of(context).colorScheme.error),
foregroundColor: Theme.of(context).colorScheme.error,
iconColor: Theme.of(context).colorScheme.error,
),
label: const Text('Delete'),
icon: const Icon(Icons.delete),
onPressed: () async {
final result = await showDeleteProfileDialog(context);
if (result == true) {
await ref
.read(profileRepositoryProvider.notifier)
.deleteProfile(profile.uuidValue.uuid);
if (context.mounted) {
context.pop();
}
}
},
),
),
],
);
}
}