From 3e905db906a848f9334ab4a254e2d0d67f51ee86 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Fri, 8 May 2026 21:56:00 -0400 Subject: [PATCH] feat(flutter/admin): quarantine screen + typed-confirm sheet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- flutter_client/lib/admin/admin_providers.dart | 36 +++++ .../lib/admin/admin_quarantine_screen.dart | 57 ++++++++ .../admin/widgets/admin_quarantine_row.dart | 101 ++++++++++++++ .../admin/widgets/typed_confirm_sheet.dart | 124 ++++++++++++++++++ .../admin/admin_quarantine_screen_test.dart | 94 +++++++++++++ 5 files changed, 412 insertions(+) create mode 100644 flutter_client/lib/admin/admin_quarantine_screen.dart create mode 100644 flutter_client/lib/admin/widgets/admin_quarantine_row.dart create mode 100644 flutter_client/lib/admin/widgets/typed_confirm_sheet.dart create mode 100644 flutter_client/test/admin/admin_quarantine_screen_test.dart diff --git a/flutter_client/lib/admin/admin_providers.dart b/flutter_client/lib/admin/admin_providers.dart index fa65419b..94ffefbe 100644 --- a/flutter_client/lib/admin/admin_providers.dart +++ b/flutter_client/lib/admin/admin_providers.dart @@ -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> { final adminUsersProvider = AsyncNotifierProvider>( AdminUsersController.new); + +class AdminQuarantineController + extends AsyncNotifier> { + @override + Future> build() async { + final api = await ref.watch(adminQuarantineApiProvider.future); + return api.list(); + } + + Future resolve(String trackId) => + _act(trackId, (api) => api.resolve(trackId)); + Future deleteFile(String trackId) => + _act(trackId, (api) => api.deleteFile(trackId)); + Future deleteViaLidarr(String trackId) => + _act(trackId, (api) => api.deleteViaLidarr(trackId)); + + Future _act( + String trackId, + Future Function(AdminQuarantineApi api) action, + ) async { + final api = await ref.read(adminQuarantineApiProvider.future); + final current = state.value ?? const []; + 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.new); diff --git a/flutter_client/lib/admin/admin_quarantine_screen.dart b/flutter_client/lib/admin/admin_quarantine_screen.dart new file mode 100644 index 00000000..d9c55a9d --- /dev/null +++ b/flutter_client/lib/admin/admin_quarantine_screen.dart @@ -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()!; + 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), + ), + ), + ); + }, + ), + ), + ); + } +} diff --git a/flutter_client/lib/admin/widgets/admin_quarantine_row.dart b/flutter_client/lib/admin/widgets/admin_quarantine_row.dart new file mode 100644 index 00000000..6e81e7e7 --- /dev/null +++ b/flutter_client/lib/admin/widgets/admin_quarantine_row.dart @@ -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()!; + 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( + 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, + ), + ], + ); + } +} diff --git a/flutter_client/lib/admin/widgets/typed_confirm_sheet.dart b/flutter_client/lib/admin/widgets/typed_confirm_sheet.dart new file mode 100644 index 00000000..2904e7ae --- /dev/null +++ b/flutter_client/lib/admin/widgets/typed_confirm_sheet.dart @@ -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 show( + BuildContext context, { + required String title, + required String message, + String confirmWord = 'DELETE', + String confirmLabel = 'Delete', + }) async { + final ok = await showModalBottomSheet( + context: context, + isScrollControlled: true, + builder: (_) => TypedConfirmSheet( + title: title, + message: message, + confirmWord: confirmWord, + confirmLabel: confirmLabel, + ), + ); + return ok ?? false; + } + + @override + State createState() => _TypedConfirmSheetState(); +} + +class _TypedConfirmSheetState extends State { + final _controller = TextEditingController(); + bool _enabled = false; + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final fs = Theme.of(context).extension()!; + 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), + ), + ], + ), + ], + ), + ), + ); + } +} diff --git a/flutter_client/test/admin/admin_quarantine_screen_test.dart b/flutter_client/test/admin/admin_quarantine_screen_test.dart new file mode 100644 index 00000000..2ec85b9e --- /dev/null +++ b/flutter_client/test/admin/admin_quarantine_screen_test.dart @@ -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 build() async => + const User(id: 'u1', username: 'admin', isAdmin: true); +} + +class _StubQuarantine extends AdminQuarantineController { + _StubQuarantine(this._initial); + final List _initial; + @override + Future> 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 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); + }); +}