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
@@ -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),
),
],
),
],
),
),
);
}
}