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