Files
cxyz/cxyz-android/lib/ui/catalog_page.dart
T
nsrb a7c6058540 Add cxyz Android project structure and initial configuration
- 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.
2026-08-29 17:50:31 +07:00

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()),
],
),
);
}
}