import 'package:flutter/material.dart' hide Title; 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'; import 'widgets/source_logo.dart'; class TitlePage extends ConsumerStatefulWidget { const TitlePage({super.key, required this.titleId}); final int titleId; @override ConsumerState createState() => _TitlePageState(); } class _TitlePageState extends ConsumerState { Title? _title; List _chapters = const []; final Set _selected = {}; bool _newestFirst = true; bool _loading = true; bool _syncing = false; String? _error; @override void initState() { super.initState(); _reload(); } List _sortedReadingOrder(List chapters) { final copy = [...chapters]; copy.sort((a, b) { final na = double.tryParse(a.chapterNumber ?? '') ?? 0; final nb = double.tryParse(b.chapterNumber ?? '') ?? 0; return na.compareTo(nb); }); return copy; } Future _reload({bool showLoading = true}) async { if (showLoading) { setState(() { _loading = true; _error = null; }); } final db = ref.read(databaseProvider); final title = await db.getTitle(widget.titleId); final chapters = await db.listChapters(widget.titleId, newestFirst: _newestFirst); chapters.sort((a, b) { final na = double.tryParse(a.chapterNumber ?? '') ?? 0; final nb = double.tryParse(b.chapterNumber ?? '') ?? 0; return _newestFirst ? nb.compareTo(na) : na.compareTo(nb); }); if (!mounted) return; setState(() { _title = title; _chapters = chapters; _loading = false; }); } Future _sync() async { final title = _title; if (title == null) return; setState(() => _syncing = true); try { final source = ref.read(sourceRegistryProvider).get( SourceId.values.byName(title.source), ); final series = await source.fetchSeries(title.url); await ref.read(downloadQueueProvider).upsertSeries(series); await _reload(); } catch (e) { if (!mounted) return; setState(() => _error = e.toString()); } finally { if (mounted) setState(() => _syncing = false); } } Future _downloadSelected() async { if (_selected.isEmpty) return; await ref.read(downloadQueueProvider).enqueueChapters(_selected); if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text('Queued ${_selected.length} chapter(s)')), ); setState(() => _selected.clear()); await _reload(); } Future _downloadAll() async { if (_chapters.isEmpty) return; var unreadOnly = true; final confirmed = await showDialog( context: context, builder: (ctx) { return StatefulBuilder( builder: (ctx, setLocal) { final ids = _chapters.where((c) { if (c.status == 'downloaded') return false; if (unreadOnly && c.completed) return false; return true; }).map((c) => c.id).toList(); return AlertDialog( title: const Text('Download all'), content: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( ids.isEmpty ? 'No chapters match the current filter.' : 'Queue ${ids.length} chapter(s) for download?', ), const SizedBox(height: 12), CheckboxListTile( contentPadding: EdgeInsets.zero, title: const Text('Download unread only'), subtitle: const Text( 'Skip chapters already marked completed', ), value: unreadOnly, onChanged: (v) => setLocal(() => unreadOnly = v ?? true), ), ], ), actions: [ TextButton( onPressed: () => Navigator.pop(ctx, false), child: const Text('Cancel'), ), FilledButton( onPressed: ids.isEmpty ? null : () => Navigator.pop(ctx, true), child: const Text('Download'), ), ], ); }, ); }, ); if (confirmed != true || !mounted) return; final ids = _chapters.where((c) { if (c.status == 'downloaded') return false; if (unreadOnly && c.completed) return false; return true; }).map((c) => c.id); await ref.read(downloadQueueProvider).enqueueChapters(ids); if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text('Queued ${ids.length} chapter(s)')), ); await _reload(); } bool get _hasReadingHistory { final title = _title; if (title?.lastReadChapterId != null) return true; return _chapters.any((c) => c.lastReadAt != null || c.lastPage > 0 || c.completed); } Chapter? get _continueChapter { final title = _title; if (title?.lastReadChapterId != null) { final match = _chapters.where((c) => c.id == title!.lastReadChapterId).firstOrNull; if (match != null) return match; } Chapter? best; for (final c in _chapters) { if (c.lastReadAt == null && c.lastPage == 0 && !c.completed) continue; if (best == null) { best = c; continue; } final a = c.lastReadAt; final b = best.lastReadAt; if (a != null && (b == null || a.isAfter(b))) best = c; } return best; } Chapter? get _firstChapter { final ordered = _sortedReadingOrder(_chapters); return ordered.isEmpty ? null : ordered.first; } Future _toggleInLibrary() async { final title = _title; if (title == null) return; final next = !title.inLibrary; await ref.read(databaseProvider).setInLibrary(title.id, next); ref.read(webDavSyncProvider).scheduleSync(); await _reload(); if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text(next ? 'Added to Library' : 'Removed from Library'), ), ); } void _openChapter(Chapter chapter) { context.push('/read/${widget.titleId}/${chapter.id}').then((_) async { if (!mounted) return; await _reload(showLoading: false); }); } void _onReadOrContinue() { if (_hasReadingHistory) { final ch = _continueChapter ?? _firstChapter; if (ch != null) _openChapter(ch); } else { final ch = _firstChapter; if (ch != null) _openChapter(ch); } } Color? _statusColor(BuildContext context, Chapter ch) { final scheme = Theme.of(context).colorScheme; if (ch.completed) return scheme.primary; if (ch.status == 'downloaded') return scheme.tertiary; if (ch.status == 'downloading') return scheme.tertiary; if (ch.status == 'failed') return scheme.error; return null; } String _chapterSubtitle(Chapter ch) { final parts = []; if (ch.completed) { parts.add('Completed'); } else if (ch.pageCount > 0 && (ch.lastPage > 0 || ch.lastReadAt != null)) { parts.add('Page ${ch.lastPage + 1}/${ch.pageCount}'); } else if (ch.lastReadAt != null || ch.lastPage > 0) { parts.add('Reading'); } if (ch.status == 'downloaded') { parts.add('Downloaded'); } else if (ch.status == 'downloading') { parts.add('Downloading'); } else if (ch.status == 'failed') { parts.add('Failed'); } return parts.isEmpty ? 'Unread' : parts.join(' · '); } @override Widget build(BuildContext context) { final title = _title; final canRead = _chapters.isNotEmpty; final continueLabel = _hasReadingHistory ? 'Continue' : 'Read first chapter'; final continueChapter = _hasReadingHistory ? (_continueChapter ?? _firstChapter) : _firstChapter; return Scaffold( appBar: AppBar( title: Text(title?.title ?? 'Title'), actions: [ if (title != null) IconButton( tooltip: title.inLibrary ? 'Remove from Library' : 'Add to Library', onPressed: _toggleInLibrary, icon: Icon(title.inLibrary ? Icons.star : Icons.star_border), ), IconButton( tooltip: 'Download all', onPressed: _chapters.isEmpty ? null : _downloadAll, icon: const Icon(Icons.download_for_offline_outlined), ), IconButton( tooltip: _newestFirst ? 'Oldest first' : 'Newest first', onPressed: () async { setState(() => _newestFirst = !_newestFirst); await _reload(); }, icon: Icon(_newestFirst ? Icons.arrow_downward : Icons.arrow_upward), ), IconButton( tooltip: 'Sync chapters', onPressed: _syncing ? null : _sync, icon: _syncing ? const SizedBox( width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2), ) : const Icon(Icons.sync), ), ], ), floatingActionButton: _selected.isEmpty ? null : FloatingActionButton.extended( onPressed: _downloadSelected, icon: const Icon(Icons.download), label: Text('Download ${_selected.length}'), ), body: _loading ? const Center(child: CircularProgressIndicator()) : title == null ? const Center(child: Text('Title not found')) : Column( children: [ if (_error != null) MaterialBanner( content: Text(_error!), actions: [ TextButton( onPressed: () => setState(() => _error = null), child: const Text('Dismiss'), ), ], ), Padding( padding: const EdgeInsets.all(12), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ SizedBox( width: 96, height: 128, child: ClipRRect( borderRadius: BorderRadius.circular(8), child: ComicCover( url: title.coverUrl, sourceId: SourceId.tryParse(title.source), ), ), ), const SizedBox(width: 12), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( title.title, style: Theme.of(context).textTheme.titleLarge, ), const SizedBox(height: 4), Row( children: [ if (SourceId.tryParse(title.source) case final sourceId?) SourceLogo( sourceId: sourceId, size: 16, borderRadius: 4, ), if (SourceId.tryParse(title.source) != null) const SizedBox(width: 6), Expanded( child: Text( '${SourceId.tryParse(title.source)?.label ?? title.source} · ${_chapters.length} chapters', style: Theme.of(context).textTheme.bodySmall, ), ), ], ), if (title.description != null) ...[ const SizedBox(height: 8), Text( title.description!, maxLines: 3, overflow: TextOverflow.ellipsis, ), ], if (canRead) ...[ const SizedBox(height: 12), FilledButton.icon( onPressed: _onReadOrContinue, icon: Icon( _hasReadingHistory ? Icons.play_arrow : Icons.menu_book, ), label: Text( continueChapter == null ? continueLabel : _hasReadingHistory ? 'Continue · ${continueChapter.label}' : continueLabel, ), ), ], ], ), ), ], ), ), const Divider(height: 1), Expanded( child: ListView.builder( itemCount: _chapters.length, itemBuilder: (context, i) { final ch = _chapters[i]; final selected = _selected.contains(ch.id); return ListTile( leading: Checkbox( value: selected, onChanged: (v) { setState(() { if (v == true) { _selected.add(ch.id); } else { _selected.remove(ch.id); } }); }, ), title: Text(ch.label), subtitle: Text(_chapterSubtitle(ch)), trailing: Icon( ch.completed ? Icons.check_circle : ch.status == 'downloaded' ? Icons.offline_pin : Icons.chevron_right, color: _statusColor(context, ch), ), onTap: () => _openChapter(ch), onLongPress: () { setState(() { if (selected) { _selected.remove(ch.id); } else { _selected.add(ch.id); } }); }, ); }, ), ), ], ), ); } }