Merge pull request 'feat(flutter): admin parity slice — requests, quarantine, users, invites' (#33) from dev into main

This commit was merged in pull request #33.
This commit is contained in:
2026-05-09 02:07:59 +00:00
33 changed files with 2157 additions and 27 deletions
@@ -0,0 +1,66 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../shared/widgets/main_app_bar_actions.dart';
import '../theme/theme_extension.dart';
import 'admin_providers.dart';
import 'widgets/admin_section_card.dart';
class AdminLandingScreen extends ConsumerWidget {
const AdminLandingScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
final counts = ref.watch(adminCountsProvider);
return Scaffold(
backgroundColor: fs.obsidian,
appBar: AppBar(
backgroundColor: fs.obsidian,
elevation: 0,
title: Text('Admin', style: TextStyle(color: fs.parchment)),
actions: const [MainAppBarActions(currentRoute: '/admin')],
),
body: SafeArea(
child: counts.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, _) => Center(
child: Text('$e', style: TextStyle(color: fs.error)),
),
data: (c) => RefreshIndicator(
onRefresh: () async => ref.refresh(adminCountsProvider.future),
child: ListView(
children: [
AdminSectionCard(
key: const Key('admin_card_requests'),
icon: Icons.inbox,
title: 'Requests',
subtitle: 'Approve or reject Lidarr requests',
count: c.requests,
onTap: () => context.push('/admin/requests'),
),
AdminSectionCard(
key: const Key('admin_card_quarantine'),
icon: Icons.warning_amber,
title: 'Quarantine',
subtitle: 'Resolve scan failures',
count: c.quarantine,
onTap: () => context.push('/admin/quarantine'),
),
AdminSectionCard(
key: const Key('admin_card_users'),
icon: Icons.people,
title: 'Users',
subtitle: 'Manage accounts and invites',
count: c.users,
onTap: () => context.push('/admin/users'),
),
],
),
),
),
),
);
}
}
@@ -0,0 +1,237 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../api/endpoints/admin_invites.dart';
import '../api/endpoints/admin_quarantine.dart';
import '../api/endpoints/admin_requests.dart';
import '../api/endpoints/admin_users.dart';
import '../library/library_providers.dart' show dioProvider;
import '../models/admin_quarantine_item.dart';
import '../models/admin_request.dart';
import '../models/admin_user.dart';
import '../models/invite.dart';
final adminRequestsApiProvider = FutureProvider<AdminRequestsApi>((ref) async {
return AdminRequestsApi(await ref.watch(dioProvider.future));
});
final adminQuarantineApiProvider =
FutureProvider<AdminQuarantineApi>((ref) async {
return AdminQuarantineApi(await ref.watch(dioProvider.future));
});
final adminUsersApiProvider = FutureProvider<AdminUsersApi>((ref) async {
return AdminUsersApi(await ref.watch(dioProvider.future));
});
final adminInvitesApiProvider = FutureProvider<AdminInvitesApi>((ref) async {
return AdminInvitesApi(await ref.watch(dioProvider.future));
});
class AdminCounts {
const AdminCounts({
required this.requests,
required this.quarantine,
required this.users,
});
final int requests;
final int quarantine;
final int users;
}
/// Fans out to the three list endpoints in parallel and rolls them up
/// into `{requests, quarantine, users}` for the landing screen badges.
final adminCountsProvider = FutureProvider<AdminCounts>((ref) async {
final requestsApi = await ref.watch(adminRequestsApiProvider.future);
final quarantineApi = await ref.watch(adminQuarantineApiProvider.future);
final usersApi = await ref.watch(adminUsersApiProvider.future);
final results = await Future.wait([
requestsApi.list(),
quarantineApi.list(),
usersApi.list(),
]);
return AdminCounts(
requests: (results[0] as List).length,
quarantine: (results[1] as List).length,
users: (results[2] as List).length,
);
});
class AdminRequestsController extends AsyncNotifier<List<AdminRequest>> {
@override
Future<List<AdminRequest>> build() async {
final api = await ref.watch(adminRequestsApiProvider.future);
return api.list();
}
Future<void> approve(String id) async {
await _decide(id, (api) => api.approve(id));
}
Future<void> reject(String id) async {
await _decide(id, (api) => api.reject(id));
}
Future<void> _decide(
String id,
Future<void> Function(AdminRequestsApi api) action,
) async {
final api = await ref.read(adminRequestsApiProvider.future);
final current = state.value ?? const <AdminRequest>[];
state = AsyncData(current.where((r) => r.id != id).toList());
try {
await action(api);
// Refresh landing badge.
ref.invalidate(adminCountsProvider);
} catch (e, st) {
state = AsyncData(current);
Error.throwWithStackTrace(e, st);
}
}
}
final adminRequestsProvider =
AsyncNotifierProvider<AdminRequestsController, List<AdminRequest>>(
AdminRequestsController.new);
/// Read side originally defined for the Requests screen's
/// requester-UUID → username join. Mutation methods (setAdmin,
/// setAutoApprove, resetPassword, delete) added alongside the Users
/// screen in Slice 3. resetPassword is admin-supplies; the server
/// has no auto-generation mode.
class AdminUsersController extends AsyncNotifier<List<AdminUser>> {
@override
Future<List<AdminUser>> build() async {
final api = await ref.watch(adminUsersApiProvider.future);
return api.list();
}
/// Optimistic flip of `is_admin` on the user row; rolls back on
/// server error (including the last-admin guard 4xx).
Future<void> setAdmin(String id, bool isAdmin) =>
_patch(id, (u) => _copy(u, isAdmin: isAdmin),
(api) => api.setAdmin(id, isAdmin));
Future<void> setAutoApprove(String id, bool autoApprove) =>
_patch(id, (u) => _copy(u, autoApproveRequests: autoApprove),
(api) => api.setAutoApprove(id, autoApprove));
Future<void> delete(String id) async {
final api = await ref.read(adminUsersApiProvider.future);
final current = state.value ?? const <AdminUser>[];
state = AsyncData(current.where((u) => u.id != id).toList());
try {
await api.delete(id);
ref.invalidate(adminCountsProvider);
} catch (e, st) {
state = AsyncData(current);
Error.throwWithStackTrace(e, st);
}
}
Future<void> resetPassword(String id, String newPassword) async {
final api = await ref.read(adminUsersApiProvider.future);
await api.resetPassword(id, newPassword);
}
Future<void> _patch(
String id,
AdminUser Function(AdminUser) mutate,
Future<void> Function(AdminUsersApi api) action,
) async {
final api = await ref.read(adminUsersApiProvider.future);
final current = state.value ?? const <AdminUser>[];
state = AsyncData([
for (final u in current) u.id == id ? mutate(u) : u,
]);
try {
await action(api);
} catch (e, st) {
state = AsyncData(current);
Error.throwWithStackTrace(e, st);
}
}
AdminUser _copy(AdminUser u, {bool? isAdmin, bool? autoApproveRequests}) =>
AdminUser(
id: u.id,
username: u.username,
displayName: u.displayName,
isAdmin: isAdmin ?? u.isAdmin,
autoApproveRequests: autoApproveRequests ?? u.autoApproveRequests,
createdAt: u.createdAt,
);
}
final adminUsersProvider =
AsyncNotifierProvider<AdminUsersController, List<AdminUser>>(
AdminUsersController.new);
class AdminQuarantineController
extends AsyncNotifier<List<AdminQuarantineItem>> {
@override
Future<List<AdminQuarantineItem>> build() async {
final api = await ref.watch(adminQuarantineApiProvider.future);
return api.list();
}
Future<void> resolve(String trackId) =>
_act(trackId, (api) => api.resolve(trackId));
Future<void> deleteFile(String trackId) =>
_act(trackId, (api) => api.deleteFile(trackId));
Future<void> deleteViaLidarr(String trackId) =>
_act(trackId, (api) => api.deleteViaLidarr(trackId));
Future<void> _act(
String trackId,
Future<void> Function(AdminQuarantineApi api) action,
) async {
final api = await ref.read(adminQuarantineApiProvider.future);
final current = state.value ?? const <AdminQuarantineItem>[];
state = AsyncData(current.where((q) => q.trackId != trackId).toList());
try {
await action(api);
ref.invalidate(adminCountsProvider);
} catch (e, st) {
state = AsyncData(current);
Error.throwWithStackTrace(e, st);
}
}
}
final adminQuarantineProvider = AsyncNotifierProvider<AdminQuarantineController,
List<AdminQuarantineItem>>(AdminQuarantineController.new);
class AdminInvitesController extends AsyncNotifier<List<Invite>> {
@override
Future<List<Invite>> build() async {
final api = await ref.watch(adminInvitesApiProvider.future);
return api.list();
}
/// Returns the freshly-minted invite so the screen can show the
/// token in a copy-once dialog. The new invite is also prepended
/// to the local list so it shows up without a refresh.
Future<Invite> create({String? note}) async {
final api = await ref.read(adminInvitesApiProvider.future);
final invite = await api.create(note: note);
final current = state.value ?? const <Invite>[];
state = AsyncData([invite, ...current]);
return invite;
}
Future<void> revoke(String token) async {
final api = await ref.read(adminInvitesApiProvider.future);
final current = state.value ?? const <Invite>[];
state = AsyncData(current.where((i) => i.token != token).toList());
try {
await api.revoke(token);
} catch (e, st) {
state = AsyncData(current);
Error.throwWithStackTrace(e, st);
}
}
}
final adminInvitesProvider =
AsyncNotifierProvider<AdminInvitesController, List<Invite>>(
AdminInvitesController.new);
@@ -0,0 +1,57 @@
import 'package:flutter/material.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_quarantine_row.dart';
class AdminQuarantineScreen extends ConsumerWidget {
const AdminQuarantineScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
final items = ref.watch(adminQuarantineProvider);
return Scaffold(
backgroundColor: fs.obsidian,
appBar: AppBar(
backgroundColor: fs.obsidian,
elevation: 0,
title: Text('Quarantine', style: TextStyle(color: fs.parchment)),
actions: const [MainAppBarActions(currentRoute: '/admin/quarantine')],
),
body: SafeArea(
child: items.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, _) =>
Center(child: Text('$e', style: TextStyle(color: fs.error))),
data: (rows) {
if (rows.isEmpty) {
return Center(
child: Text('No quarantined tracks.',
style: TextStyle(color: fs.ash)),
);
}
final notifier = ref.read(adminQuarantineProvider.notifier);
return RefreshIndicator(
onRefresh: () async =>
ref.refresh(adminQuarantineProvider.future),
child: ListView.builder(
itemCount: rows.length,
itemBuilder: (_, i) => AdminQuarantineRow(
key: Key('admin_quarantine_row_${rows[i].trackId}'),
item: rows[i],
onResolve: () => notifier.resolve(rows[i].trackId),
onDeleteFile: () => notifier.deleteFile(rows[i].trackId),
onDeleteViaLidarr: () =>
notifier.deleteViaLidarr(rows[i].trackId),
),
),
);
},
),
),
);
}
}
@@ -0,0 +1,71 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../models/admin_user.dart';
import '../shared/widgets/main_app_bar_actions.dart';
import '../theme/theme_extension.dart';
import 'admin_providers.dart';
import 'widgets/admin_request_row.dart';
class AdminRequestsScreen extends ConsumerWidget {
const AdminRequestsScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
final requests = ref.watch(adminRequestsProvider);
// Best-effort lookup for requester usernames. If the users provider
// hasn't loaded yet, valueOrNull is null and rows fall back to the
// UUID prefix; no blocking spinner.
final users = ref.watch(adminUsersProvider).value ?? const <AdminUser>[];
final usersById = {for (final u in users) u.id: u};
return Scaffold(
backgroundColor: fs.obsidian,
appBar: AppBar(
backgroundColor: fs.obsidian,
elevation: 0,
title: Text('Requests', style: TextStyle(color: fs.parchment)),
actions: const [MainAppBarActions(currentRoute: '/admin/requests')],
),
body: SafeArea(
child: requests.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, _) =>
Center(child: Text('$e', style: TextStyle(color: fs.error))),
data: (rows) {
if (rows.isEmpty) {
return Center(
child: Text('No pending requests.',
style: TextStyle(color: fs.ash)),
);
}
final notifier = ref.read(adminRequestsProvider.notifier);
return RefreshIndicator(
onRefresh: () async =>
ref.refresh(adminRequestsProvider.future),
child: ListView.builder(
itemCount: rows.length,
itemBuilder: (_, i) {
final req = rows[i];
final user = usersById[req.userId];
final display = user?.username ??
(req.userId.length >= 8
? req.userId.substring(0, 8)
: req.userId);
return AdminRequestRow(
key: Key('admin_request_row_${req.id}'),
request: req,
requesterDisplay: display,
onApprove: () => notifier.approve(req.id),
onReject: () => notifier.reject(req.id),
);
},
),
);
},
),
),
);
}
}
@@ -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,101 @@
import 'package:flutter/material.dart';
import '../../models/admin_quarantine_item.dart';
import '../../theme/theme_extension.dart';
import 'typed_confirm_sheet.dart';
class AdminQuarantineRow extends StatelessWidget {
const AdminQuarantineRow({
super.key,
required this.item,
required this.onResolve,
required this.onDeleteFile,
required this.onDeleteViaLidarr,
});
final AdminQuarantineItem item;
final VoidCallback onResolve;
final VoidCallback onDeleteFile;
final VoidCallback onDeleteViaLidarr;
@override
Widget build(BuildContext context) {
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
return ExpansionTile(
key: Key('admin_quarantine_tile_${item.trackId}'),
title: Text(
item.trackTitle,
style: TextStyle(
color: fs.parchment,
fontFamily: 'Fraunces',
fontSize: 16,
),
),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('${item.artistName} · ${item.albumTitle}',
style: TextStyle(color: fs.ash)),
Text(
'${item.reportCount} '
'${item.reportCount == 1 ? "report" : "reports"} · ${item.topReasonSummary}',
style: TextStyle(color: fs.error, fontSize: 12),
),
],
),
trailing: PopupMenuButton<String>(
key: Key('admin_quarantine_menu_${item.trackId}'),
icon: Icon(Icons.more_vert, color: fs.parchment),
onSelected: (action) async {
switch (action) {
case 'resolve':
onResolve();
break;
case 'delete_file':
if (await TypedConfirmSheet.show(
context,
title: 'Delete file?',
message:
'Permanently delete the local file for "${item.trackTitle}". '
'Cannot be undone.',
)) {
onDeleteFile();
}
break;
case 'delete_lidarr':
if (await TypedConfirmSheet.show(
context,
title: 'Delete via Lidarr?',
message:
'Ask Lidarr to delete the file for "${item.trackTitle}".',
)) {
onDeleteViaLidarr();
}
break;
}
},
itemBuilder: (_) => const [
PopupMenuItem(value: 'resolve', child: Text('Resolve')),
PopupMenuItem(value: 'delete_file', child: Text('Delete file')),
PopupMenuItem(
value: 'delete_lidarr', child: Text('Delete via Lidarr')),
],
),
childrenPadding: const EdgeInsets.only(left: 16, right: 16, bottom: 8),
children: [
for (final report in item.reports)
ListTile(
dense: true,
title: Text(
'${report.username}${report.reason}',
style: TextStyle(color: fs.parchment, fontSize: 13),
),
subtitle: report.notes != null
? Text(report.notes!,
style: TextStyle(color: fs.ash, fontSize: 12))
: null,
),
],
);
}
}
@@ -0,0 +1,75 @@
import 'package:flutter/material.dart';
import '../../models/admin_request.dart';
import '../../theme/theme_extension.dart';
class AdminRequestRow extends StatelessWidget {
const AdminRequestRow({
super.key,
required this.request,
required this.requesterDisplay,
required this.onApprove,
required this.onReject,
});
final AdminRequest request;
/// Pre-resolved username (or UUID-prefix fallback) for the requester.
final String requesterDisplay;
final VoidCallback onApprove;
final VoidCallback onReject;
@override
Widget build(BuildContext context) {
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
final subtitle =
'${request.kind} · ${request.artistName} · requested by $requesterDisplay';
return ListTile(
title: Text(
request.displayName,
style: TextStyle(
color: fs.parchment,
fontFamily: 'Fraunces',
fontSize: 16,
),
),
subtitle: Text(subtitle, style: TextStyle(color: fs.ash)),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
TextButton(
onPressed: onApprove,
style: TextButton.styleFrom(foregroundColor: fs.moss),
child: const Text('Approve'),
),
TextButton(
onPressed: () async {
final ok = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Reject request?'),
content: Text('Reject "${request.displayName}"?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: const Text('Cancel'),
),
TextButton(
onPressed: () => Navigator.pop(ctx, true),
style: TextButton.styleFrom(foregroundColor: fs.oxblood),
child: const Text('Reject'),
),
],
),
);
if (ok == true) onReject();
},
style: TextButton.styleFrom(foregroundColor: fs.oxblood),
child: const Text('Reject'),
),
],
),
);
}
}
@@ -0,0 +1,53 @@
import 'package:flutter/material.dart';
import '../../theme/theme_extension.dart';
class AdminSectionCard extends StatelessWidget {
const AdminSectionCard({
super.key,
required this.icon,
required this.title,
required this.subtitle,
required this.count,
required this.onTap,
});
final IconData icon;
final String title;
final String subtitle;
final int count;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
return Card(
color: fs.iron,
margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
child: ListTile(
leading: Icon(icon, color: fs.parchment),
title: Text(
title,
style: TextStyle(
color: fs.parchment,
fontFamily: 'Fraunces',
fontSize: 18,
),
),
subtitle: Text(subtitle, style: TextStyle(color: fs.ash)),
trailing: Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: fs.bronze,
borderRadius: BorderRadius.circular(12),
),
child: Text(
'$count',
style: TextStyle(color: fs.obsidian, fontWeight: FontWeight.w500),
),
),
onTap: onTap,
),
);
}
}
@@ -0,0 +1,167 @@
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),
),
);
}
}
@@ -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,
),
);
}
}
@@ -0,0 +1,124 @@
import 'package:flutter/material.dart';
import '../../theme/theme_extension.dart';
/// Modal bottom sheet that requires the user to type [confirmWord]
/// (default "DELETE") before the confirm button enables. Used for
/// destructive actions like quarantine delete-file / delete-via-Lidarr
/// and user delete.
///
/// Returns true if the user confirmed, false (or null → false) otherwise.
class TypedConfirmSheet extends StatefulWidget {
const TypedConfirmSheet({
super.key,
required this.title,
required this.message,
this.confirmWord = 'DELETE',
this.confirmLabel = 'Delete',
});
final String title;
final String message;
final String confirmWord;
final String confirmLabel;
static Future<bool> show(
BuildContext context, {
required String title,
required String message,
String confirmWord = 'DELETE',
String confirmLabel = 'Delete',
}) async {
final ok = await showModalBottomSheet<bool>(
context: context,
isScrollControlled: true,
builder: (_) => TypedConfirmSheet(
title: title,
message: message,
confirmWord: confirmWord,
confirmLabel: confirmLabel,
),
);
return ok ?? false;
}
@override
State<TypedConfirmSheet> createState() => _TypedConfirmSheetState();
}
class _TypedConfirmSheetState extends State<TypedConfirmSheet> {
final _controller = TextEditingController();
bool _enabled = false;
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
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(
widget.title,
style: TextStyle(
color: fs.parchment,
fontFamily: 'Fraunces',
fontSize: 20,
),
),
const SizedBox(height: 12),
Text(widget.message, style: TextStyle(color: fs.ash)),
const SizedBox(height: 16),
Text(
'Type ${widget.confirmWord} to confirm:',
style: TextStyle(color: fs.ash),
),
const SizedBox(height: 8),
TextField(
key: const Key('typed_confirm_input'),
controller: _controller,
autofocus: true,
style: TextStyle(color: fs.parchment, fontFamily: 'JetBrainsMono'),
decoration: const InputDecoration(border: OutlineInputBorder()),
onChanged: (v) =>
setState(() => _enabled = v == widget.confirmWord),
),
const SizedBox(height: 16),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('Cancel'),
),
const SizedBox(width: 8),
ElevatedButton(
key: const Key('typed_confirm_button'),
onPressed:
_enabled ? () => Navigator.pop(context, true) : null,
style: ElevatedButton.styleFrom(
backgroundColor: fs.oxblood,
foregroundColor: fs.parchment,
),
child: Text(widget.confirmLabel),
),
],
),
],
),
),
);
}
}
@@ -0,0 +1,32 @@
import 'package:dio/dio.dart';
import '../../models/invite.dart';
class AdminInvitesApi {
AdminInvitesApi(this._dio);
final Dio _dio;
/// GET /api/admin/invites → `{"invites": [...]}`. Envelope unwrapped.
Future<List<Invite>> list() async {
final r = await _dio.get<Map<String, dynamic>>('/api/admin/invites');
final raw = (r.data?['invites'] as List?) ?? const [];
return raw
.map((e) => Invite.fromJson((e as Map).cast<String, dynamic>()))
.toList(growable: false);
}
/// POST /api/admin/invites — body is optional `{"note": "..."}`.
/// Server hardcodes the 24h TTL; admins cannot configure expiry.
/// Returns the bare invite (not enveloped).
Future<Invite> create({String? note}) async {
final r = await _dio.post<Map<String, dynamic>>(
'/api/admin/invites',
data: {if (note != null && note.isNotEmpty) 'note': note},
);
return Invite.fromJson(r.data ?? const {});
}
Future<void> revoke(String token) async {
await _dio.delete<void>('/api/admin/invites/$token');
}
}
@@ -0,0 +1,31 @@
import 'package:dio/dio.dart';
import '../../models/admin_quarantine_item.dart';
class AdminQuarantineApi {
AdminQuarantineApi(this._dio);
final Dio _dio;
/// GET /api/admin/quarantine — flat list (no envelope) of aggregated
/// quarantine rows.
Future<List<AdminQuarantineItem>> list() async {
final r = await _dio.get<List<dynamic>>('/api/admin/quarantine');
final raw = r.data ?? const [];
return raw
.map((e) =>
AdminQuarantineItem.fromJson((e as Map).cast<String, dynamic>()))
.toList(growable: false);
}
Future<void> resolve(String trackId) async {
await _dio.post<void>('/api/admin/quarantine/$trackId/resolve');
}
Future<void> deleteFile(String trackId) async {
await _dio.post<void>('/api/admin/quarantine/$trackId/delete-file');
}
Future<void> deleteViaLidarr(String trackId) async {
await _dio.post<void>('/api/admin/quarantine/$trackId/delete-via-lidarr');
}
}
@@ -0,0 +1,26 @@
import 'package:dio/dio.dart';
import '../../models/admin_request.dart';
class AdminRequestsApi {
AdminRequestsApi(this._dio);
final Dio _dio;
/// GET /api/admin/requests — flat list of pending requests.
/// Server defaults to ?status=pending; we don't override.
Future<List<AdminRequest>> list() async {
final r = await _dio.get<List<dynamic>>('/api/admin/requests');
final raw = r.data ?? const [];
return raw
.map((e) => AdminRequest.fromJson((e as Map).cast<String, dynamic>()))
.toList(growable: false);
}
Future<void> approve(String id) async {
await _dio.post<void>('/api/admin/requests/$id/approve');
}
Future<void> reject(String id) async {
await _dio.post<void>('/api/admin/requests/$id/reject');
}
}
@@ -0,0 +1,47 @@
import 'package:dio/dio.dart';
import '../../models/admin_user.dart';
class AdminUsersApi {
AdminUsersApi(this._dio);
final Dio _dio;
/// GET /api/admin/users → `{"users": [...]}`. Envelope unwrapped here.
Future<List<AdminUser>> list() async {
final r = await _dio.get<Map<String, dynamic>>('/api/admin/users');
final raw = (r.data?['users'] as List?) ?? const [];
return raw
.map((e) => AdminUser.fromJson((e as Map).cast<String, dynamic>()))
.toList(growable: false);
}
Future<void> setAdmin(String id, bool isAdmin) async {
await _dio.put<void>(
'/api/admin/users/$id/admin',
data: {'is_admin': isAdmin},
);
}
/// Server's body field is `auto_approve` (NOT `auto_approve_requests`)
/// — different from the response field name. Verified against
/// internal/api/admin_users.go `adminAutoApproveReq` (May 2026).
Future<void> setAutoApprove(String id, bool autoApprove) async {
await _dio.put<void>(
'/api/admin/users/$id/auto-approve',
data: {'auto_approve': autoApprove},
);
}
/// Admin supplies the new password; server returns 204. There is no
/// server-generated-password mode.
Future<void> resetPassword(String id, String newPassword) async {
await _dio.post<void>(
'/api/admin/users/$id/reset-password',
data: {'password': newPassword},
);
}
Future<void> delete(String id) async {
await _dio.delete<void>('/api/admin/users/$id');
}
}
@@ -7,6 +7,7 @@ import '../api/endpoints/discover.dart';
import '../api/errors.dart';
import '../library/library_providers.dart' show dioProvider;
import '../models/lidarr.dart';
import '../shared/widgets/main_app_bar_actions.dart';
import '../theme/theme_extension.dart';
final _discoverApiProvider = FutureProvider<DiscoverApi>((ref) async {
@@ -87,6 +88,7 @@ class _DiscoverScreenState extends ConsumerState<DiscoverScreen> {
onPressed: () => context.pop(),
),
title: Text('Discover', style: TextStyle(color: fs.parchment)),
actions: const [MainAppBarActions(currentRoute: '/discover')],
),
body: Column(children: [
Padding(
+2 -27
View File
@@ -9,6 +9,7 @@ import '../models/artist.dart';
import '../models/track.dart';
import '../player/player_provider.dart';
import '../shared/widgets/connection_error_banner.dart';
import '../shared/widgets/main_app_bar_actions.dart';
import '../theme/theme_extension.dart';
import 'library_providers.dart';
import 'widgets/album_card.dart';
@@ -28,33 +29,7 @@ class HomeScreen extends ConsumerWidget {
backgroundColor: fs.obsidian,
elevation: 0,
title: Text('Minstrel', style: TextStyle(color: fs.parchment)),
actions: [
IconButton(
icon: Icon(Icons.library_music, color: fs.parchment),
tooltip: 'Library',
onPressed: () => context.push('/library'),
),
IconButton(
icon: Icon(Icons.queue_music, color: fs.parchment),
tooltip: 'Playlists',
onPressed: () => context.push('/playlists'),
),
IconButton(
icon: Icon(Icons.search, color: fs.parchment),
tooltip: 'Search',
onPressed: () => context.push('/search'),
),
IconButton(
icon: Icon(Icons.explore, color: fs.parchment),
tooltip: 'Discover',
onPressed: () => context.push('/discover'),
),
IconButton(
icon: Icon(Icons.settings, color: fs.parchment),
tooltip: 'Settings',
onPressed: () => context.push('/settings'),
),
],
actions: const [MainAppBarActions(currentRoute: '/home')],
),
body: SafeArea(
child: ref.watch(homeProvider).when(
@@ -15,6 +15,7 @@ import '../models/page.dart' as wire;
import '../models/quarantine_mine.dart';
import '../models/track.dart';
import '../player/player_provider.dart';
import '../shared/widgets/main_app_bar_actions.dart';
import '../theme/theme_extension.dart';
import 'widgets/album_card.dart';
import 'widgets/artist_card.dart';
@@ -90,6 +91,7 @@ class _LibraryScreenState extends ConsumerState<LibraryScreen>
backgroundColor: fs.obsidian,
elevation: 0,
title: Text('Library', style: TextStyle(color: fs.parchment)),
actions: const [MainAppBarActions(currentRoute: '/library')],
bottom: TabBar(
controller: _ctrl,
isScrollable: true,
@@ -0,0 +1,92 @@
/// One user's report under an aggregated quarantine row.
class AdminQuarantineReport {
const AdminQuarantineReport({
required this.userId,
required this.username,
required this.reason,
this.notes,
required this.createdAt,
});
final String userId;
final String username;
final String reason;
final String? notes;
final String createdAt;
factory AdminQuarantineReport.fromJson(Map<String, dynamic> j) =>
AdminQuarantineReport(
userId: j['user_id'] as String? ?? '',
username: j['username'] as String? ?? '',
reason: j['reason'] as String? ?? '',
notes: j['notes'] as String?,
createdAt: j['created_at'] as String? ?? '',
);
}
/// Mirrors `adminQueueRowView` from internal/api/admin_quarantine.go.
/// One row aggregates all reports against a single track, with
/// per-reason counts and the underlying per-user reports nested.
class AdminQuarantineItem {
const AdminQuarantineItem({
required this.trackId,
required this.trackTitle,
required this.artistName,
required this.albumTitle,
required this.albumId,
this.lidarrAlbumMbid,
required this.reportCount,
required this.latestAt,
required this.reasonCounts,
required this.reports,
});
final String trackId;
final String trackTitle;
final String artistName;
final String albumTitle;
final String albumId;
final String? lidarrAlbumMbid;
final int reportCount;
final String latestAt;
/// Map of reason → number of reports citing that reason.
final Map<String, int> reasonCounts;
final List<AdminQuarantineReport> reports;
/// One-line summary of the most-cited reason. Returns just the top
/// reason when only one reason exists, or `top (+N more)` when there
/// are additional distinct reasons.
String get topReasonSummary {
if (reasonCounts.isEmpty) return '';
final entries = reasonCounts.entries.toList()
..sort((a, b) => b.value.compareTo(a.value));
final top = entries.first.key;
return entries.length > 1
? '$top (+${entries.length - 1} more)'
: top;
}
factory AdminQuarantineItem.fromJson(Map<String, dynamic> j) {
final reasonCountsRaw =
(j['reason_counts'] as Map?)?.cast<String, dynamic>() ?? const {};
final reportsRaw = (j['reports'] as List?) ?? const [];
return AdminQuarantineItem(
trackId: j['track_id'] as String? ?? '',
trackTitle: j['track_title'] as String? ?? '',
artistName: j['artist_name'] as String? ?? '',
albumTitle: j['album_title'] as String? ?? '',
albumId: j['album_id'] as String? ?? '',
lidarrAlbumMbid: j['lidarr_album_mbid'] as String?,
reportCount: (j['report_count'] as int?) ?? 0,
latestAt: j['latest_at'] as String? ?? '',
reasonCounts:
reasonCountsRaw.map((k, v) => MapEntry(k, (v as int?) ?? 0)),
reports: reportsRaw
.map((e) =>
AdminQuarantineReport.fromJson((e as Map).cast<String, dynamic>()))
.toList(growable: false),
);
}
}
@@ -0,0 +1,64 @@
/// Mirrors `requestView` from internal/api/requests.go. Server returns
/// these as a flat list (no envelope) from GET /api/admin/requests.
///
/// Note the requester is exposed only as a UUID (`user_id`) — the
/// response does not include the username. The Flutter Requests screen
/// joins client-side against the AdminUser list for display.
class AdminRequest {
const AdminRequest({
required this.id,
required this.userId,
required this.status,
required this.kind,
required this.artistName,
this.albumTitle,
this.trackTitle,
required this.requestedAt,
this.decidedAt,
this.notes,
required this.importedAlbumCount,
required this.importedTrackCount,
});
final String id;
final String userId;
/// One of: pending, approved, rejected, completed, failed.
final String status;
/// One of: artist, album, track.
final String kind;
final String artistName;
final String? albumTitle;
final String? trackTitle;
final String requestedAt;
final String? decidedAt;
final String? notes;
final int importedAlbumCount;
final int importedTrackCount;
/// Display label depending on the kind of request — what the user
/// asked for. For an album request it's the album title; for an
/// artist request it's the artist name; etc.
String get displayName => switch (kind) {
'album' => albumTitle ?? artistName,
'track' => trackTitle ?? artistName,
_ => artistName,
};
factory AdminRequest.fromJson(Map<String, dynamic> j) => AdminRequest(
id: j['id'] as String? ?? '',
userId: j['user_id'] as String? ?? '',
status: j['status'] as String? ?? 'pending',
kind: j['kind'] as String? ?? 'artist',
artistName: j['artist_name'] as String? ?? '',
albumTitle: j['album_title'] as String?,
trackTitle: j['track_title'] as String?,
requestedAt: j['requested_at'] as String? ?? '',
decidedAt: j['decided_at'] as String?,
notes: j['notes'] as String?,
importedAlbumCount: (j['imported_album_count'] as int?) ?? 0,
importedTrackCount: (j['imported_track_count'] as int?) ?? 0,
);
}
+33
View File
@@ -0,0 +1,33 @@
/// Mirrors `adminUserView` from internal/api/admin_users.go. Distinct
/// from MyProfile because admin endpoints expose `auto_approve_requests`
/// and the canonical `created_at` that the user-scoped /me response
/// intentionally omits.
///
/// The list endpoint wraps these in `{"users": [...]}`; the parsing of
/// the envelope is done in `AdminUsersApi`, not here.
class AdminUser {
const AdminUser({
required this.id,
required this.username,
this.displayName,
required this.isAdmin,
required this.autoApproveRequests,
required this.createdAt,
});
final String id;
final String username;
final String? displayName;
final bool isAdmin;
final bool autoApproveRequests;
final String createdAt;
factory AdminUser.fromJson(Map<String, dynamic> j) => AdminUser(
id: j['id'] as String? ?? '',
username: j['username'] as String? ?? '',
displayName: j['display_name'] as String?,
isAdmin: j['is_admin'] as bool? ?? false,
autoApproveRequests: j['auto_approve_requests'] as bool? ?? false,
createdAt: j['created_at'] as String? ?? '',
);
}
+40
View File
@@ -0,0 +1,40 @@
/// Mirrors `inviteResp` from internal/api/admin_invites.go. The list
/// endpoint wraps these in `{"invites": [...]}`; the create endpoint
/// returns a single bare invite. Envelope parsing is done in
/// `AdminInvitesApi`, not here.
///
/// `invitedBy` is the UUID of the inviting admin (not their username);
/// the server doesn't currently denormalize it. Likewise `redeemedBy`.
/// TTL is hardcoded server-side at 24h, so admins can't customise the
/// expiry — only the optional `note`.
class Invite {
const Invite({
required this.token,
required this.invitedBy,
this.note,
required this.createdAt,
required this.expiresAt,
this.redeemedAt,
this.redeemedBy,
});
final String token;
final String invitedBy;
final String? note;
final String createdAt;
final String expiresAt;
final String? redeemedAt;
final String? redeemedBy;
bool get isRedeemed => redeemedAt != null;
factory Invite.fromJson(Map<String, dynamic> j) => Invite(
token: j['token'] as String? ?? '',
invitedBy: j['invited_by'] as String? ?? '',
note: j['note'] as String?,
createdAt: j['created_at'] as String? ?? '',
expiresAt: j['expires_at'] as String? ?? '',
redeemedAt: j['redeemed_at'] as String?,
redeemedBy: j['redeemed_by'] as String?,
);
}
@@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../models/playlist.dart';
import '../shared/widgets/main_app_bar_actions.dart';
import '../theme/theme_extension.dart';
import 'playlists_provider.dart';
@@ -22,6 +23,7 @@ class PlaylistsListScreen extends ConsumerWidget {
backgroundColor: fs.obsidian,
elevation: 0,
title: Text('Playlists', style: TextStyle(color: fs.parchment)),
actions: const [MainAppBarActions(currentRoute: '/playlists')],
),
body: list.when(
loading: () => const Center(child: CircularProgressIndicator()),
@@ -7,6 +7,7 @@ import '../library/widgets/artist_card.dart';
import '../library/widgets/track_row.dart';
import '../models/search_response.dart';
import '../player/player_provider.dart';
import '../shared/widgets/main_app_bar_actions.dart';
import '../theme/theme_extension.dart';
import 'search_provider.dart';
@@ -73,6 +74,7 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
_focus.requestFocus();
},
),
const MainAppBarActions(currentRoute: '/search'),
],
),
body: results.when(
@@ -7,6 +7,7 @@ import '../api/endpoints/settings.dart';
import '../api/errors.dart';
import '../library/library_providers.dart' show dioProvider;
import '../models/my_profile.dart';
import '../shared/widgets/main_app_bar_actions.dart';
import '../theme/theme_extension.dart';
final _settingsApiProvider = FutureProvider<SettingsApi>((ref) async {
@@ -37,6 +38,7 @@ class SettingsScreen extends ConsumerWidget {
onPressed: () => context.pop(),
),
title: Text('Settings', style: TextStyle(color: fs.parchment)),
actions: const [MainAppBarActions(currentRoute: '/settings')],
),
body: ListView(
padding: const EdgeInsets.symmetric(vertical: 8),
@@ -46,6 +48,7 @@ class SettingsScreen extends ConsumerWidget {
_PasswordSection(),
_Divider(),
_ListenBrainzSection(),
_AdminSection(),
SizedBox(height: 96),
],
),
@@ -85,6 +88,44 @@ class _SectionHeader extends StatelessWidget {
}
}
/// Renders an "Admin" entry only when the current profile has
/// `is_admin = true`. Returns an empty SizedBox otherwise so the
/// const-children layout above stays valid.
class _AdminSection extends ConsumerWidget {
const _AdminSection();
@override
Widget build(BuildContext context, WidgetRef ref) {
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
final profile = ref.watch(_profileProvider).value;
if (profile == null || !profile.isAdmin) return const SizedBox.shrink();
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const _Divider(),
ListTile(
key: const Key('settings_admin_card'),
leading: Icon(Icons.shield, color: fs.parchment),
title: Text(
'Admin',
style: TextStyle(
color: fs.parchment,
fontFamily: 'Fraunces',
fontSize: 18,
),
),
subtitle: Text(
'Manage requests, quarantine, and users',
style: TextStyle(color: fs.ash),
),
trailing: Icon(Icons.chevron_right, color: fs.ash),
onTap: () => context.push('/admin'),
),
],
);
}
}
class _ProfileSection extends ConsumerStatefulWidget {
const _ProfileSection();
@override
+11
View File
@@ -17,6 +17,10 @@ import '../playlists/playlist_detail_screen.dart';
import '../playlists/playlists_list_screen.dart';
import '../search/search_screen.dart';
import '../settings/settings_screen.dart';
import '../admin/admin_landing_screen.dart';
import '../admin/admin_requests_screen.dart';
import '../admin/admin_quarantine_screen.dart';
import '../admin/admin_users_screen.dart';
import 'widgets/version_gate.dart';
/// Exposed as a Provider so its single argument is a real `Ref` (the
@@ -39,6 +43,9 @@ GoRouter buildRouter(Ref ref) {
if (user != null && (loc == '/login' || loc == '/server-url')) {
return '/home';
}
if (loc.startsWith('/admin') && !user!.isAdmin) {
return '/home';
}
return null;
},
routes: [
@@ -68,6 +75,10 @@ GoRouter buildRouter(Ref ref) {
path: '/playlists/:id',
builder: (_, s) => PlaylistDetailScreen(id: s.pathParameters['id']!),
),
GoRoute(path: '/admin', builder: (_, __) => const AdminLandingScreen()),
GoRoute(path: '/admin/requests', builder: (_, __) => const AdminRequestsScreen()),
GoRoute(path: '/admin/quarantine', builder: (_, __) => const AdminQuarantineScreen()),
GoRoute(path: '/admin/users', builder: (_, __) => const AdminUsersScreen()),
],
),
],
@@ -0,0 +1,63 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../auth/auth_provider.dart';
import '../../theme/theme_extension.dart';
/// Shared AppBar `actions` for top-level screens. Renders Home / Library /
/// Search as primary icons (suppressing the icon for [currentRoute]) plus
/// a kebab overflow with Playlists / Discover / Settings and (for admins)
/// Admin.
class MainAppBarActions extends ConsumerWidget {
const MainAppBarActions({super.key, required this.currentRoute});
final String currentRoute;
@override
Widget build(BuildContext context, WidgetRef ref) {
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
final user = ref.watch(authControllerProvider).value;
final isAdmin = user?.isAdmin ?? false;
return Row(
mainAxisSize: MainAxisSize.min,
children: [
if (currentRoute != '/home')
IconButton(
key: const Key('app_bar_home'),
icon: Icon(Icons.home, color: fs.parchment),
tooltip: 'Home',
onPressed: () => context.go('/home'),
),
if (currentRoute != '/library')
IconButton(
key: const Key('app_bar_library'),
icon: Icon(Icons.library_music, color: fs.parchment),
tooltip: 'Library',
onPressed: () => context.push('/library'),
),
if (currentRoute != '/search')
IconButton(
key: const Key('app_bar_search'),
icon: Icon(Icons.search, color: fs.parchment),
tooltip: 'Search',
onPressed: () => context.push('/search'),
),
PopupMenuButton<String>(
key: const Key('app_bar_overflow'),
icon: Icon(Icons.more_vert, color: fs.parchment),
tooltip: 'More',
onSelected: (route) => context.push(route),
itemBuilder: (_) => [
const PopupMenuItem(value: '/playlists', child: Text('Playlists')),
const PopupMenuItem(value: '/discover', child: Text('Discover')),
const PopupMenuItem(value: '/settings', child: Text('Settings')),
if (isAdmin)
const PopupMenuItem(value: '/admin', child: Text('Admin')),
],
),
],
);
}
}
@@ -0,0 +1,46 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:minstrel/admin/admin_landing_screen.dart';
import 'package:minstrel/admin/admin_providers.dart';
import 'package:minstrel/auth/auth_provider.dart';
import 'package:minstrel/models/user.dart';
import 'package:minstrel/theme/theme_data.dart';
class _StubAuth extends AuthController {
@override
Future<User?> build() async =>
const User(id: 'u1', username: 'admin', isAdmin: true);
}
void main() {
testWidgets('renders three section cards with counts from provider',
(t) async {
await t.pumpWidget(
ProviderScope(
overrides: [
authControllerProvider.overrideWith(() => _StubAuth()),
adminCountsProvider.overrideWith(
(_) async => const AdminCounts(
requests: 3,
quarantine: 1,
users: 5,
),
),
],
child: MaterialApp(
theme: buildThemeData(),
home: const AdminLandingScreen(),
),
),
);
await t.pumpAndSettle();
expect(find.byKey(const Key('admin_card_requests')), findsOneWidget);
expect(find.byKey(const Key('admin_card_quarantine')), findsOneWidget);
expect(find.byKey(const Key('admin_card_users')), findsOneWidget);
expect(find.text('3'), findsOneWidget);
expect(find.text('1'), findsOneWidget);
expect(find.text('5'), findsOneWidget);
});
}
@@ -0,0 +1,94 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:minstrel/admin/admin_providers.dart';
import 'package:minstrel/admin/admin_quarantine_screen.dart';
import 'package:minstrel/auth/auth_provider.dart';
import 'package:minstrel/models/admin_quarantine_item.dart';
import 'package:minstrel/models/user.dart';
import 'package:minstrel/theme/theme_data.dart';
class _StubAuth extends AuthController {
@override
Future<User?> build() async =>
const User(id: 'u1', username: 'admin', isAdmin: true);
}
class _StubQuarantine extends AdminQuarantineController {
_StubQuarantine(this._initial);
final List<AdminQuarantineItem> _initial;
@override
Future<List<AdminQuarantineItem>> build() async => _initial;
}
const _item = AdminQuarantineItem(
trackId: 't1',
trackTitle: 'Bad Track',
artistName: 'Some Artist',
albumTitle: 'Some Album',
albumId: 'al1',
lidarrAlbumMbid: null,
reportCount: 2,
latestAt: '2026-05-08T00:00:00Z',
reasonCounts: {'wrong_tags': 1, 'bad_rip': 1},
reports: [
AdminQuarantineReport(
userId: 'u2',
username: 'alice',
reason: 'wrong_tags',
notes: null,
createdAt: '2026-05-08T00:00:00Z',
),
AdminQuarantineReport(
userId: 'u3',
username: 'bob',
reason: 'bad_rip',
notes: 'cracks at 2:13',
createdAt: '2026-05-08T00:01:00Z',
),
],
);
Widget _harness(List<AdminQuarantineItem> rows) => ProviderScope(
overrides: [
authControllerProvider.overrideWith(() => _StubAuth()),
adminQuarantineProvider.overrideWith(() => _StubQuarantine(rows)),
],
child: MaterialApp(
theme: buildThemeData(),
home: const AdminQuarantineScreen(),
),
);
void main() {
testWidgets('empty state', (t) async {
await t.pumpWidget(_harness(const []));
await t.pumpAndSettle();
expect(find.text('No quarantined tracks.'), findsOneWidget);
});
testWidgets('row renders aggregate summary + 3-dot menu has three actions',
(t) async {
await t.pumpWidget(_harness(const [_item]));
await t.pumpAndSettle();
expect(find.byKey(const Key('admin_quarantine_tile_t1')), findsOneWidget);
expect(find.text('Bad Track'), findsOneWidget);
expect(find.textContaining('2 reports'), findsOneWidget);
await t.tap(find.byKey(const Key('admin_quarantine_menu_t1')));
await t.pumpAndSettle();
expect(find.text('Resolve'), findsOneWidget);
expect(find.text('Delete file'), findsOneWidget);
expect(find.text('Delete via Lidarr'), findsOneWidget);
});
testWidgets('expanding tile reveals per-user reports', (t) async {
await t.pumpWidget(_harness(const [_item]));
await t.pumpAndSettle();
await t.tap(find.byKey(const Key('admin_quarantine_tile_t1')));
await t.pumpAndSettle();
expect(find.textContaining('alice — wrong_tags'), findsOneWidget);
expect(find.textContaining('bob — bad_rip'), findsOneWidget);
expect(find.text('cracks at 2:13'), findsOneWidget);
});
}
@@ -0,0 +1,94 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:minstrel/admin/admin_providers.dart';
import 'package:minstrel/admin/admin_requests_screen.dart';
import 'package:minstrel/auth/auth_provider.dart';
import 'package:minstrel/models/admin_request.dart';
import 'package:minstrel/models/admin_user.dart';
import 'package:minstrel/models/user.dart';
import 'package:minstrel/theme/theme_data.dart';
class _StubAuth extends AuthController {
@override
Future<User?> build() async =>
const User(id: 'u1', username: 'admin', isAdmin: true);
}
class _StubRequests extends AdminRequestsController {
_StubRequests(this._initial);
final List<AdminRequest> _initial;
@override
Future<List<AdminRequest>> build() async => _initial;
}
class _StubUsers extends AdminUsersController {
_StubUsers(this._initial);
final List<AdminUser> _initial;
@override
Future<List<AdminUser>> build() async => _initial;
}
const _row = AdminRequest(
id: 'r1',
userId: 'user-uuid-1',
status: 'pending',
kind: 'album',
artistName: 'Some Artist',
albumTitle: 'Test Album',
trackTitle: null,
requestedAt: '2026-05-08T00:00:00Z',
decidedAt: null,
notes: null,
importedAlbumCount: 0,
importedTrackCount: 0,
);
const _alice = AdminUser(
id: 'user-uuid-1',
username: 'alice',
displayName: null,
isAdmin: false,
autoApproveRequests: false,
createdAt: '2026-05-01T00:00:00Z',
);
Widget _harness({
required List<AdminRequest> requests,
required List<AdminUser> users,
}) =>
ProviderScope(
overrides: [
authControllerProvider.overrideWith(() => _StubAuth()),
adminRequestsProvider.overrideWith(() => _StubRequests(requests)),
adminUsersProvider.overrideWith(() => _StubUsers(users)),
],
child: MaterialApp(
theme: buildThemeData(),
home: const AdminRequestsScreen(),
),
);
void main() {
testWidgets('renders empty state when no requests', (t) async {
await t.pumpWidget(_harness(requests: const [], users: const []));
await t.pumpAndSettle();
expect(find.text('No pending requests.'), findsOneWidget);
});
testWidgets('renders row with display name + joined requester username',
(t) async {
await t.pumpWidget(_harness(requests: const [_row], users: const [_alice]));
await t.pumpAndSettle();
expect(find.byKey(const Key('admin_request_row_r1')), findsOneWidget);
expect(find.text('Test Album'), findsOneWidget);
expect(find.textContaining('requested by alice'), findsOneWidget);
});
testWidgets('falls back to uuid prefix when username unknown', (t) async {
await t.pumpWidget(_harness(requests: const [_row], users: const []));
await t.pumpAndSettle();
expect(find.textContaining('requested by user-uui'), findsOneWidget);
});
}
@@ -0,0 +1,82 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:minstrel/admin/admin_providers.dart';
import 'package:minstrel/admin/admin_users_screen.dart';
import 'package:minstrel/auth/auth_provider.dart';
import 'package:minstrel/models/admin_user.dart';
import 'package:minstrel/models/invite.dart';
import 'package:minstrel/models/user.dart';
import 'package:minstrel/theme/theme_data.dart';
class _StubAuth extends AuthController {
@override
Future<User?> build() async =>
const User(id: 'u1', username: 'admin', isAdmin: true);
}
class _StubUsers extends AdminUsersController {
_StubUsers(this._initial);
final List<AdminUser> _initial;
@override
Future<List<AdminUser>> build() async => _initial;
}
class _StubInvites extends AdminInvitesController {
_StubInvites(this._initial);
final List<Invite> _initial;
@override
Future<List<Invite>> build() async => _initial;
}
const _userRow = AdminUser(
id: 'u2',
username: 'alice',
displayName: null,
isAdmin: false,
autoApproveRequests: true,
createdAt: '2026-05-01T00:00:00Z',
);
const _inviteRow = Invite(
token: 'INV-ABC123',
invitedBy: 'u1',
note: 'for alice',
createdAt: '2026-05-08T00:00:00Z',
expiresAt: '2026-05-09T00:00:00Z',
);
Widget _harness({
required List<AdminUser> users,
required List<Invite> invites,
}) =>
ProviderScope(
overrides: [
authControllerProvider.overrideWith(() => _StubAuth()),
adminUsersProvider.overrideWith(() => _StubUsers(users)),
adminInvitesProvider.overrideWith(() => _StubInvites(invites)),
],
child: MaterialApp(
theme: buildThemeData(),
home: const AdminUsersScreen(),
),
);
void main() {
testWidgets('renders user rows with badges', (t) async {
await t.pumpWidget(_harness(users: const [_userRow], invites: const []));
await t.pumpAndSettle();
expect(find.byKey(const Key('admin_user_row_u2')), findsOneWidget);
expect(find.text('alice'), findsOneWidget);
expect(find.text('auto-approve'), findsOneWidget);
});
testWidgets('renders invite rows + Generate button', (t) async {
await t.pumpWidget(_harness(users: const [], invites: const [_inviteRow]));
await t.pumpAndSettle();
expect(find.byKey(const Key('invite_row_INV-ABC123')), findsOneWidget);
expect(find.byKey(const Key('invite_generate_button')), findsOneWidget);
expect(find.textContaining('for alice'), findsOneWidget);
});
}
@@ -0,0 +1,70 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:minstrel/auth/auth_provider.dart';
import 'package:minstrel/models/user.dart';
import 'package:minstrel/shared/widgets/main_app_bar_actions.dart';
import 'package:minstrel/theme/theme_data.dart';
class _StubAuth extends AuthController {
_StubAuth(this._user);
final User? _user;
@override
Future<User?> build() async => _user;
}
Widget _harness({required User? user, required String currentRoute}) {
return ProviderScope(
overrides: [
authControllerProvider.overrideWith(() => _StubAuth(user)),
],
child: MaterialApp(
theme: buildThemeData(),
home: Scaffold(
appBar: AppBar(
actions: [MainAppBarActions(currentRoute: currentRoute)],
),
),
),
);
}
void main() {
const adminUser = User(id: 'u1', username: 'admin', isAdmin: true);
const regularUser = User(id: 'u2', username: 'alice', isAdmin: false);
testWidgets('renders Library + Search primary icons + kebab on /home',
(t) async {
await t.pumpWidget(_harness(user: regularUser, currentRoute: '/home'));
await t.pumpAndSettle();
expect(find.byKey(const Key('app_bar_library')), findsOneWidget);
expect(find.byKey(const Key('app_bar_search')), findsOneWidget);
expect(find.byKey(const Key('app_bar_home')), findsNothing);
expect(find.byKey(const Key('app_bar_overflow')), findsOneWidget);
});
testWidgets('suppresses Library icon when currentRoute is /library',
(t) async {
await t.pumpWidget(_harness(user: regularUser, currentRoute: '/library'));
await t.pumpAndSettle();
expect(find.byKey(const Key('app_bar_library')), findsNothing);
expect(find.byKey(const Key('app_bar_home')), findsOneWidget);
});
testWidgets('overflow includes Admin only for admin users', (t) async {
await t.pumpWidget(_harness(user: adminUser, currentRoute: '/home'));
await t.pumpAndSettle();
await t.tap(find.byKey(const Key('app_bar_overflow')));
await t.pumpAndSettle();
expect(find.text('Admin'), findsOneWidget);
});
testWidgets('overflow omits Admin for non-admin users', (t) async {
await t.pumpWidget(_harness(user: regularUser, currentRoute: '/home'));
await t.pumpAndSettle();
await t.tap(find.byKey(const Key('app_bar_overflow')));
await t.pumpAndSettle();
expect(find.text('Admin'), findsNothing);
});
}