import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import '../providers/providers.dart'; import '../sources/source.dart'; import 'catalog/mangatown_directory_filters.dart'; import 'widgets/comic_cover.dart'; /// Catalog feed for one `(source, menuKind)` — grid + search field when needed. class CatalogFeedPage extends ConsumerStatefulWidget { const CatalogFeedPage({ super.key, required this.sourceId, required this.menuKind, }); final SourceId sourceId; final CatalogMenuKind menuKind; @override ConsumerState createState() => _CatalogFeedPageState(); } class _CatalogFeedPageState extends ConsumerState { final _searchCtrl = TextEditingController(); List _items = const []; bool _loading = true; String? _error; String _lastQuery = ''; /// Next 0-based page index (mapped to `offset: page * _pageSize` for sources). int _page = 0; static const _pageSize = 30; bool _hasMore = true; bool _loadingMore = false; CatalogBrowseParams _browse = const CatalogBrowseParams(); ComicSource get _source => ref.read(sourceRegistryProvider).get(widget.sourceId); bool get _showDirectoryFilters => widget.sourceId == SourceId.mangatown && widget.menuKind == CatalogMenuKind.directory; String get _menuTitle { final menus = _source.catalogMenus; for (final m in menus) { if (m.kind == widget.menuKind) return m.title; } return widget.menuKind.name; } @override void initState() { super.initState(); if (widget.menuKind != CatalogMenuKind.search) { _load(reset: true); } else { _loading = false; } } @override void dispose() { _searchCtrl.dispose(); super.dispose(); } Future _load({required bool reset}) async { if (reset) { setState(() { _loading = true; _error = null; _page = 0; _hasMore = true; }); } else { if (!_hasMore || _loadingMore) return; setState(() => _loadingMore = true); } final pageIndex = reset ? 0 : _page; try { final list = await _source.loadCatalog( widget.menuKind, query: _lastQuery, offset: pageIndex * _pageSize, limit: _pageSize, browse: _showDirectoryFilters ? _browse : null, ); if (!mounted) return; setState(() { if (reset) { _items = list; } else { // Drop accidental overlaps if a source re-served the same page. final seen = _items.map((c) => c.listingUrl).toSet(); _items = [ ..._items, ...list.where((c) => seen.add(c.listingUrl)), ]; } _page = pageIndex + 1; // Madara (and similar) may return < limit per HTML page; keep going // until a page comes back empty. _hasMore = list.isNotEmpty; _loading = false; _loadingMore = false; }); } catch (e) { if (!mounted) return; setState(() { _error = e.toString(); _loading = false; _loadingMore = false; }); } } Future _runSearch(String q) async { final query = q.trim(); if (query.length < 2) { setState(() { _items = const []; _lastQuery = ''; _error = null; }); return; } _lastQuery = query; await _load(reset: true); } void _onBrowseChanged(CatalogBrowseParams next) { if (next == _browse) return; setState(() => _browse = next); _load(reset: true); } Future _openComic(CatalogComic comic) async { final messenger = ScaffoldMessenger.of(context); messenger.showSnackBar( SnackBar(content: Text('Loading ${comic.title}…')), ); try { final source = ref.read(sourceRegistryProvider).get(comic.source); final queue = ref.read(downloadQueueProvider); final series = await source.fetchSeries(comic.listingUrl); final titleId = await queue.upsertSeries(series); if (!mounted) return; messenger.hideCurrentSnackBar(); context.push('/title/$titleId'); } catch (e) { if (!mounted) return; messenger.hideCurrentSnackBar(); messenger.showSnackBar(SnackBar(content: Text('Failed: $e'))); } } Widget _grid() { if (_loading && _items.isEmpty) { return const Center(child: CircularProgressIndicator()); } if (_items.isEmpty) { final empty = widget.menuKind == CatalogMenuKind.search ? 'Type at least 2 characters to search' : 'No comics found'; return Center(child: Text(empty)); } return RefreshIndicator( onRefresh: () => _load(reset: true), child: NotificationListener( onNotification: (n) { if (n.metrics.pixels > n.metrics.maxScrollExtent - 400) { _load(reset: false); } return false; }, child: GridView.builder( padding: const EdgeInsets.all(12), gridDelegate: (() { final tablet = MediaQuery.sizeOf(context).shortestSide >= 600; return SliverGridDelegateWithFixedCrossAxisCount( crossAxisCount: tablet ? 5 : 3, childAspectRatio: tablet ? 0.58 : 0.55, crossAxisSpacing: 10, mainAxisSpacing: 10, ); })(), itemCount: _items.length + (_loadingMore ? 1 : 0), itemBuilder: (context, i) { if (i >= _items.length) { return const Center(child: CircularProgressIndicator()); } final c = _items[i]; final tablet = MediaQuery.sizeOf(context).shortestSide >= 600; return ComicGridTile( comic: c, onTap: () => _openComic(c), titleMaxLines: tablet ? 1 : 2, ); }, ), ), ); } @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: Text('${_source.displayName} · $_menuTitle'), actions: [ const PeekModeToggleButton(), IconButton( tooltip: 'Settings', icon: const Icon(Icons.settings_outlined), onPressed: () => context.push('/settings'), ), ], ), body: Column( children: [ if (_error != null) MaterialBanner( content: Text(_error!, maxLines: 3, overflow: TextOverflow.ellipsis), actions: [ TextButton( onPressed: () => setState(() => _error = null), child: const Text('Dismiss'), ), ], ), if (widget.menuKind == CatalogMenuKind.search) Padding( padding: const EdgeInsets.fromLTRB(12, 12, 12, 8), child: TextField( controller: _searchCtrl, textInputAction: TextInputAction.search, decoration: InputDecoration( hintText: 'Search comics…', prefixIcon: const Icon(Icons.search), border: const OutlineInputBorder(), suffixIcon: IconButton( icon: const Icon(Icons.clear), onPressed: () { _searchCtrl.clear(); _runSearch(''); }, ), ), onChanged: (v) { Future.delayed(const Duration(milliseconds: 250), () { if (_searchCtrl.text == v) _runSearch(v); }); }, onSubmitted: _runSearch, ), ), if (_showDirectoryFilters) MangaTownDirectoryFilters( value: _browse, onChanged: _onBrowseChanged, ), Expanded(child: _grid()), ], ), ); } }