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);