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:
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -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? ?? '',
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user