diff --git a/flutter_client/lib/admin/widgets/admin_user_edit_sheet.dart b/flutter_client/lib/admin/widgets/admin_user_edit_sheet.dart new file mode 100644 index 00000000..7b087c18 --- /dev/null +++ b/flutter_client/lib/admin/widgets/admin_user_edit_sheet.dart @@ -0,0 +1,165 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../models/admin_user.dart'; +import '../../theme/theme_extension.dart'; +import '../admin_providers.dart'; +import 'typed_confirm_sheet.dart'; + +class AdminUserEditSheet extends ConsumerWidget { + const AdminUserEditSheet({super.key, required this.user}); + + final AdminUser user; + + static Future show(BuildContext context, AdminUser user) => + showModalBottomSheet( + context: context, + isScrollControlled: true, + builder: (_) => AdminUserEditSheet(user: user), + ); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final fs = Theme.of(context).extension()!; + final users = ref.read(adminUsersProvider.notifier); + return Padding( + padding: EdgeInsets.only( + bottom: MediaQuery.of(context).viewInsets.bottom, + ), + child: Container( + color: fs.iron, + padding: const EdgeInsets.all(20), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + user.username, + style: TextStyle( + color: fs.parchment, + fontFamily: 'Fraunces', + fontSize: 20, + ), + ), + const SizedBox(height: 12), + SwitchListTile( + key: const Key('user_edit_is_admin'), + title: Text('Admin', style: TextStyle(color: fs.parchment)), + value: user.isAdmin, + onChanged: (v) async { + try { + await users.setAdmin(user.id, v); + } catch (e) { + if (context.mounted) { + ScaffoldMessenger.of(context) + .showSnackBar(SnackBar(content: Text('$e'))); + } + } + }, + ), + SwitchListTile( + key: const Key('user_edit_auto_approve'), + title: Text('Auto-approve requests', + style: TextStyle(color: fs.parchment)), + value: user.autoApproveRequests, + onChanged: (v) async { + try { + await users.setAutoApprove(user.id, v); + } catch (e) { + if (context.mounted) { + ScaffoldMessenger.of(context) + .showSnackBar(SnackBar(content: Text('$e'))); + } + } + }, + ), + const SizedBox(height: 12), + Row( + children: [ + TextButton( + key: const Key('user_edit_reset_password'), + onPressed: () => _resetPassword(context, users), + child: const Text('Reset password'), + ), + const Spacer(), + TextButton( + key: const Key('user_edit_delete'), + style: TextButton.styleFrom(foregroundColor: fs.oxblood), + onPressed: () => _deleteUser(context, users), + child: const Text('Delete'), + ), + ], + ), + ], + ), + ), + ); + } + + Future _resetPassword( + BuildContext context, AdminUsersController users) async { + final controller = TextEditingController(); + final newPw = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('Reset password'), + content: TextField( + controller: controller, + obscureText: true, + decoration: const InputDecoration( + labelText: 'New password', + helperText: 'Minimum 8 characters', + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () { + if (controller.text.length >= 8) { + Navigator.pop(ctx, controller.text); + } + }, + child: const Text('Reset'), + ), + ], + ), + ); + if (newPw == null || !context.mounted) return; + try { + await users.resetPassword(user.id, newPw); + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Password reset.')), + ); + } + } catch (e) { + if (context.mounted) { + ScaffoldMessenger.of(context) + .showSnackBar(SnackBar(content: Text('$e'))); + } + } + } + + Future _deleteUser( + BuildContext context, AdminUsersController users) async { + if (!await TypedConfirmSheet.show( + context, + title: 'Delete user?', + message: + 'Permanently delete user "${user.username}" and all their data. ' + 'Cannot be undone.', + )) return; + try { + await users.delete(user.id); + if (context.mounted) Navigator.pop(context); + } catch (e) { + if (context.mounted) { + ScaffoldMessenger.of(context) + .showSnackBar(SnackBar(content: Text('$e'))); + } + } + } +} diff --git a/flutter_client/lib/admin/widgets/admin_user_row.dart b/flutter_client/lib/admin/widgets/admin_user_row.dart new file mode 100644 index 00000000..b029f748 --- /dev/null +++ b/flutter_client/lib/admin/widgets/admin_user_row.dart @@ -0,0 +1,58 @@ +import 'package:flutter/material.dart'; + +import '../../models/admin_user.dart'; +import '../../theme/theme_extension.dart'; + +class AdminUserRow extends StatelessWidget { + const AdminUserRow({super.key, required this.user, required this.onTap}); + + final AdminUser user; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final fs = Theme.of(context).extension()!; + return ListTile( + title: Text( + user.username, + style: TextStyle( + color: fs.parchment, + fontFamily: 'Fraunces', + fontSize: 16, + ), + ), + subtitle: Wrap( + spacing: 6, + children: [ + if (user.isAdmin) _Badge(label: 'admin', color: fs.bronze), + if (user.autoApproveRequests) + _Badge(label: 'auto-approve', color: fs.moss), + ], + ), + trailing: Icon(Icons.chevron_right, color: fs.ash), + onTap: onTap, + ); + } +} + +class _Badge extends StatelessWidget { + const _Badge({required this.label, required this.color}); + final String label; + final Color color; + + @override + Widget build(BuildContext context) { + final fs = Theme.of(context).extension()!; + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + decoration: BoxDecoration( + color: color, + borderRadius: BorderRadius.circular(8), + ), + child: Text( + label, + style: TextStyle(color: fs.obsidian, fontSize: 11), + ), + ); + } +}