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:
2026-08-29 17:50:31 +07:00
parent 6e7f1ced35
commit a7c6058540
105 changed files with 16721 additions and 275 deletions
+320
View File
@@ -0,0 +1,320 @@
import 'package:flutter/material.dart';
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';
class DownloadsPage extends ConsumerWidget {
const DownloadsPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final queue = ref.watch(downloadQueueProvider);
final queueAsync = ref.watch(queueJobsProvider);
final downloadedAsync = ref.watch(downloadedSeriesProvider);
return Scaffold(
appBar: AppBar(title: const Text('Downloads')),
body: queueAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, _) => Center(child: Text('$e')),
data: (queueJobs) {
return downloadedAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, _) => Center(child: Text('$e')),
data: (groups) {
final queued =
queueJobs.where((j) => j.status == 'queued').length;
final failed =
queueJobs.where((j) => j.status == 'failed').length;
final pendingTotal = queued + failed;
final hasDownloads = groups.isNotEmpty;
final showRunning = queue.isRunning;
if (!showRunning && !hasDownloads && pendingTotal == 0) {
return const Center(
child: Padding(
padding: EdgeInsets.all(24),
child: Text(
'No downloads yet.\nSelect chapters on a series and tap Download.',
textAlign: TextAlign.center,
),
),
);
}
final queueLabel = failed > 0
? 'Queue ($pendingTotal · $failed failed)'
: 'Queue ($pendingTotal)';
return CustomScrollView(
slivers: [
if (showRunning)
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 4),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
queue.message.isEmpty
? 'Downloading…'
: queue.message,
style: Theme.of(context).textTheme.bodyMedium,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 8),
LinearProgressIndicator(
value: queue.progress.clamp(0.0, 1.0),
),
],
),
),
),
if (pendingTotal > 0)
SliverToBoxAdapter(
child: ListTile(
leading: const Icon(Icons.queue),
title: Text(queueLabel),
trailing: const Icon(Icons.chevron_right),
onTap: () => context.push('/downloads/queue'),
),
),
if (groups.isNotEmpty) ...[
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
child: Text(
'Downloaded',
style: Theme.of(context).textTheme.titleMedium,
),
),
),
SliverList.builder(
itemCount: groups.length,
itemBuilder: (context, i) {
return _DownloadedSeriesTile(group: groups[i]);
},
),
],
const SliverToBoxAdapter(child: SizedBox(height: 24)),
],
);
},
);
},
),
);
}
}
class DownloadsQueuePage extends ConsumerWidget {
const DownloadsQueuePage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final queueAsync = ref.watch(queueJobsProvider);
return Scaffold(
appBar: AppBar(title: const Text('Queue')),
body: queueAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, _) => Center(child: Text('$e')),
data: (jobs) {
final list = jobs
.where((j) => j.status == 'queued' || j.status == 'failed')
.toList();
if (list.isEmpty) {
return const Center(child: Text('Queue is empty.'));
}
return ListView.builder(
itemCount: list.length,
itemBuilder: (context, i) => _QueueJobTile(job: list[i]),
);
},
),
);
}
}
class _QueueJobTile extends ConsumerWidget {
const _QueueJobTile({required this.job});
final QueueJobView job;
@override
Widget build(BuildContext context, WidgetRef ref) {
final statusLabel = switch (job.status) {
'running' => 'Downloading',
'queued' => 'Queued',
'failed' => 'Failed',
_ => job.status,
};
return ListTile(
leading: Icon(_iconFor(job.status)),
title: Text(
job.seriesTitle,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'${job.chapterLabel} · $statusLabel',
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
if (job.status == 'failed' && job.error != null) ...[
const SizedBox(height: 2),
Text(
job.error!,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: Theme.of(context).colorScheme.error,
fontSize: 12,
),
),
],
],
),
isThreeLine: job.status == 'failed',
trailing: job.status == 'failed'
? IconButton(
tooltip: 'Retry',
icon: const Icon(Icons.refresh),
onPressed: job.chapterId == null
? null
: () => ref
.read(downloadQueueProvider)
.retryChapter(job.chapterId!),
)
: job.status == 'queued'
? IconButton(
tooltip: 'Cancel',
icon: const Icon(Icons.cancel_outlined),
onPressed: job.chapterId == null
? null
: () => ref
.read(downloadQueueProvider)
.cancelChapter(job.chapterId!),
)
: null,
onTap: job.titleId == null
? null
: () => context.push('/title/${job.titleId}'),
);
}
IconData _iconFor(String status) {
switch (status) {
case 'failed':
return Icons.error_outline;
case 'queued':
return Icons.hourglass_empty;
default:
return Icons.download_outlined;
}
}
}
class _DownloadedSeriesTile extends ConsumerWidget {
const _DownloadedSeriesTile({required this.group});
final DownloadedSeriesGroup group;
@override
Widget build(BuildContext context, WidgetRef ref) {
final sourceId = SourceId.tryParse(group.source);
return ExpansionTile(
leading: SizedBox(
width: 40,
height: 56,
child: ClipRRect(
borderRadius: BorderRadius.circular(4),
child: ComicCover(url: group.coverUrl, sourceId: sourceId),
),
),
title: Text(
group.seriesTitle,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
subtitle: Text(
'${group.chapters.length} chapter${group.chapters.length == 1 ? '' : 's'}',
),
children: [
for (final ch in group.chapters)
Dismissible(
key: ValueKey('dl-${ch.chapterId}'),
direction: DismissDirection.endToStart,
background: Container(
color: Theme.of(context).colorScheme.error,
alignment: Alignment.centerRight,
padding: const EdgeInsets.symmetric(horizontal: 16),
child: const Icon(Icons.delete_outline, color: Colors.white),
),
confirmDismiss: (_) async {
final ok = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Delete download?'),
content: Text(
'Remove “${ch.chapterLabel}” from Downloads and delete the CBZ file.',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () => Navigator.pop(ctx, true),
child: const Text('Delete'),
),
],
),
);
return ok == true;
},
onDismissed: (_) {
ref.read(downloadQueueProvider).deleteDownload(ch.chapterId);
},
child: ListTile(
contentPadding: const EdgeInsets.only(left: 72, right: 8),
title: Text(ch.chapterLabel),
subtitle: ch.downloadedAt == null
? null
: Text(_formatDate(ch.downloadedAt!)),
trailing: PopupMenuButton<String>(
onSelected: (v) {
if (v == 'delete') {
ref
.read(downloadQueueProvider)
.deleteDownload(ch.chapterId);
}
},
itemBuilder: (context) => const [
PopupMenuItem(value: 'delete', child: Text('Delete')),
],
),
onTap: () {
context.push('/read/${ch.titleId}/${ch.chapterId}');
},
),
),
],
);
}
String _formatDate(DateTime dt) {
final local = dt.toLocal();
final y = local.year.toString().padLeft(4, '0');
final m = local.month.toString().padLeft(2, '0');
final d = local.day.toString().padLeft(2, '0');
return 'Downloaded $y-$m-$d';
}
}