summaryrefslogtreecommitdiff
path: root/player-android/lib/screens/admin_rescan_screen.dart
diff options
context:
space:
mode:
Diffstat (limited to 'player-android/lib/screens/admin_rescan_screen.dart')
-rw-r--r--player-android/lib/screens/admin_rescan_screen.dart427
1 files changed, 427 insertions, 0 deletions
diff --git a/player-android/lib/screens/admin_rescan_screen.dart b/player-android/lib/screens/admin_rescan_screen.dart
new file mode 100644
index 0000000..3ce8953
--- /dev/null
+++ b/player-android/lib/screens/admin_rescan_screen.dart
@@ -0,0 +1,427 @@
+import 'dart:async';
+
+import 'package:flutter/material.dart';
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+
+import '../providers/api_client_provider.dart';
+import '../utils/error_mappers.dart';
+
+/// Admin-only rescan screen.
+///
+/// Design notes:
+/// - The user taps "Trigger Rescan" to start a library rescan on the server.
+/// - After triggering, the screen polls [getScanProgress] every 2 seconds
+/// while the scan is running, displaying live progress (file counts, current
+/// set name).
+/// - The poll timer is stored in [_pollTimer] and cancelled in [dispose] to
+/// prevent memory leaks and spurious setState calls after the widget is gone.
+/// - A generation counter prevents stale polling results from overwriting
+/// the state after the user navigates away and back again.
+/// - The trigger button is disabled while a scan is actively running to
+/// prevent duplicate scans.
+/// - All async continuations guard on [mounted] to prevent setState/context
+/// calls after widget disposal.
+class AdminRescanScreen extends ConsumerStatefulWidget {
+ const AdminRescanScreen({super.key});
+
+ @override
+ ConsumerState<AdminRescanScreen> createState() => _AdminRescanScreenState();
+}
+
+class _AdminRescanScreenState extends ConsumerState<AdminRescanScreen> {
+ // Interval between progress poll requests while a scan is running.
+ static const _pollInterval = Duration(seconds: 2);
+
+ // Null before the first status fetch, non-null after.
+ _ScanStatus? _status;
+
+ // Non-null when the last API call failed.
+ String? _error;
+
+ // True while the trigger request is in flight.
+ bool _isTriggerring = false;
+
+ // Active polling timer; cancelled in dispose and whenever the scan finishes.
+ Timer? _pollTimer;
+
+ // Generation counter: async completions discard results if they captured a
+ // stale generation value (prevents out-of-order result clobbering).
+ int _generation = 0;
+
+ @override
+ void initState() {
+ super.initState();
+ // Fetch the current scan status immediately so the user sees whether a
+ // scan is already running (e.g. started by another admin session).
+ WidgetsBinding.instance.addPostFrameCallback((_) => _fetchStatus());
+ }
+
+ @override
+ void dispose() {
+ // Always cancel the polling timer to avoid calling setState after disposal
+ // and to release the periodic timer resource.
+ _pollTimer?.cancel();
+ super.dispose();
+ }
+
+ // ---------------------------------------------------------------------------
+ // Status fetching
+ // ---------------------------------------------------------------------------
+
+ /// Fetches the current scan progress and updates [_status].
+ ///
+ /// If the scan is running, a poll timer is started (or kept running).
+ /// If the scan is idle/complete, any active poll timer is cancelled.
+ Future<void> _fetchStatus() async {
+ if (!mounted) return;
+ final generation = ++_generation;
+
+ try {
+ final raw = await ref.read(apiClientProvider).getScanProgress();
+ if (!mounted || generation != _generation) return;
+
+ final status = _ScanStatus.fromMap(raw);
+ setState(() {
+ _status = status;
+ _error = null;
+ });
+
+ _updatePolling(status.isRunning);
+ } catch (e) {
+ if (!mounted || generation != _generation) return;
+ setState(() => _error = adminRescanErrorMessage(e));
+ // Stop polling on error to avoid hammering a broken endpoint; the user
+ // can retry manually via the refresh button.
+ _pollTimer?.cancel();
+ _pollTimer = null;
+ }
+ }
+
+ /// Starts or stops the background polling timer based on [scanRunning].
+ ///
+ /// Starts a new periodic timer when [scanRunning] is true and no timer is
+ /// active; cancels any active timer when [scanRunning] is false.
+ void _updatePolling(bool scanRunning) {
+ if (scanRunning && _pollTimer == null) {
+ // Poll every 2 seconds while the scan is running to show live progress.
+ _pollTimer = Timer.periodic(_pollInterval, (_) => _fetchStatus());
+ } else if (!scanRunning) {
+ _pollTimer?.cancel();
+ _pollTimer = null;
+ }
+ }
+
+ // ---------------------------------------------------------------------------
+ // Trigger rescan action
+ // ---------------------------------------------------------------------------
+
+ /// Sends a trigger-rescan request and immediately begins polling for progress.
+ Future<void> _triggerRescan() async {
+ if (!mounted || _isTriggerring) return;
+ setState(() {
+ _isTriggerring = true;
+ _error = null;
+ });
+
+ try {
+ await ref.read(apiClientProvider).triggerRescan();
+ if (!mounted) return;
+ setState(() => _isTriggerring = false);
+ // Start polling immediately so the user sees progress as soon as the
+ // server reports the scan has begun.
+ await _fetchStatus();
+ } catch (e) {
+ if (!mounted) return;
+ setState(() {
+ _isTriggerring = false;
+ _error = adminRescanErrorMessage(e);
+ });
+ }
+ }
+
+ // ---------------------------------------------------------------------------
+ // Build
+ // ---------------------------------------------------------------------------
+
+ @override
+ Widget build(BuildContext context) {
+ return Scaffold(
+ appBar: AppBar(
+ title: const Text('Rescan Library'),
+ actions: [
+ IconButton(
+ key: const Key('admin_rescan_refresh'),
+ icon: const Icon(Icons.refresh),
+ tooltip: 'Check status',
+ onPressed: _fetchStatus,
+ ),
+ ],
+ ),
+ body: SafeArea(
+ child: Padding(
+ padding: const EdgeInsets.all(24),
+ child: _buildBody(context),
+ ),
+ ),
+ );
+ }
+
+ /// Builds the screen body: status card + trigger button.
+ Widget _buildBody(BuildContext context) {
+ return Column(
+ crossAxisAlignment: CrossAxisAlignment.stretch,
+ children: [
+ _StatusCard(status: _status, error: _error),
+ const SizedBox(height: 32),
+ _TriggerButton(
+ isRunning: _status?.isRunning ?? false,
+ isTriggerring: _isTriggerring,
+ onTap: _triggerRescan,
+ ),
+ ],
+ );
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Data model for scan progress
+// ---------------------------------------------------------------------------
+
+/// Parsed scan progress state returned by GET /api/v1/admin/scan-progress.
+///
+/// Kept as a plain data class (no business logic) so [_AdminRescanScreenState]
+/// and the sub-widgets stay focused on their own concerns (SRP).
+class _ScanStatus {
+ const _ScanStatus({
+ required this.isRunning,
+ required this.currentSet,
+ required this.setsTotal,
+ required this.setsDone,
+ required this.filesTotal,
+ required this.filesDone,
+ this.lastError,
+ });
+
+ /// Parses the raw progress map returned by the server.
+ factory _ScanStatus.fromMap(Map<String, dynamic> map) {
+ return _ScanStatus(
+ isRunning: map['running'] as bool? ?? false,
+ currentSet: map['current_set'] as String? ?? '',
+ setsTotal: map['sets_total'] as int? ?? 0,
+ setsDone: map['sets_done'] as int? ?? 0,
+ filesTotal: map['files_total'] as int? ?? 0,
+ filesDone: map['files_done'] as int? ?? 0,
+ lastError: map['last_error'] as String?,
+ );
+ }
+
+ final bool isRunning;
+ final String currentSet;
+ final int setsTotal;
+ final int setsDone;
+ final int filesTotal;
+ final int filesDone;
+ final String? lastError;
+}
+
+// ---------------------------------------------------------------------------
+// Sub-widgets
+// ---------------------------------------------------------------------------
+
+/// Card that displays the current scan status and progress.
+///
+/// Shows a spinner + live counters while running; shows "Idle" or "Scan
+/// complete" when not running; shows a loading placeholder before the first
+/// status fetch completes.
+class _StatusCard extends StatelessWidget {
+ const _StatusCard({required this.status, required this.error});
+
+ final _ScanStatus? status;
+ final String? error;
+
+ @override
+ Widget build(BuildContext context) {
+ return Card(
+ child: Padding(
+ padding: const EdgeInsets.all(20),
+ child: _cardContent(context),
+ ),
+ );
+ }
+
+ /// Returns the inner content of the status card.
+ Widget _cardContent(BuildContext context) {
+ // Show error state if there was an API failure.
+ if (error != null) {
+ return _ErrorRow(message: error!);
+ }
+
+ // Show a spinner while the initial status fetch is in progress.
+ final s = status;
+ if (s == null) {
+ return const Center(
+ key: Key('admin_rescan_status_loading'),
+ child: CircularProgressIndicator(),
+ );
+ }
+
+ if (s.isRunning) {
+ return _RunningContent(status: s);
+ }
+
+ return _IdleContent(status: s);
+ }
+}
+
+/// Status card content while a scan is running.
+class _RunningContent extends StatelessWidget {
+ const _RunningContent({required this.status});
+
+ final _ScanStatus status;
+
+ @override
+ Widget build(BuildContext context) {
+ return Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ Row(
+ children: [
+ const SizedBox(
+ width: 20,
+ height: 20,
+ child: CircularProgressIndicator(strokeWidth: 2),
+ ),
+ const SizedBox(width: 12),
+ Text(
+ 'Scan running…',
+ key: const Key('admin_rescan_running_label'),
+ style: Theme.of(context).textTheme.titleSmall,
+ ),
+ ],
+ ),
+ if (status.currentSet.isNotEmpty) ...[
+ const SizedBox(height: 12),
+ Text(
+ 'Current set: ${status.currentSet}',
+ style: Theme.of(context).textTheme.bodyMedium,
+ ),
+ ],
+ if (status.setsTotal > 0) ...[
+ const SizedBox(height: 6),
+ Text('Sets: ${status.setsDone} / ${status.setsTotal}'),
+ ],
+ if (status.filesTotal > 0) ...[
+ const SizedBox(height: 6),
+ Text('Files: ${status.filesDone} / ${status.filesTotal}'),
+ ],
+ ],
+ );
+ }
+}
+
+/// Status card content when no scan is running.
+class _IdleContent extends StatelessWidget {
+ const _IdleContent({required this.status});
+
+ final _ScanStatus status;
+
+ @override
+ Widget build(BuildContext context) {
+ // Show a "Scan complete" summary when there are files already scanned;
+ // otherwise show the neutral "Idle" state.
+ final hasScanned = status.filesTotal > 0 || status.setsDone > 0;
+ return Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ Row(
+ children: [
+ Icon(
+ hasScanned ? Icons.check_circle_outline : Icons.schedule_outlined,
+ color: hasScanned
+ ? Theme.of(context).colorScheme.primary
+ : Theme.of(context).colorScheme.onSurfaceVariant,
+ ),
+ const SizedBox(width: 12),
+ Text(
+ hasScanned ? 'Scan complete' : 'Idle — no scan running',
+ key: const Key('admin_rescan_idle_label'),
+ style: Theme.of(context).textTheme.titleSmall,
+ ),
+ ],
+ ),
+ if (hasScanned && status.filesTotal > 0) ...[
+ const SizedBox(height: 8),
+ Text('Files scanned: ${status.filesDone} / ${status.filesTotal}'),
+ ],
+ if (status.lastError != null && status.lastError!.isNotEmpty) ...[
+ const SizedBox(height: 8),
+ Text(
+ 'Last error: ${status.lastError}',
+ style: TextStyle(color: Theme.of(context).colorScheme.error),
+ ),
+ ],
+ ],
+ );
+ }
+}
+
+/// Inline error row shown inside the status card.
+class _ErrorRow extends StatelessWidget {
+ const _ErrorRow({required this.message});
+
+ final String message;
+
+ @override
+ Widget build(BuildContext context) {
+ return Row(
+ children: [
+ Icon(Icons.error_outline, color: Theme.of(context).colorScheme.error),
+ const SizedBox(width: 12),
+ Expanded(
+ child: Text(
+ message,
+ key: const Key('admin_rescan_error'),
+ style: TextStyle(color: Theme.of(context).colorScheme.error),
+ ),
+ ),
+ ],
+ );
+ }
+}
+
+/// Button that triggers a rescan.
+///
+/// Disabled while a scan is running or a trigger request is in flight,
+/// preventing duplicate scans and accidental double-taps.
+class _TriggerButton extends StatelessWidget {
+ const _TriggerButton({
+ required this.isRunning,
+ required this.isTriggerring,
+ required this.onTap,
+ });
+
+ final bool isRunning;
+ final bool isTriggerring;
+ final VoidCallback onTap;
+
+ @override
+ Widget build(BuildContext context) {
+ // Disable the button while a scan is active or the trigger is in flight.
+ final canTrigger = !isRunning && !isTriggerring;
+
+ return FilledButton.icon(
+ key: const Key('admin_rescan_trigger'),
+ onPressed: canTrigger ? onTap : null,
+ icon: isTriggerring
+ ? const SizedBox(
+ width: 18,
+ height: 18,
+ child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white),
+ )
+ : const Icon(Icons.sync_outlined),
+ label: Text(isRunning ? 'Scan in progress…' : 'Trigger Rescan'),
+ );
+ }
+}