- Introduced a new Flutter project for the cxyz Android manga/comic reader, including essential files such as pubspec.yaml, AndroidManifest.xml, and Gradle configuration. - Added .gitignore files to exclude unnecessary files and directories for both the main project and Android-specific files. - Implemented initial README.md with project description, features, setup instructions, and API details for ComicLand, MangaRead, and MangaTown. - Configured analysis options and dependencies in pubspec.yaml to support Flutter development. - Established a pnpm workspace and lockfile for dependency management. - Included various Android resources such as icons, styles, and launch backgrounds for a complete application setup.
273 lines
7.9 KiB
Dart
273 lines
7.9 KiB
Dart
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<CatalogFeedPage> createState() => _CatalogFeedPageState();
|
|
}
|
|
|
|
class _CatalogFeedPageState extends ConsumerState<CatalogFeedPage> {
|
|
final _searchCtrl = TextEditingController();
|
|
List<CatalogComic> _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<void> _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<void> _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<void> _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<ScrollNotification>(
|
|
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<void>.delayed(const Duration(milliseconds: 250), () {
|
|
if (_searchCtrl.text == v) _runSearch(v);
|
|
});
|
|
},
|
|
onSubmitted: _runSearch,
|
|
),
|
|
),
|
|
if (_showDirectoryFilters)
|
|
MangaTownDirectoryFilters(
|
|
value: _browse,
|
|
onChanged: _onBrowseChanged,
|
|
),
|
|
Expanded(child: _grid()),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|