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,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';
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user