import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import '../data/database.dart'; import '../providers/providers.dart'; import '../sources/source.dart'; import 'widgets/comic_cover.dart'; class DownloadsPage extends ConsumerWidget { const DownloadsPage({super.key}); @override Widget build(BuildContext context, WidgetRef ref) { final queue = ref.watch(downloadQueueProvider); final queueAsync = ref.watch(queueJobsProvider); final downloadedAsync = ref.watch(downloadedSeriesProvider); return Scaffold( appBar: AppBar(title: const Text('Downloads')), body: queueAsync.when( loading: () => const Center(child: CircularProgressIndicator()), error: (e, _) => Center(child: Text('$e')), data: (queueJobs) { return downloadedAsync.when( loading: () => const Center(child: CircularProgressIndicator()), error: (e, _) => Center(child: Text('$e')), data: (groups) { final queued = queueJobs.where((j) => j.status == 'queued').length; final failed = queueJobs.where((j) => j.status == 'failed').length; final pendingTotal = queued + failed; final hasDownloads = groups.isNotEmpty; final showRunning = queue.isRunning; if (!showRunning && !hasDownloads && pendingTotal == 0) { return const Center( child: Padding( padding: EdgeInsets.all(24), child: Text( 'No downloads yet.\nSelect chapters on a series and tap Download.', textAlign: TextAlign.center, ), ), ); } final queueLabel = failed > 0 ? 'Queue ($pendingTotal · $failed failed)' : 'Queue ($pendingTotal)'; return CustomScrollView( slivers: [ if (showRunning) SliverToBoxAdapter( child: Padding( padding: const EdgeInsets.fromLTRB(16, 12, 16, 4), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Text( queue.message.isEmpty ? 'Downloading…' : queue.message, style: Theme.of(context).textTheme.bodyMedium, maxLines: 2, overflow: TextOverflow.ellipsis, ), const SizedBox(height: 8), LinearProgressIndicator( value: queue.progress.clamp(0.0, 1.0), ), ], ), ), ), if (pendingTotal > 0) SliverToBoxAdapter( child: ListTile( leading: const Icon(Icons.queue), title: Text(queueLabel), trailing: const Icon(Icons.chevron_right), onTap: () => context.push('/downloads/queue'), ), ), if (groups.isNotEmpty) ...[ SliverToBoxAdapter( child: Padding( padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), child: Text( 'Downloaded', style: Theme.of(context).textTheme.titleMedium, ), ), ), SliverList.builder( itemCount: groups.length, itemBuilder: (context, i) { return _DownloadedSeriesTile(group: groups[i]); }, ), ], const SliverToBoxAdapter(child: SizedBox(height: 24)), ], ); }, ); }, ), ); } } class DownloadsQueuePage extends ConsumerWidget { const DownloadsQueuePage({super.key}); @override Widget build(BuildContext context, WidgetRef ref) { final queueAsync = ref.watch(queueJobsProvider); return Scaffold( appBar: AppBar(title: const Text('Queue')), body: queueAsync.when( loading: () => const Center(child: CircularProgressIndicator()), error: (e, _) => Center(child: Text('$e')), data: (jobs) { final list = jobs .where((j) => j.status == 'queued' || j.status == 'failed') .toList(); if (list.isEmpty) { return const Center(child: Text('Queue is empty.')); } return ListView.builder( itemCount: list.length, itemBuilder: (context, i) => _QueueJobTile(job: list[i]), ); }, ), ); } } class _QueueJobTile extends ConsumerWidget { const _QueueJobTile({required this.job}); final QueueJobView job; @override Widget build(BuildContext context, WidgetRef ref) { final statusLabel = switch (job.status) { 'running' => 'Downloading', 'queued' => 'Queued', 'failed' => 'Failed', _ => job.status, }; return ListTile( leading: Icon(_iconFor(job.status)), title: Text( job.seriesTitle, maxLines: 1, overflow: TextOverflow.ellipsis, ), subtitle: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( '${job.chapterLabel} · $statusLabel', maxLines: 1, overflow: TextOverflow.ellipsis, ), if (job.status == 'failed' && job.error != null) ...[ const SizedBox(height: 2), Text( job.error!, maxLines: 2, overflow: TextOverflow.ellipsis, style: TextStyle( color: Theme.of(context).colorScheme.error, fontSize: 12, ), ), ], ], ), isThreeLine: job.status == 'failed', trailing: job.status == 'failed' ? IconButton( tooltip: 'Retry', icon: const Icon(Icons.refresh), onPressed: job.chapterId == null ? null : () => ref .read(downloadQueueProvider) .retryChapter(job.chapterId!), ) : job.status == 'queued' ? IconButton( tooltip: 'Cancel', icon: const Icon(Icons.cancel_outlined), onPressed: job.chapterId == null ? null : () => ref .read(downloadQueueProvider) .cancelChapter(job.chapterId!), ) : null, onTap: job.titleId == null ? null : () => context.push('/title/${job.titleId}'), ); } IconData _iconFor(String status) { switch (status) { case 'failed': return Icons.error_outline; case 'queued': return Icons.hourglass_empty; default: return Icons.download_outlined; } } } class _DownloadedSeriesTile extends ConsumerWidget { const _DownloadedSeriesTile({required this.group}); final DownloadedSeriesGroup group; @override Widget build(BuildContext context, WidgetRef ref) { final sourceId = SourceId.tryParse(group.source); return ExpansionTile( leading: SizedBox( width: 40, height: 56, child: ClipRRect( borderRadius: BorderRadius.circular(4), child: ComicCover(url: group.coverUrl, sourceId: sourceId), ), ), title: Text( group.seriesTitle, maxLines: 1, overflow: TextOverflow.ellipsis, ), subtitle: Text( '${group.chapters.length} chapter${group.chapters.length == 1 ? '' : 's'}', ), children: [ for (final ch in group.chapters) Dismissible( key: ValueKey('dl-${ch.chapterId}'), direction: DismissDirection.endToStart, background: Container( color: Theme.of(context).colorScheme.error, alignment: Alignment.centerRight, padding: const EdgeInsets.symmetric(horizontal: 16), child: const Icon(Icons.delete_outline, color: Colors.white), ), confirmDismiss: (_) async { final ok = await showDialog( context: context, builder: (ctx) => AlertDialog( title: const Text('Delete download?'), content: Text( 'Remove “${ch.chapterLabel}” from Downloads and delete the CBZ file.', ), actions: [ TextButton( onPressed: () => Navigator.pop(ctx, false), child: const Text('Cancel'), ), FilledButton( onPressed: () => Navigator.pop(ctx, true), child: const Text('Delete'), ), ], ), ); return ok == true; }, onDismissed: (_) { ref.read(downloadQueueProvider).deleteDownload(ch.chapterId); }, child: ListTile( contentPadding: const EdgeInsets.only(left: 72, right: 8), title: Text(ch.chapterLabel), subtitle: ch.downloadedAt == null ? null : Text(_formatDate(ch.downloadedAt!)), trailing: PopupMenuButton( onSelected: (v) { if (v == 'delete') { ref .read(downloadQueueProvider) .deleteDownload(ch.chapterId); } }, itemBuilder: (context) => const [ PopupMenuItem(value: 'delete', child: Text('Delete')), ], ), onTap: () { context.push('/read/${ch.titleId}/${ch.chapterId}'); }, ), ), ], ); } String _formatDate(DateTime dt) { final local = dt.toLocal(); final y = local.year.toString().padLeft(4, '0'); final m = local.month.toString().padLeft(2, '0'); final d = local.day.toString().padLeft(2, '0'); return 'Downloaded $y-$m-$d'; } }