feat(flutter/admin): user row + edit sheet (toggles, reset, delete)
AdminUserRow shows username + admin/auto-approve badges; tap opens
AdminUserEditSheet bottom sheet.
Edit sheet: SwitchListTile pair for is_admin and auto_approve_requests
(both surface server errors via SnackBar, including the last-admin
guard). Reset password takes a typed-input dialog (>=8 chars enforced
client-side; admin supplies the new password). Delete routes through
TypedConfirmSheet ("DELETE") and pops the sheet on success.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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<void> show(BuildContext context, AdminUser user) =>
|
||||
showModalBottomSheet<void>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
builder: (_) => AdminUserEditSheet(user: user),
|
||||
);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||
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<void> _resetPassword(
|
||||
BuildContext context, AdminUsersController users) async {
|
||||
final controller = TextEditingController();
|
||||
final newPw = await showDialog<String>(
|
||||
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<void> _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')));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<FabledSwordTheme>()!;
|
||||
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<FabledSwordTheme>()!;
|
||||
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),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user