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,451 @@
|
||||
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<TitlePage> createState() => _TitlePageState();
|
||||
}
|
||||
|
||||
class _TitlePageState extends ConsumerState<TitlePage> {
|
||||
Title? _title;
|
||||
List<Chapter> _chapters = const [];
|
||||
final Set<int> _selected = {};
|
||||
bool _newestFirst = true;
|
||||
bool _loading = true;
|
||||
bool _syncing = false;
|
||||
String? _error;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_reload();
|
||||
}
|
||||
|
||||
List<Chapter> _sortedReadingOrder(List<Chapter> 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<void> _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<void> _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<void> _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<void> _downloadAll() async {
|
||||
if (_chapters.isEmpty) return;
|
||||
var unreadOnly = true;
|
||||
final confirmed = await showDialog<bool>(
|
||||
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<void> _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 = <String>[];
|
||||
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);
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user