Files
minstrel/flutter_client/lib/admin/admin_users_screen.dart
T
bvandeusen 835592f073 refactor(ui): Lucide migration unit 2 — Icons.* -> LucideIcons.* sweep (#60)
Mechanical sweep across 30 files: every Material Icons.* replaced with
the signed-off Lucide equivalent + a flutter_lucide import per file.
Zero Material Icons.* remain in lib/; no unused imports.

Judgment-call mappings: album->disc_3, library_music->library_big,
playlist_play->list_video, graphic_eq->audio_lines,
system_update->download, restore->archive_restore,
download_done->circle_check_big.

track_actions_sheet like menu row: collapsed `liked ? favorite :
favorite_border` to a single LucideIcons.heart (the row's Like/Unlike
text label conveys state). Icon-only LikeButton + the notification keep
the filled-vs-outline shape per the design decision.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 20:43:05 -04:00

202 lines
6.6 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter_lucide/flutter_lucide.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(LucideIcons.plus, 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!,
],
),
);
}
}