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.
This commit is contained in:
@@ -0,0 +1,272 @@
|
||||
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()),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user