initial multi user feature

This commit is contained in:
Fabian Freund
2025-11-27 07:51:16 +01:00
parent 6d3c2757c3
commit 00d0599dde
49 changed files with 1341 additions and 258 deletions
@@ -0,0 +1,56 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:weblibre/core/filesystem.dart';
import 'package:weblibre/core/routing/routes.dart';
import 'package:weblibre/features/user/domain/repositories/profile.dart';
import 'package:weblibre/presentation/widgets/failure_widget.dart';
import 'package:weblibre/utils/exit_app.dart';
class SelectProfileDialog extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final usersAsync = ref.watch(profileRepositoryProvider);
return AlertDialog(
title: const Text('Manage Users'),
scrollable: true,
content: usersAsync.when(
data: (profiles) => Column(
children: profiles.map((profile) {
final isSelected = filesystem.selectedProfile == profile.uuidValue;
return ListTile(
key: ValueKey(profile.id),
enabled: !isSelected,
leading: const Icon(Icons.person),
title: Text(profile.name),
subtitle: isSelected ? const Text('Active') : null,
onTap: () async {
await ref
.read(profileRepositoryProvider.notifier)
.switchProfile(profile.id);
await exitApp(ref.container);
},
);
}).toList(),
),
error: (error, stackTrace) => Center(
child: FailureWidget(
title: 'Failed to load Profiles',
exception: error,
),
),
loading: () => const Center(child: CircularProgressIndicator()),
),
actions: [
TextButton.icon(
icon: const Icon(Icons.edit),
label: const Text('Edit'),
onPressed: () async {
await ProfileListRoute().push(context);
},
),
],
);
}
}
@@ -0,0 +1,126 @@
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:weblibre/domain/entities/profile.dart';
import 'package:weblibre/features/user/domain/repositories/profile.dart';
import 'package:weblibre/utils/form_validators.dart';
class ProfileEditScreen extends HookConsumerWidget {
final Profile? profile;
const ProfileEditScreen({required this.profile});
@override
Widget build(BuildContext context, WidgetRef ref) {
final formKey = useMemoized(() => GlobalKey<FormState>());
final nameTextController = useTextEditingController(text: profile?.name);
return Scaffold(
appBar: AppBar(
title: (profile != null)
? const Text('Edit User')
: const Text('Create User'),
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();
}
}
}
},
icon: const Icon(Icons.check),
),
],
),
body: Form(
key: formKey,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12.0),
child: ListView(
children: [
TextFormField(
controller: nameTextController,
decoration: const InputDecoration(
label: Text('Name'),
floatingLabelBehavior: FloatingLabelBehavior.always,
),
validator: validateRequired,
),
const SizedBox(height: 16),
if (profile != null)
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 showDialog<bool?>(
context: context,
builder: (BuildContext context) {
return AlertDialog(
title: const Text('Delete User'),
content: const Text(
'Are you sure you want to delete this User including all data?',
),
actions: <Widget>[
TextButton(
onPressed: () {
Navigator.pop(context, false);
},
child: const Text('Cancel'),
),
TextButton(
onPressed: () {
Navigator.pop(context, true);
},
child: const Text('Delete'),
),
],
);
},
);
if (result == true) {
await ref
.read(profileRepositoryProvider.notifier)
.deleteProfile(profile!.uuidValue.uuid);
if (context.mounted) {
context.pop();
}
}
},
),
),
],
),
),
),
);
}
}
@@ -0,0 +1,56 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:weblibre/core/filesystem.dart';
import 'package:weblibre/core/routing/routes.dart';
import 'package:weblibre/features/user/domain/repositories/profile.dart';
import 'package:weblibre/presentation/widgets/failure_widget.dart';
class ProfileListScreen extends HookConsumerWidget {
const ProfileListScreen();
@override
Widget build(BuildContext context, WidgetRef ref) {
final usersAsync = ref.watch(profileRepositoryProvider);
return Scaffold(
appBar: AppBar(title: const Text('Profiles')),
body: usersAsync.when(
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,
),
),
loading: () => const Center(child: CircularProgressIndicator()),
),
floatingActionButton: FloatingActionButton(
onPressed: () async {
await CreateProfileRoute().push(context);
},
child: const Icon(Icons.person_add),
),
);
}
}
+9 -9
View File
@@ -18,26 +18,20 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'package:exceptions/exceptions.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:nullability/nullability.dart';
import 'package:riverpod/riverpod.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:weblibre/core/filesystem.dart';
import 'package:weblibre/domain/entities/profile.dart';
import 'package:weblibre/features/user/data/providers.dart';
import 'package:weblibre/features/user/domain/entities/fingerprint_overrides.dart';
import 'package:weblibre/features/user/domain/repositories/engine_settings.dart';
import 'package:weblibre/features/user/domain/repositories/general_settings.dart';
import 'package:weblibre/features/user/domain/repositories/profile.dart';
import 'package:weblibre/features/user/domain/services/fingerprinting.dart';
part 'providers.g.dart';
const _authKey = 'pb_auth';
@Riverpod()
Future<String?> _storedAuthData(Ref ref) {
const secureStorage = FlutterSecureStorage();
return secureStorage.read(key: _authKey);
}
@Riverpod()
Stream<double> iconCacheSizeMegabytes(Ref ref) {
final repository = ref.watch(userDatabaseProvider);
@@ -73,3 +67,9 @@ Future<Result<FingerprintOverrides>> fingerprintOverrideSettings(
return overrides;
}
@Riverpod(keepAlive: true)
Future<Profile> selectedProfile(Ref ref) async {
final profiles = await ref.watch(profileRepositoryProvider.future);
return profiles.firstWhere((p) => p.uuidValue == filesystem.selectedProfile);
}
+33 -33
View File
@@ -9,39 +9,6 @@ part of 'providers.dart';
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
@ProviderFor(_storedAuthData)
const _storedAuthDataProvider = _StoredAuthDataProvider._();
final class _StoredAuthDataProvider
extends $FunctionalProvider<AsyncValue<String?>, String?, FutureOr<String?>>
with $FutureModifier<String?>, $FutureProvider<String?> {
const _StoredAuthDataProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'_storedAuthDataProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$_storedAuthDataHash();
@$internal
@override
$FutureProviderElement<String?> $createElement($ProviderPointer pointer) =>
$FutureProviderElement(pointer);
@override
FutureOr<String?> create(Ref ref) {
return _storedAuthData(ref);
}
}
String _$_storedAuthDataHash() => r'5f7e3ef6233a2036f7ce3728131901a46b1e548e';
@ProviderFor(iconCacheSizeMegabytes)
const iconCacheSizeMegabytesProvider = IconCacheSizeMegabytesProvider._();
@@ -160,3 +127,36 @@ final class FingerprintOverrideSettingsProvider
String _$fingerprintOverrideSettingsHash() =>
r'd4d40ec425098fb1f5a2f0c4944f058829a41a0a';
@ProviderFor(selectedProfile)
const selectedProfileProvider = SelectedProfileProvider._();
final class SelectedProfileProvider
extends $FunctionalProvider<AsyncValue<Profile>, Profile, FutureOr<Profile>>
with $FutureModifier<Profile>, $FutureProvider<Profile> {
const SelectedProfileProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'selectedProfileProvider',
isAutoDispose: false,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$selectedProfileHash();
@$internal
@override
$FutureProviderElement<Profile> $createElement($ProviderPointer pointer) =>
$FutureProviderElement(pointer);
@override
FutureOr<Profile> create(Ref ref) {
return selectedProfile(ref);
}
}
String _$selectedProfileHash() => r'c703cad8f30abb4f5f42db0119756ee6791ac477';
@@ -0,0 +1,57 @@
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:uuid/uuid.dart';
import 'package:weblibre/core/filesystem.dart';
import 'package:weblibre/domain/entities/profile.dart';
part 'profile.g.dart';
@Riverpod(keepAlive: true)
class ProfileRepository extends _$ProfileRepository {
Future<List<Profile>> _readProfiles() {
return filesystem.getAvailableProfileDirectories().then((dirs) async {
final profiles = await Future.wait(
dirs.map(filesystem.readProfileMetadata),
);
return profiles.nonNulls.toList();
});
}
Future<void> switchProfile(String id) async {
await filesystem.setStartupProfile(UuidValue.withValidation(id));
}
Future<Profile> createProfile({required String name}) async {
final profile = Profile.create(name: name);
if (!await filesystem.createNewProfile(profile)) {
throw Exception('Could not create profile');
}
state = await AsyncValue.guard(_readProfiles);
return profile;
}
Future<void> updateProfileMetadata(Profile profile) async {
await filesystem.updateProfileMetadata(profile);
state = await AsyncValue.guard(_readProfiles);
}
Future<bool> deleteProfile(String id) async {
final uuid = UuidValue.withValidation(id);
if (filesystem.selectedProfile == uuid) {
return false;
}
await filesystem.getProfileDir(uuid).delete(recursive: true);
state = await AsyncValue.guard(_readProfiles);
return true;
}
@override
Future<List<Profile>> build() {
return _readProfiles();
}
}
@@ -0,0 +1,55 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'profile.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
@ProviderFor(ProfileRepository)
const profileRepositoryProvider = ProfileRepositoryProvider._();
final class ProfileRepositoryProvider
extends $AsyncNotifierProvider<ProfileRepository, List<Profile>> {
const ProfileRepositoryProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'profileRepositoryProvider',
isAutoDispose: false,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$profileRepositoryHash();
@$internal
@override
ProfileRepository create() => ProfileRepository();
}
String _$profileRepositoryHash() => r'1357d42738d40e8e447ab8879292e81ad7b80b61';
abstract class _$ProfileRepository extends $AsyncNotifier<List<Profile>> {
FutureOr<List<Profile>> build();
@$mustCallSuper
@override
void runBuild() {
final created = build();
final ref = this.ref as $Ref<AsyncValue<List<Profile>>, List<Profile>>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<AsyncValue<List<Profile>>, List<Profile>>,
AsyncValue<List<Profile>>,
Object?,
Object?
>;
element.handleValue(ref, created);
}
}