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>
This commit is contained in:
2026-05-08 21:57:53 -04:00
parent afcaf56b9c
commit 9f6ceb1731
3 changed files with 354 additions and 0 deletions
@@ -0,0 +1,200 @@
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!,
],
),
);
}
}
@@ -0,0 +1,72 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../../models/invite.dart';
import '../../theme/theme_extension.dart';
class InviteRow extends StatelessWidget {
const InviteRow({
super.key,
required this.invite,
required this.onRevoke,
});
final Invite invite;
final VoidCallback onRevoke;
@override
Widget build(BuildContext context) {
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
return ListTile(
title: Row(
children: [
Expanded(
child: SelectableText(
invite.token,
style: TextStyle(
color: fs.parchment,
fontFamily: 'JetBrainsMono',
fontSize: 13,
),
),
),
IconButton(
icon: Icon(Icons.copy, color: fs.ash, size: 18),
tooltip: 'Copy',
onPressed: () =>
Clipboard.setData(ClipboardData(text: invite.token)),
),
],
),
subtitle: Wrap(
spacing: 8,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
Text('expires ${invite.expiresAt}',
style: TextStyle(color: fs.ash, fontSize: 12)),
if (invite.note != null)
Text('· ${invite.note}',
style: TextStyle(color: fs.ash, fontSize: 12)),
if (invite.isRedeemed)
Container(
padding:
const EdgeInsets.symmetric(horizontal: 6, vertical: 1),
decoration: BoxDecoration(
color: fs.ash,
borderRadius: BorderRadius.circular(6),
),
child: Text(
'redeemed',
style: TextStyle(color: fs.obsidian, fontSize: 10),
),
),
],
),
trailing: IconButton(
icon: Icon(Icons.delete_outline, color: fs.oxblood),
tooltip: 'Revoke',
onPressed: onRevoke,
),
);
}
}