feat(flutter/admin): quarantine screen + typed-confirm sheet

ExpansionTile per aggregated quarantine row — collapsed shows track,
artist · album, and "{N} reports: {top reason}". Expanded reveals the
nested per-user reports with their reasons + optional notes.

Three-dot menu offers Resolve / Delete file / Delete via Lidarr; both
deletes route through TypedConfirmSheet which requires the user to
type "DELETE" before the action button enables.

Adds AdminQuarantineController with optimistic-removal + rollback,
mirroring AdminRequestsController's pattern.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-08 21:56:00 -04:00
parent bd1bcab12b
commit 3e905db906
5 changed files with 412 additions and 0 deletions
@@ -5,6 +5,7 @@ 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';
@@ -106,3 +107,38 @@ class AdminUsersController extends AsyncNotifier<List<AdminUser>> {
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);
@@ -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,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,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,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);
});
}