Files
minstrel/flutter_client/lib/admin/admin_users_screen.dart
T
bvandeusen 9f6ceb1731 feat(flutter/admin): users screen with users + invites sections
Single-scroll layout with two _SectionHeader-anchored blocks. Users
rows tap into AdminUserEditSheet. Invites row shows token (monospace)
with copy button, optional note, and a redeemed badge if applicable.

"Generate invite" opens a small dialog with an optional note field
(server hardcodes 24h TTL — admins can't customise expiry). On
success the new token is shown in a copy-once result dialog.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 21:57:53 -04:00

201 lines
6.5 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../shared/widgets/main_app_bar_actions.dart';
import '../theme/theme_extension.dart';
import 'admin_providers.dart';
import 'widgets/admin_user_edit_sheet.dart';
import 'widgets/admin_user_row.dart';
import 'widgets/invite_row.dart';
class AdminUsersScreen extends ConsumerWidget {
const AdminUsersScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
final users = ref.watch(adminUsersProvider);
final invites = ref.watch(adminInvitesProvider);
return Scaffold(
backgroundColor: fs.obsidian,
appBar: AppBar(
backgroundColor: fs.obsidian,
elevation: 0,
title: Text('Users', style: TextStyle(color: fs.parchment)),
actions: const [MainAppBarActions(currentRoute: '/admin/users')],
),
body: SafeArea(
child: RefreshIndicator(
onRefresh: () async {
ref.invalidate(adminUsersProvider);
ref.invalidate(adminInvitesProvider);
},
child: ListView(
children: [
const _SectionHeader(text: 'Users'),
users.when(
loading: () => const Padding(
padding: EdgeInsets.all(16),
child: Center(child: CircularProgressIndicator()),
),
error: (e, _) => Padding(
padding: const EdgeInsets.all(16),
child: Text('$e', style: TextStyle(color: fs.error)),
),
data: (rows) => Column(
children: rows
.map((u) => AdminUserRow(
key: Key('admin_user_row_${u.id}'),
user: u,
onTap: () => AdminUserEditSheet.show(context, u),
))
.toList(),
),
),
const Divider(),
_SectionHeader(
text: 'Invites',
trailing: TextButton.icon(
key: const Key('invite_generate_button'),
onPressed: () => _showGenerateInvite(context, ref),
icon: Icon(Icons.add, color: fs.parchment),
label: Text('Generate',
style: TextStyle(color: fs.parchment)),
),
),
invites.when(
loading: () => const Padding(
padding: EdgeInsets.all(16),
child: Center(child: CircularProgressIndicator()),
),
error: (e, _) => Padding(
padding: const EdgeInsets.all(16),
child: Text('$e', style: TextStyle(color: fs.error)),
),
data: (rows) => Column(
children: rows
.map((i) => InviteRow(
key: Key('invite_row_${i.token}'),
invite: i,
onRevoke: () => ref
.read(adminInvitesProvider.notifier)
.revoke(i.token),
))
.toList(),
),
),
],
),
),
),
);
}
Future<void> _showGenerateInvite(BuildContext context, WidgetRef ref) async {
final controller = TextEditingController();
final note = await showDialog<String?>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Generate invite'),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('Token expires in 24 hours.'),
const SizedBox(height: 12),
TextField(
controller: controller,
decoration: const InputDecoration(
labelText: 'Note (optional)',
helperText: 'e.g. "for alice"',
),
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx),
child: const Text('Cancel'),
),
TextButton(
onPressed: () => Navigator.pop(ctx, controller.text),
child: const Text('Generate'),
),
],
),
);
if (note == null || !context.mounted) return;
try {
final invite = await ref
.read(adminInvitesProvider.notifier)
.create(note: note.isEmpty ? null : note);
if (!context.mounted) return;
await showDialog<void>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Invite created'),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('Share this token with the new user:'),
const SizedBox(height: 8),
SelectableText(
invite.token,
style: const TextStyle(fontFamily: 'JetBrainsMono'),
),
],
),
actions: [
TextButton(
onPressed: () {
Clipboard.setData(ClipboardData(text: invite.token));
Navigator.pop(ctx);
},
child: const Text('Copy'),
),
TextButton(
onPressed: () => Navigator.pop(ctx),
child: const Text('Close'),
),
],
),
);
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(context)
.showSnackBar(SnackBar(content: Text('$e')));
}
}
}
}
class _SectionHeader extends StatelessWidget {
const _SectionHeader({required this.text, this.trailing});
final String text;
final Widget? trailing;
@override
Widget build(BuildContext context) {
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
return Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
child: Row(
children: [
Text(
text,
style: TextStyle(
color: fs.parchment,
fontFamily: 'Fraunces',
fontSize: 18,
),
),
const Spacer(),
if (trailing != null) trailing!,
],
),
);
}
}