summaryrefslogtreecommitdiff
path: root/player-android/lib/screens/admin_trash_screen.dart
diff options
context:
space:
mode:
Diffstat (limited to 'player-android/lib/screens/admin_trash_screen.dart')
-rw-r--r--player-android/lib/screens/admin_trash_screen.dart421
1 files changed, 421 insertions, 0 deletions
diff --git a/player-android/lib/screens/admin_trash_screen.dart b/player-android/lib/screens/admin_trash_screen.dart
new file mode 100644
index 0000000..1b63596
--- /dev/null
+++ b/player-android/lib/screens/admin_trash_screen.dart
@@ -0,0 +1,421 @@
+import 'package:flutter/material.dart';
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+
+import '../models/models.dart';
+import '../providers/api_client_provider.dart';
+import '../utils/error_mappers.dart';
+
+/// Admin-only screen that lists soft-deleted media items (the trash).
+///
+/// Design notes:
+/// - Items are loaded from GET /api/v1/admin/trash via [listTrash].
+/// - Restore action calls [restoreMedia]; optimistic UI removes the item
+/// from the trash list immediately and reverts on error.
+/// - Hard-delete shows a confirmation dialog before calling [deleteMedia]
+/// (permanent purge from the perspective of this UI, even though the
+/// underlying API still performs a soft-delete — the server's GC worker
+/// completes the physical removal).
+/// - A generation counter prevents stale loads from overwriting a newer
+/// refresh that started while the previous was still in flight.
+/// - All async continuations guard on [mounted] to prevent setState/context
+/// calls after widget disposal.
+class AdminTrashScreen extends ConsumerStatefulWidget {
+ const AdminTrashScreen({super.key});
+
+ @override
+ ConsumerState<AdminTrashScreen> createState() => _AdminTrashScreenState();
+}
+
+class _AdminTrashScreenState extends ConsumerState<AdminTrashScreen> {
+ // Null while the initial load is in flight.
+ List<Media>? _items;
+
+ // Non-null when the last load attempt failed.
+ String? _error;
+
+ // True while a load is in flight (initial or refresh).
+ bool _isLoading = false;
+
+ // Generation counter: incremented on every load call so stale completions
+ // from a previous request are silently discarded when they arrive.
+ int _generation = 0;
+
+ @override
+ void initState() {
+ super.initState();
+ // Defer until after first frame so provider overrides in tests are applied.
+ WidgetsBinding.instance.addPostFrameCallback((_) => _load());
+ }
+
+ // ---------------------------------------------------------------------------
+ // Data loading
+ // ---------------------------------------------------------------------------
+
+ /// Fetches the current trash list and updates local state.
+ Future<void> _load() async {
+ if (!mounted) return;
+ final generation = ++_generation;
+
+ setState(() {
+ _isLoading = true;
+ _error = null;
+ });
+
+ try {
+ final items = await ref.read(apiClientProvider).listTrash();
+ if (!mounted || generation != _generation) return;
+ setState(() {
+ _items = items;
+ _isLoading = false;
+ });
+ } catch (e) {
+ if (!mounted || generation != _generation) return;
+ setState(() {
+ _error = adminTrashErrorMessage(e);
+ _isLoading = false;
+ });
+ }
+ }
+
+ // ---------------------------------------------------------------------------
+ // Restore action (optimistic UI)
+ // ---------------------------------------------------------------------------
+
+ /// Restores [item] from trash and removes it from the local list optimistically.
+ ///
+ /// On error the item is re-appended to the list and a SnackBar reports the
+ /// problem. Re-appending (rather than re-inserting at the original index)
+ /// avoids position jitter from concurrent mutations.
+ Future<void> _restore(Media item, int index) async {
+ // Optimistic removal: remove the item from the list immediately.
+ setState(() => _items!.removeAt(index));
+
+ try {
+ await ref.read(apiClientProvider).restoreMedia(item.id);
+ if (!mounted) return;
+ ScaffoldMessenger.of(context).showSnackBar(
+ SnackBar(
+ key: const Key('admin_trash_restore_snackbar'),
+ content: Text('"${item.fileName}" restored.'),
+ duration: const Duration(seconds: 3),
+ ),
+ );
+ } catch (e) {
+ if (!mounted) return;
+ // Revert: re-append the item so it remains visible even after the error.
+ setState(() => _items = [..._items!, item]);
+ _showError(adminTrashErrorMessage(e));
+ }
+ }
+
+ // ---------------------------------------------------------------------------
+ // Hard delete action (confirmation + optimistic UI)
+ // ---------------------------------------------------------------------------
+
+ /// Shows a confirmation dialog, then hard-deletes [item] if confirmed.
+ ///
+ /// "Hard delete" from the UI's perspective means the item is flagged for
+ /// permanent removal. The server's GC worker completes the physical file
+ /// removal. The item is removed from the local list optimistically and
+ /// reverted on error.
+ Future<void> _hardDelete(Media item, int index) async {
+ final confirmed = await _confirmHardDelete(item.fileName);
+ if (!confirmed || !mounted) return;
+
+ // Optimistic removal.
+ setState(() => _items!.removeAt(index));
+
+ try {
+ // deleteMedia soft-deletes again (no-op for an already-deleted item)
+ // so the item stays flagged for GC rather than being restored.
+ await ref.read(apiClientProvider).deleteMedia(item.id);
+ if (!mounted) return;
+ ScaffoldMessenger.of(context).showSnackBar(
+ SnackBar(
+ key: const Key('admin_trash_delete_snackbar'),
+ content: Text('"${item.fileName}" marked for permanent deletion.'),
+ duration: const Duration(seconds: 3),
+ ),
+ );
+ } catch (e) {
+ if (!mounted) return;
+ // Revert: re-append the item so it remains visible after the error.
+ setState(() => _items = [..._items!, item]);
+ _showError(adminTrashErrorMessage(e));
+ }
+ }
+
+ /// Shows a confirmation [AlertDialog] before permanent deletion.
+ ///
+ /// Returns true only when the user taps the "Delete" button.
+ Future<bool> _confirmHardDelete(String fileName) async {
+ final result = await showDialog<bool>(
+ context: context,
+ builder: (ctx) => AlertDialog(
+ title: const Text('Permanently delete?'),
+ content: Text(
+ '"$fileName" will be flagged for permanent removal and cannot be '
+ 'restored. Are you sure?',
+ ),
+ actions: [
+ TextButton(
+ key: const Key('admin_trash_confirm_cancel'),
+ onPressed: () => Navigator.of(ctx).pop(false),
+ child: const Text('Cancel'),
+ ),
+ TextButton(
+ key: const Key('admin_trash_confirm_delete'),
+ style: TextButton.styleFrom(
+ foregroundColor: Theme.of(ctx).colorScheme.error,
+ ),
+ onPressed: () => Navigator.of(ctx).pop(true),
+ child: const Text('Delete permanently'),
+ ),
+ ],
+ ),
+ );
+ return result ?? false;
+ }
+
+ // ---------------------------------------------------------------------------
+ // Error display
+ // ---------------------------------------------------------------------------
+
+ void _showError(String message) {
+ if (!mounted) return;
+ ScaffoldMessenger.of(context).showSnackBar(
+ SnackBar(
+ key: const Key('admin_trash_error_snackbar'),
+ content: Text(message),
+ backgroundColor: Theme.of(context).colorScheme.error,
+ ),
+ );
+ }
+
+ // ---------------------------------------------------------------------------
+ // Build
+ // ---------------------------------------------------------------------------
+
+ @override
+ Widget build(BuildContext context) {
+ return Scaffold(
+ appBar: AppBar(
+ title: const Text('Trash'),
+ actions: [
+ IconButton(
+ key: const Key('admin_trash_refresh'),
+ icon: const Icon(Icons.refresh),
+ tooltip: 'Refresh',
+ onPressed: _load,
+ ),
+ ],
+ ),
+ body: _buildBody(context),
+ );
+ }
+
+ /// Builds the appropriate body widget for the current state.
+ Widget _buildBody(BuildContext context) {
+ // Show a full-screen spinner while the very first load is in flight.
+ if (_isLoading && _items == null) {
+ return const Center(
+ key: Key('admin_trash_loading'),
+ child: CircularProgressIndicator(),
+ );
+ }
+
+ if (_error != null) {
+ return _ErrorView(message: _error!, onRetry: _load);
+ }
+
+ return RefreshIndicator(
+ onRefresh: _load,
+ child: _items == null || _items!.isEmpty
+ ? const _EmptyView()
+ : _TrashList(
+ items: _items!,
+ onRestore: _restore,
+ onHardDelete: _hardDelete,
+ ),
+ );
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Sub-widgets
+// ---------------------------------------------------------------------------
+
+/// Scrollable list of trashed [Media] items.
+///
+/// Extracted as a stateless widget (SRP) so [_AdminTrashScreenState] focuses
+/// on data-loading and mutation concerns.
+class _TrashList extends StatelessWidget {
+ const _TrashList({
+ required this.items,
+ required this.onRestore,
+ required this.onHardDelete,
+ });
+
+ final List<Media> items;
+ final Future<void> Function(Media item, int index) onRestore;
+ final Future<void> Function(Media item, int index) onHardDelete;
+
+ @override
+ Widget build(BuildContext context) {
+ return ListView.separated(
+ key: const Key('admin_trash_list'),
+ itemCount: items.length,
+ separatorBuilder: (_, __) => const Divider(height: 1),
+ itemBuilder: (_, index) => _TrashTile(
+ item: items[index],
+ index: index,
+ onRestore: onRestore,
+ onHardDelete: onHardDelete,
+ ),
+ );
+ }
+}
+
+/// A single trash item row with restore and hard-delete actions.
+class _TrashTile extends StatelessWidget {
+ const _TrashTile({
+ required this.item,
+ required this.index,
+ required this.onRestore,
+ required this.onHardDelete,
+ });
+
+ final Media item;
+ final int index;
+ final Future<void> Function(Media item, int index) onRestore;
+ final Future<void> Function(Media item, int index) onHardDelete;
+
+ @override
+ Widget build(BuildContext context) {
+ return ListTile(
+ key: Key('admin_trash_tile_${item.id}'),
+ leading: _TypeIcon(type: item.type),
+ title: Text(item.fileName, overflow: TextOverflow.ellipsis),
+ subtitle: Text(
+ item.absPath,
+ overflow: TextOverflow.ellipsis,
+ maxLines: 1,
+ style: Theme.of(context).textTheme.bodySmall,
+ ),
+ // Restore and hard-delete actions side by side in the trailing slot.
+ trailing: Row(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ IconButton(
+ key: Key('admin_trash_restore_${item.id}'),
+ icon: const Icon(Icons.restore_outlined),
+ tooltip: 'Restore',
+ onPressed: () => onRestore(item, index),
+ ),
+ IconButton(
+ key: Key('admin_trash_delete_${item.id}'),
+ icon: const Icon(Icons.delete_forever_outlined),
+ tooltip: 'Delete permanently',
+ color: Theme.of(context).colorScheme.error,
+ onPressed: () => onHardDelete(item, index),
+ ),
+ ],
+ ),
+ );
+ }
+}
+
+/// Small icon that visually distinguishes video items from audio items.
+class _TypeIcon extends StatelessWidget {
+ const _TypeIcon({required this.type});
+
+ final String type;
+
+ @override
+ Widget build(BuildContext context) {
+ final isAudio = type == 'audio';
+ return Icon(
+ isAudio ? Icons.audio_file_outlined : Icons.video_file_outlined,
+ color: Theme.of(context).colorScheme.onSurfaceVariant,
+ );
+ }
+}
+
+/// Full-screen empty-state shown when trash is empty.
+///
+/// Wrapped in a scrollable so the parent [RefreshIndicator] can trigger
+/// pull-to-refresh even when no content is present.
+class _EmptyView extends StatelessWidget {
+ const _EmptyView();
+
+ @override
+ Widget build(BuildContext context) {
+ return LayoutBuilder(
+ builder: (context, constraints) => SingleChildScrollView(
+ physics: const AlwaysScrollableScrollPhysics(),
+ child: SizedBox(
+ height: constraints.maxHeight,
+ child: Center(
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ Icon(
+ Icons.delete_outline,
+ size: 72,
+ color: Theme.of(context).colorScheme.onSurfaceVariant,
+ ),
+ const SizedBox(height: 16),
+ Text(
+ 'Trash is empty',
+ key: const Key('admin_trash_empty'),
+ style: Theme.of(context).textTheme.titleMedium,
+ ),
+ ],
+ ),
+ ),
+ ),
+ ),
+ );
+ }
+}
+
+/// Full-screen error view with a retry button.
+class _ErrorView extends StatelessWidget {
+ const _ErrorView({required this.message, required this.onRetry});
+
+ final String message;
+ final VoidCallback onRetry;
+
+ @override
+ Widget build(BuildContext context) {
+ return Center(
+ child: Padding(
+ padding: const EdgeInsets.all(24),
+ child: Column(
+ mainAxisAlignment: MainAxisAlignment.center,
+ children: [
+ Icon(
+ Icons.error_outline,
+ size: 56,
+ color: Theme.of(context).colorScheme.error,
+ ),
+ const SizedBox(height: 16),
+ Text(
+ message,
+ key: const Key('admin_trash_error'),
+ textAlign: TextAlign.center,
+ style: Theme.of(context).textTheme.bodyLarge,
+ ),
+ const SizedBox(height: 24),
+ ElevatedButton.icon(
+ key: const Key('admin_trash_retry'),
+ onPressed: onRetry,
+ icon: const Icon(Icons.refresh),
+ label: const Text('Retry'),
+ ),
+ ],
+ ),
+ ),
+ );
+ }
+}