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