/*
* Copyright (c) 2024-2026 Fabian Freund.
*
* This file is part of WebLibre
* (see https://weblibre.eu).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see .
*/
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:flutter_material_design_icons/flutter_material_design_icons.dart';
import 'package:go_router/go_router.dart';
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(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({super.key, required this.profile});
Future _handleSave(
BuildContext context,
WidgetRef ref,
GlobalKey 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());
final nameTextController = useTextEditingController(text: profile?.name);
final authSettings = useState(
profile?.authSettings ?? AuthSettings.withDefaults(),
);
return Scaffold(
appBar: AppBar(
title: (profile != null)
? const Text('Edit User')
: const Text('Create User'),
actions: [
IconButton(
onPressed: () async {
await _handleSave(
context,
ref,
formKey,
nameTextController.text,
authSettings.value,
);
},
icon: const Icon(Icons.check),
),
],
),
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,
),
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 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(
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(
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();
}
}
},
),
),
],
);
}
}