diff --git a/lib/pages/comic_details_page/comic_page.dart b/lib/pages/comic_details_page/comic_page.dart index 3807c4b..67708ba 100644 --- a/lib/pages/comic_details_page/comic_page.dart +++ b/lib/pages/comic_details_page/comic_page.dart @@ -1,7 +1,10 @@ +import 'dart:async'; import 'dart:collection'; +import 'dart:ui'; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import 'package:photo_view/photo_view.dart'; import 'package:shimmer_animation/shimmer_animation.dart'; import 'package:sliver_tools/sliver_tools.dart'; import 'package:url_launcher/url_launcher_string.dart'; @@ -22,6 +25,7 @@ import 'package:venera/pages/favorites/favorites_page.dart'; import 'package:venera/pages/reader/reader.dart'; import 'package:venera/utils/app_links.dart'; import 'package:venera/utils/ext.dart'; +import 'package:venera/utils/file_type.dart'; import 'package:venera/utils/io.dart'; import 'package:venera/utils/tags_translation.dart'; import 'package:venera/utils/translations.dart'; @@ -39,6 +43,8 @@ part 'comments_preview.dart'; part 'actions.dart'; +part 'cover_viewer.dart'; + class ComicPage extends StatefulWidget { const ComicPage({ super.key, @@ -296,31 +302,35 @@ class _ComicPageState extends LoadingState crossAxisAlignment: CrossAxisAlignment.start, children: [ const SizedBox(width: 16), - Hero( - tag: "cover${widget.heroID}", - child: Container( - decoration: BoxDecoration( - color: context.colorScheme.primaryContainer, - borderRadius: BorderRadius.circular(8), - boxShadow: [ - BoxShadow( - color: context.colorScheme.outlineVariant, - blurRadius: 1, - offset: const Offset(0, 1), - ), - ], - ), - height: 144, - width: 144 * 0.72, - clipBehavior: Clip.antiAlias, - child: AnimatedImage( - image: CachedImageProvider( - widget.cover ?? comic.cover, - sourceKey: comic.sourceKey, - cid: comic.id, + GestureDetector( + onTap: () => _viewCover(context), + onLongPress: () => _saveCover(context), + child: Hero( + tag: "cover${widget.heroID}", + child: Container( + decoration: BoxDecoration( + color: context.colorScheme.primaryContainer, + borderRadius: BorderRadius.circular(8), + boxShadow: [ + BoxShadow( + color: context.colorScheme.outlineVariant, + blurRadius: 1, + offset: const Offset(0, 1), + ), + ], + ), + height: 144, + width: 144 * 0.72, + clipBehavior: Clip.antiAlias, + child: AnimatedImage( + image: CachedImageProvider( + widget.cover ?? comic.cover, + sourceKey: comic.sourceKey, + cid: comic.id, + ), + width: double.infinity, + height: double.infinity, ), - width: double.infinity, - height: double.infinity, ), ), ), @@ -723,6 +733,54 @@ class _ComicPageState extends LoadingState } return _CommentsPart(comments: comic.comments!, showMore: showComments); } + + void _viewCover(BuildContext context) { + final imageProvider = CachedImageProvider( + widget.cover ?? comic.cover, + sourceKey: comic.sourceKey, + cid: comic.id, + ); + + context.to( + () => _CoverViewer( + imageProvider: imageProvider, + title: comic.title, + heroTag: "cover${widget.heroID}", + ), + ); + } + + void _saveCover(BuildContext context) async { + try { + final imageProvider = CachedImageProvider( + widget.cover ?? comic.cover, + sourceKey: comic.sourceKey, + cid: comic.id, + ); + + final imageStream = imageProvider.resolve(const ImageConfiguration()); + final completer = Completer(); + + imageStream.addListener( + ImageStreamListener((ImageInfo info, bool _) async { + final byteData = await info.image.toByteData( + format: ImageByteFormat.png, + ); + if (byteData != null) { + completer.complete(byteData.buffer.asUint8List()); + } + }), + ); + + final data = await completer.future; + final fileType = detectFileType(data); + await saveFile(filename: "cover${fileType.ext}", data: data); + } catch (e) { + if (context.mounted) { + context.showMessage(message: "Error".tl); + } + } + } } class _ActionButton extends StatelessWidget { diff --git a/lib/pages/comic_details_page/cover_viewer.dart b/lib/pages/comic_details_page/cover_viewer.dart new file mode 100644 index 0000000..45d1eab --- /dev/null +++ b/lib/pages/comic_details_page/cover_viewer.dart @@ -0,0 +1,140 @@ +part of 'comic_page.dart'; + +class _CoverViewer extends StatefulWidget { + const _CoverViewer({ + required this.imageProvider, + required this.title, + required this.heroTag, + }); + + final ImageProvider imageProvider; + final String title; + final String heroTag; + + @override + State<_CoverViewer> createState() => _CoverViewerState(); +} + +class _CoverViewerState extends State<_CoverViewer> { + bool isAppBarShow = true; + + @override + Widget build(BuildContext context) { + return PopScope( + canPop: true, + child: Scaffold( + backgroundColor: context.colorScheme.surface, + body: Stack( + children: [ + Positioned.fill( + child: PhotoView( + imageProvider: widget.imageProvider, + minScale: PhotoViewComputedScale.contained * 1.0, + maxScale: PhotoViewComputedScale.covered * 3.0, + backgroundDecoration: BoxDecoration( + color: context.colorScheme.surface, + ), + loadingBuilder: (context, event) => Center( + child: SizedBox( + width: 24.0, + height: 24.0, + child: CircularProgressIndicator( + value: event == null || event.expectedTotalBytes == null + ? null + : event.cumulativeBytesLoaded / + event.expectedTotalBytes!, + ), + ), + ), + onTapUp: (context, details, controllerValue) { + setState(() { + isAppBarShow = !isAppBarShow; + }); + }, + heroAttributes: PhotoViewHeroAttributes(tag: widget.heroTag), + ), + ), + AnimatedPositioned( + top: isAppBarShow ? 0 : -(context.padding.top + 52), + left: 0, + right: 0, + duration: const Duration(milliseconds: 180), + child: _buildAppBar(), + ), + ], + ), + ), + ); + } + + Widget _buildAppBar() { + return Material( + color: context.colorScheme.surface.toOpacity(0.72), + child: BlurEffect( + child: Container( + decoration: BoxDecoration( + border: Border( + bottom: BorderSide( + color: context.colorScheme.outlineVariant, + width: 0.5, + ), + ), + ), + height: 52, + child: Row( + children: [ + const SizedBox(width: 8), + IconButton( + icon: const Icon(Icons.close), + onPressed: () { + Navigator.of(context).pop(); + }, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + widget.title, + style: const TextStyle(fontSize: 18), + overflow: TextOverflow.ellipsis, + ), + ), + IconButton( + icon: const Icon(Icons.save_alt), + onPressed: _saveCover, + ), + const SizedBox(width: 8), + ], + ), + ).paddingTop(context.padding.top), + ), + ); + } + + void _saveCover() async { + try { + final imageStream = widget.imageProvider.resolve( + const ImageConfiguration(), + ); + final completer = Completer(); + + imageStream.addListener( + ImageStreamListener((ImageInfo info, bool _) async { + final byteData = await info.image.toByteData( + format: ImageByteFormat.png, + ); + if (byteData != null) { + completer.complete(byteData.buffer.asUint8List()); + } + }), + ); + + final data = await completer.future; + final fileType = detectFileType(data); + await saveFile(filename: "cover_${widget.title}${fileType.ext}", data: data); + } catch (e) { + if (mounted) { + context.showMessage(message: "Error".tl); + } + } + } +}