- 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.
179 lines
4.7 KiB
Dart
179 lines
4.7 KiB
Dart
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,
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|