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
@@ -0,0 +1,111 @@
import 'package:flutter/material.dart';
import '../../sources/mangatown/urls.dart';
import '../../sources/source.dart';
/// MangaTown Directory filter bar (demographic / genre / status / initial).
class MangaTownDirectoryFilters extends StatelessWidget {
const MangaTownDirectoryFilters({
super.key,
required this.value,
required this.onChanged,
});
final CatalogBrowseParams value;
final ValueChanged<CatalogBrowseParams> onChanged;
@override
Widget build(BuildContext context) {
final initials = mangaTownInitials();
return Padding(
padding: const EdgeInsets.fromLTRB(12, 8, 12, 4),
child: Column(
children: [
Row(
children: [
Expanded(
child: _FilterDropdown(
label: 'Demographic',
value: value.demographic,
entries: mangaTownDemographics,
onChanged: (v) => onChanged(value.copyWith(demographic: v)),
),
),
const SizedBox(width: 8),
Expanded(
child: _FilterDropdown(
label: 'Status',
value: value.status,
entries: mangaTownStatuses,
onChanged: (v) => onChanged(value.copyWith(status: v)),
),
),
],
),
const SizedBox(height: 8),
Row(
children: [
Expanded(
child: _FilterDropdown(
label: 'Genre',
value: value.genre,
entries: mangaTownGenres,
onChanged: (v) => onChanged(value.copyWith(genre: v)),
),
),
const SizedBox(width: 8),
Expanded(
child: _FilterDropdown(
label: 'Letter',
value: value.initial,
entries: initials,
onChanged: (v) => onChanged(value.copyWith(initial: v)),
),
),
],
),
],
),
);
}
}
class _FilterDropdown extends StatelessWidget {
const _FilterDropdown({
required this.label,
required this.value,
required this.entries,
required this.onChanged,
});
final String label;
final String value;
final List<MapEntry<String, String>> entries;
final ValueChanged<String> onChanged;
@override
Widget build(BuildContext context) {
final known = entries.any((e) => e.key == value) ? value : '0';
return InputDecorator(
decoration: InputDecoration(
labelText: label,
border: const OutlineInputBorder(),
contentPadding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
isDense: true,
),
child: DropdownButtonHideUnderline(
child: DropdownButton<String>(
isExpanded: true,
value: known,
items: [
for (final e in entries)
DropdownMenuItem(value: e.key, child: Text(e.value)),
],
onChanged: (v) {
if (v != null) onChanged(v);
},
),
),
);
}
}
@@ -0,0 +1,197 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../../providers/providers.dart';
import '../../sources/source.dart';
import '../widgets/comic_cover.dart';
import '../widgets/source_logo.dart';
/// Catalog tab root helpers live in the router redirect; picker is [SourcePickerPage].
const _kLastCatalogSource = 'catalog_last_source_id';
Future<void> saveLastCatalogSource(SourceId id) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_kLastCatalogSource, id.name);
}
Future<SourceId?> loadLastCatalogSource() async {
final prefs = await SharedPreferences.getInstance();
final raw = prefs.getString(_kLastCatalogSource);
if (raw == null) return null;
return SourceId.tryParse(raw);
}
class SourcePickerPage extends ConsumerStatefulWidget {
const SourcePickerPage({super.key});
@override
ConsumerState<SourcePickerPage> createState() => _SourcePickerPageState();
}
class _SourcePickerPageState extends ConsumerState<SourcePickerPage> {
SourceId? _last;
@override
void initState() {
super.initState();
loadLastCatalogSource().then((id) {
if (!mounted) return;
setState(() => _last = id);
});
}
@override
Widget build(BuildContext context) {
final registry = ref.watch(sourceRegistryProvider);
final sources = registry.all;
final last = _last;
final continueSource =
last != null && registry.isEnabled(last) ? registry.get(last) : null;
return Scaffold(
appBar: AppBar(
title: const Text('Catalog'),
actions: [
const PeekModeToggleButton(),
IconButton(
tooltip: 'Settings',
icon: const Icon(Icons.settings_outlined),
onPressed: () => context.push('/settings'),
),
],
),
body: ListView(
padding: const EdgeInsets.symmetric(vertical: 8),
children: [
if (continueSource != null)
Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 4),
child: Align(
alignment: Alignment.centerLeft,
child: ActionChip(
avatar: SourceLogo(sourceId: continueSource.id, size: 18),
label: Text('Continue · ${continueSource.displayName}'),
onPressed: () => _open(continueSource.id),
),
),
),
for (final s in sources)
ListTile(
leading: SourceLogo(sourceId: s.id),
title: Text(s.displayName),
subtitle: Text(s.blurb),
trailing: const Icon(Icons.chevron_right),
onTap: () => _open(s.id),
),
],
),
);
}
void _open(SourceId id) {
saveLastCatalogSource(id);
context.push('/catalog/${id.name}');
}
}
/// Lists [ComicSource.catalogMenus] for one enabled source.
class SourceHomePage extends ConsumerWidget {
const SourceHomePage({
super.key,
required this.sourceId,
this.embeddedAsRoot = false,
});
final SourceId sourceId;
final bool embeddedAsRoot;
@override
Widget build(BuildContext context, WidgetRef ref) {
final registry = ref.watch(sourceRegistryProvider);
if (!registry.isEnabled(sourceId)) {
return Scaffold(
appBar: AppBar(title: const Text('Catalog')),
body: const Center(child: Text('Source not enabled in this build.')),
);
}
final source = registry.get(sourceId);
final menus = source.catalogMenus;
return Scaffold(
appBar: AppBar(
title: Row(
mainAxisSize: MainAxisSize.min,
children: [
SourceLogo(sourceId: sourceId, size: 24),
const SizedBox(width: 8),
Text(source.displayName),
],
),
leading: embeddedAsRoot
? null
: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () {
if (context.canPop()) {
context.pop();
} else {
context.go('/catalog');
}
},
),
actions: [
const PeekModeToggleButton(),
IconButton(
tooltip: 'Settings',
icon: const Icon(Icons.settings_outlined),
onPressed: () => context.push('/settings'),
),
],
),
body: ListView.separated(
itemCount: menus.length,
separatorBuilder: (_, _) => const Divider(height: 1),
itemBuilder: (context, i) {
final menu = menus[i];
return ListTile(
leading: Icon(_iconFor(menu.kind)),
title: Text(menu.title),
trailing: const Icon(Icons.chevron_right),
onTap: () {
saveLastCatalogSource(sourceId);
context.push('/catalog/${sourceId.name}/${menu.kind.pathSegment}');
},
);
},
),
);
}
IconData _iconFor(CatalogMenuKind kind) {
switch (kind) {
case CatalogMenuKind.popular:
return Icons.local_fire_department_outlined;
case CatalogMenuKind.latest:
return Icons.schedule_outlined;
case CatalogMenuKind.official:
return Icons.verified_outlined;
case CatalogMenuKind.uncensored:
return Icons.visibility_outlined;
case CatalogMenuKind.recommended:
return Icons.thumb_up_outlined;
case CatalogMenuKind.search:
return Icons.search;
case CatalogMenuKind.hotToday:
return Icons.wb_sunny_outlined;
case CatalogMenuKind.hotWeek:
return Icons.date_range_outlined;
case CatalogMenuKind.hotMonth:
return Icons.calendar_month_outlined;
case CatalogMenuKind.directory:
return Icons.grid_view_outlined;
}
}
}
+272
View File
@@ -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()),
],
),
);
}
}
+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';
}
}
+63
View File
@@ -0,0 +1,63 @@
import 'package:flutter/material.dart' hide Title;
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../providers/providers.dart';
import '../sources/source.dart';
import 'widgets/comic_cover.dart';
class HistoryPage extends ConsumerWidget {
const HistoryPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final titlesAsync = ref.watch(historyTitlesProvider);
return Scaffold(
appBar: AppBar(title: const Text('History')),
body: titlesAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, _) => Center(child: Text('Error: $e')),
data: (titles) {
if (titles.isEmpty) {
return const Center(
child: Text('Chapters you read will show up here.'),
);
}
return ListView.separated(
itemCount: titles.length,
separatorBuilder: (context, index) => const Divider(height: 1),
itemBuilder: (context, i) {
final t = titles[i];
final sourceId = SourceId.tryParse(t.source);
final label = sourceId?.label ?? t.source;
final when = t.lastReadAt;
final subtitle = when == null
? label
: '$label · ${_formatWhen(when)}';
return ListTile(
leading: SizedBox(
width: 48,
height: 64,
child: ComicCover(url: t.coverUrl, sourceId: sourceId),
),
title: Text(t.title),
subtitle: Text(subtitle),
onTap: () => context.push('/title/${t.id}'),
);
},
);
},
),
);
}
String _formatWhen(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');
final hh = local.hour.toString().padLeft(2, '0');
final mm = local.minute.toString().padLeft(2, '0');
return '$y-$m-$d $hh:$mm';
}
}
+56
View File
@@ -0,0 +1,56 @@
import 'package:flutter/material.dart' hide Title;
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../providers/providers.dart';
import '../sources/source.dart';
import 'widgets/comic_cover.dart';
class LibraryPage extends ConsumerWidget {
const LibraryPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final titlesAsync = ref.watch(libraryTitlesProvider);
return Scaffold(
appBar: AppBar(title: const Text('Library')),
body: titlesAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, _) => Center(child: Text('Error: $e')),
data: (titles) {
if (titles.isEmpty) {
return const Center(
child: Padding(
padding: EdgeInsets.all(24),
child: Text(
'Star a series on its title page to add it to your Library.',
textAlign: TextAlign.center,
),
),
);
}
return ListView.separated(
itemCount: titles.length,
separatorBuilder: (context, index) => const Divider(height: 1),
itemBuilder: (context, i) {
final t = titles[i];
final sourceId = SourceId.tryParse(t.source);
final label = sourceId?.label ?? t.source;
return ListTile(
leading: SizedBox(
width: 48,
height: 64,
child: ComicCover(url: t.coverUrl, sourceId: sourceId),
),
title: Text(t.title),
subtitle: Text(label),
trailing: const Icon(Icons.star, size: 18),
onTap: () => context.push('/title/${t.id}'),
);
},
);
},
),
);
}
}
File diff suppressed because it is too large Load Diff
+397
View File
@@ -0,0 +1,397 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../providers/providers.dart';
import '../services/ntfy_settings.dart';
import '../services/reader_direction.dart';
import '../services/webdav_settings.dart';
class SettingsPage extends ConsumerStatefulWidget {
const SettingsPage({super.key});
@override
ConsumerState<SettingsPage> createState() => _SettingsPageState();
}
class _SettingsPageState extends ConsumerState<SettingsPage> {
final _urlCtrl = TextEditingController();
final _userCtrl = TextEditingController();
final _passCtrl = TextEditingController();
final _pathCtrl = TextEditingController();
final _ntfyCompletedCtrl = TextEditingController();
final _ntfyFailedCtrl = TextEditingController();
final _ntfyDrainedCtrl = TextEditingController();
final _ntfyTokenCtrl = TextEditingController();
bool _enabled = false;
bool _ntfyEnabled = false;
NtfyAttachmentMode _ntfyAttachment = NtfyAttachmentMode.none;
bool _loading = true;
bool _busy = false;
String? _status;
String? _error;
DateTime? _lastSync;
@override
void initState() {
super.initState();
_load();
}
@override
void dispose() {
_urlCtrl.dispose();
_userCtrl.dispose();
_passCtrl.dispose();
_pathCtrl.dispose();
_ntfyCompletedCtrl.dispose();
_ntfyFailedCtrl.dispose();
_ntfyDrainedCtrl.dispose();
_ntfyTokenCtrl.dispose();
super.dispose();
}
Future<void> _load() async {
final s = await WebDavSettings.load();
final n = await NtfySettings.load();
if (!mounted) return;
setState(() {
_enabled = s.enabled;
_urlCtrl.text = s.url;
_userCtrl.text = s.username;
_passCtrl.text = s.password;
_pathCtrl.text = s.path;
_status = s.lastStatus;
_error = s.lastError;
_lastSync = s.lastSyncAt;
_ntfyEnabled = n.enabled;
_ntfyCompletedCtrl.text = n.completedUrl;
_ntfyFailedCtrl.text = n.failedUrl;
_ntfyDrainedCtrl.text = n.drainedUrl;
_ntfyTokenCtrl.text = n.authToken;
_ntfyAttachment = n.attachmentMode;
_loading = false;
});
}
Future<void> _save() async {
await WebDavSettings.save(
enabled: _enabled,
url: _urlCtrl.text,
username: _userCtrl.text,
password: _passCtrl.text,
path: _pathCtrl.text,
);
await NtfySettings.save(
enabled: _ntfyEnabled,
completedUrl: _ntfyCompletedCtrl.text,
failedUrl: _ntfyFailedCtrl.text,
drainedUrl: _ntfyDrainedCtrl.text,
attachmentMode: _ntfyAttachment,
authToken: _ntfyTokenCtrl.text,
);
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Settings saved')),
);
}
Future<void> _test() async {
setState(() => _busy = true);
try {
await _save();
final msg = await ref.read(webDavSyncProvider).testConnection();
if (!mounted) return;
setState(() {
_status = msg;
_error = null;
});
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(msg)));
} catch (e) {
if (!mounted) return;
setState(() {
_status = 'Test failed';
_error = e.toString();
});
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Test failed: $e')),
);
} finally {
if (mounted) setState(() => _busy = false);
}
}
Future<void> _syncNow() async {
setState(() => _busy = true);
try {
await _save();
await ref.read(webDavSyncProvider).sync();
await _load();
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Sync finished')),
);
} catch (e) {
await _load();
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Sync failed: $e')),
);
} finally {
if (mounted) setState(() => _busy = false);
}
}
Future<void> _testNtfy(String kind, TextEditingController ctrl) async {
setState(() => _busy = true);
try {
await _save();
await ref.read(ntfyNotifierProvider).testTopic(ctrl.text, kind: kind);
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Test notification sent')),
);
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('ntfy test failed: $e')),
);
} finally {
if (mounted) setState(() => _busy = false);
}
}
Widget _ntfyUrlRow({
required String label,
required TextEditingController controller,
required String kind,
}) {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
TextField(
controller: controller,
decoration: InputDecoration(
labelText: label,
hintText: 'https://ntfy.sh/mytopic',
),
keyboardType: TextInputType.url,
autocorrect: false,
enabled: _ntfyEnabled,
),
Align(
alignment: Alignment.centerRight,
child: TextButton(
onPressed: !_ntfyEnabled || _busy
? null
: () => _testNtfy(kind, controller),
child: const Text('Test'),
),
),
],
);
}
@override
Widget build(BuildContext context) {
final direction = ref.watch(readingDirectionProvider);
if (_loading) {
return const Scaffold(
body: Center(child: CircularProgressIndicator()),
);
}
return Scaffold(
appBar: AppBar(
title: const Text('Settings'),
actions: [
TextButton(
onPressed: _busy ? null : _save,
child: const Text('Save'),
),
],
),
body: SafeArea(
top: false,
child: ListView(
padding: const EdgeInsets.all(16),
children: [
Text('Reader', style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 4),
Text(
'Fallback page direction when a title has no saved preference '
'(ComicLand). MangaRead / MangaTown default to Right → Left; '
'overrides are saved per title and sync via WebDAV.',
style: Theme.of(context).textTheme.bodySmall,
),
const SizedBox(height: 8),
SegmentedButton<ReadingDirection>(
segments: const [
ButtonSegment(
value: ReadingDirection.ltr,
label: Text('Left → Right'),
icon: Icon(Icons.format_textdirection_l_to_r),
),
ButtonSegment(
value: ReadingDirection.rtl,
label: Text('Right → Left'),
icon: Icon(Icons.format_textdirection_r_to_l),
),
],
selected: {direction},
onSelectionChanged: (s) {
ref.read(readingDirectionProvider.notifier).setDirection(s.first);
},
),
const SizedBox(height: 24),
Text('WebDAV sync', style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 4),
Text(
'Syncs reading progress, reader mode/direction, Library stars, '
'and History across devices.',
style: Theme.of(context).textTheme.bodySmall,
),
SwitchListTile(
contentPadding: EdgeInsets.zero,
title: const Text('Enable sync'),
value: _enabled,
onChanged: (v) => setState(() => _enabled = v),
),
TextField(
controller: _urlCtrl,
decoration: const InputDecoration(
labelText: 'WebDAV URL',
hintText: 'https://dav.example.com/remote.php/dav/files/user',
),
keyboardType: TextInputType.url,
autocorrect: false,
),
TextField(
controller: _userCtrl,
decoration: const InputDecoration(labelText: 'Username'),
autocorrect: false,
),
TextField(
controller: _passCtrl,
decoration: const InputDecoration(labelText: 'Password'),
obscureText: true,
autocorrect: false,
),
TextField(
controller: _pathCtrl,
decoration: const InputDecoration(
labelText: 'Remote file path',
hintText: WebDavSettings.defaultPath,
),
autocorrect: false,
),
const SizedBox(height: 12),
if (_status != null)
Text('Status: $_status', style: Theme.of(context).textTheme.bodySmall),
if (_lastSync != null)
Text(
'Last sync: ${_lastSync!.toLocal()}',
style: Theme.of(context).textTheme.bodySmall,
),
if (_error != null)
Text(
_error!,
style: TextStyle(color: Theme.of(context).colorScheme.error),
),
const SizedBox(height: 12),
Row(
children: [
FilledButton(
onPressed: _busy ? null : _test,
child: const Text('Test connection'),
),
const SizedBox(width: 12),
OutlinedButton(
onPressed: _busy || !_enabled ? null : _syncNow,
child: const Text('Sync now'),
),
],
),
const SizedBox(height: 32),
Text(
'Notifications (ntfy)',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 4),
Text(
'Send download events to ntfy topics. Leave a URL empty to disable that event.',
style: Theme.of(context).textTheme.bodySmall,
),
SwitchListTile(
contentPadding: EdgeInsets.zero,
title: const Text('Enable notifications'),
value: _ntfyEnabled,
onChanged: (v) => setState(() => _ntfyEnabled = v),
),
_ntfyUrlRow(
label: 'Chapter completed URL',
controller: _ntfyCompletedCtrl,
kind: 'completed',
),
_ntfyUrlRow(
label: 'Job failed URL',
controller: _ntfyFailedCtrl,
kind: 'failed',
),
_ntfyUrlRow(
label: 'Queue drained URL',
controller: _ntfyDrainedCtrl,
kind: 'drained',
),
const SizedBox(height: 8),
Text(
'Attachment on complete',
style: Theme.of(context).textTheme.bodyMedium,
),
const SizedBox(height: 8),
DropdownMenu<NtfyAttachmentMode>(
initialSelection: _ntfyAttachment,
enabled: _ntfyEnabled,
width: double.infinity,
dropdownMenuEntries: [
for (final m in NtfyAttachmentMode.values)
DropdownMenuEntry(value: m, label: m.label),
],
onSelected: !_ntfyEnabled
? null
: (v) {
if (v != null) setState(() => _ntfyAttachment = v);
},
),
const SizedBox(height: 4),
Text(
'Cover uses the series cover URL. First page is resized to ≤720px / ≤250 KB. CBZ skips if over 15 MB.',
style: Theme.of(context).textTheme.bodySmall,
),
TextField(
controller: _ntfyTokenCtrl,
decoration: const InputDecoration(
labelText: 'Auth token (optional)',
hintText: 'Bearer token for private topics',
),
obscureText: true,
autocorrect: false,
enabled: _ntfyEnabled,
),
if (_busy) ...[
const SizedBox(height: 16),
const LinearProgressIndicator(),
],
const SizedBox(height: 32),
Text('About', style: Theme.of(context).textTheme.titleMedium),
const ListTile(
contentPadding: EdgeInsets.zero,
title: Text('Version'),
subtitle: Text('1.0'),
),
],
),
),
);
}
}
+48
View File
@@ -0,0 +1,48 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
class AppShell extends StatelessWidget {
const AppShell({super.key, required this.navigationShell});
final StatefulNavigationShell navigationShell;
void _onTap(int index) {
navigationShell.goBranch(
index,
initialLocation: index == navigationShell.currentIndex,
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: navigationShell,
bottomNavigationBar: NavigationBar(
selectedIndex: navigationShell.currentIndex,
onDestinationSelected: _onTap,
destinations: const [
NavigationDestination(
icon: Icon(Icons.explore_outlined),
selectedIcon: Icon(Icons.explore),
label: 'Catalog',
),
NavigationDestination(
icon: Icon(Icons.collections_bookmark_outlined),
selectedIcon: Icon(Icons.collections_bookmark),
label: 'Library',
),
NavigationDestination(
icon: Icon(Icons.history_outlined),
selectedIcon: Icon(Icons.history),
label: 'History',
),
NavigationDestination(
icon: Icon(Icons.download_outlined),
selectedIcon: Icon(Icons.download),
label: 'Downloads',
),
],
),
);
}
}
+451
View File
@@ -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);
}
});
},
);
},
),
),
],
),
);
}
}
@@ -0,0 +1,178 @@
import 'dart:ui';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../providers/providers.dart';
import '../../sources/source.dart';
class ComicCover extends ConsumerStatefulWidget {
const ComicCover({
super.key,
required this.url,
this.sourceId,
this.width,
this.height,
this.fit = BoxFit.cover,
});
final String? url;
final SourceId? sourceId;
final double? width;
final double? height;
final BoxFit fit;
@override
ConsumerState<ComicCover> createState() => _ComicCoverState();
}
class _ComicCoverState extends ConsumerState<ComicCover> {
bool _revealed = false;
void _setRevealed(bool value) {
if (_revealed == value) return;
setState(() => _revealed = value);
}
@override
Widget build(BuildContext context) {
final peek = ref.watch(peekModeProvider);
final cache = ref.watch(imageCacheManagerProvider);
Map<String, String> headers = const {};
final sid = widget.sourceId;
if (sid != null) {
final registry = ref.watch(sourceRegistryProvider);
if (registry.isEnabled(sid)) {
headers = registry.get(sid).imageHeaders;
}
}
if (widget.url == null || widget.url!.isEmpty) {
return Container(
width: widget.width,
height: widget.height,
color: Theme.of(context).colorScheme.surfaceContainerHighest,
child: const Icon(Icons.menu_book_outlined),
);
}
final image = CachedNetworkImage(
imageUrl: widget.url!,
cacheManager: cache,
httpHeaders: headers,
width: widget.width,
height: widget.height,
fit: widget.fit,
placeholder: (context, url) => Container(
width: widget.width,
height: widget.height,
color: Theme.of(context).colorScheme.surfaceContainerHighest,
),
errorWidget: (context, url, error) => Container(
width: widget.width,
height: widget.height,
color: Theme.of(context).colorScheme.surfaceContainerHighest,
child: const Icon(Icons.broken_image_outlined),
),
);
if (!peek) return image;
if (_revealed) {
return GestureDetector(
behavior: HitTestBehavior.opaque,
onLongPressEnd: (_) => _setRevealed(false),
onLongPressCancel: () => _setRevealed(false),
child: image,
);
}
// Heavy blur + dark veil so covers are unreadable in public.
return GestureDetector(
behavior: HitTestBehavior.opaque,
onLongPressStart: (_) => _setRevealed(true),
child: SizedBox(
width: widget.width,
height: widget.height,
child: Stack(
fit: StackFit.expand,
children: [
ClipRect(
child: ImageFiltered(
imageFilter: ImageFilter.blur(
sigmaX: 36,
sigmaY: 36,
tileMode: TileMode.clamp,
),
child: image,
),
),
ColoredBox(
color: Colors.black.withValues(alpha: 0.45),
child: const Center(
child: Icon(
Icons.visibility_off,
color: Colors.white54,
size: 28,
),
),
),
],
),
),
);
}
}
class PeekModeToggleButton extends ConsumerWidget {
const PeekModeToggleButton({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final peek = ref.watch(peekModeProvider);
return IconButton(
tooltip: peek ? 'Peek mode on (covers hidden)' : 'Peek mode off',
onPressed: () => ref.read(peekModeProvider.notifier).toggle(),
icon: Icon(peek ? Icons.visibility_off : Icons.visibility),
);
}
}
class ComicGridTile extends StatelessWidget {
const ComicGridTile({
super.key,
required this.comic,
required this.onTap,
this.titleMaxLines = 2,
});
final CatalogComic comic;
final VoidCallback onTap;
final int titleMaxLines;
@override
Widget build(BuildContext context) {
return InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Expanded(
child: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: ComicCover(url: comic.coverUrl, sourceId: comic.source),
),
),
const SizedBox(height: 6),
Text(
comic.title,
maxLines: titleMaxLines,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodyMedium,
),
],
),
);
}
}
@@ -0,0 +1,34 @@
import 'package:flutter/material.dart';
import '../../sources/source.dart';
/// Bundled source favicon from [SourceId.logoAsset].
class SourceLogo extends StatelessWidget {
const SourceLogo({
super.key,
required this.sourceId,
this.size = 32,
this.borderRadius = 6,
});
final SourceId sourceId;
final double size;
final double borderRadius;
@override
Widget build(BuildContext context) {
return ClipRRect(
borderRadius: BorderRadius.circular(borderRadius),
child: Image.asset(
sourceId.logoAsset,
width: size,
height: size,
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) => Icon(
Icons.public,
size: size * 0.75,
),
),
);
}
}