diff --git a/lib/common/widgets/pull_to_refresh_header.dart b/lib/common/widgets/pull_to_refresh_header.dart deleted file mode 100644 index 46db5138..00000000 --- a/lib/common/widgets/pull_to_refresh_header.dart +++ /dev/null @@ -1,130 +0,0 @@ -// ignore_for_file: depend_on_referenced_packages - -import 'dart:math'; -import 'dart:ui' as ui show Image; - -import 'package:extended_image/extended_image.dart'; -import 'package:flutter/material.dart'; -import 'package:intl/intl.dart'; -import 'package:pull_to_refresh_notification/pull_to_refresh_notification.dart'; - -double get maxDragOffset => 100; -double hideHeight = maxDragOffset / 2.3; -double refreshHeight = maxDragOffset / 1.5; - -class PullToRefreshHeader extends StatelessWidget { - const PullToRefreshHeader( - this.info, - this.lastRefreshTime, { - this.color, - super.key, - }); - - final PullToRefreshScrollNotificationInfo? info; - final DateTime? lastRefreshTime; - final Color? color; - - @override - Widget build(BuildContext context) { - final PullToRefreshScrollNotificationInfo? infos = info; - if (infos == null) { - return const SizedBox(); - } - String text = ''; - if (infos.mode == PullToRefreshIndicatorMode.armed) { - text = 'Release to refresh'; - } else if (infos.mode == PullToRefreshIndicatorMode.refresh || - infos.mode == PullToRefreshIndicatorMode.snap) { - text = 'Loading...'; - } else if (infos.mode == PullToRefreshIndicatorMode.done) { - text = 'Refresh completed.'; - } else if (infos.mode == PullToRefreshIndicatorMode.drag) { - text = 'Pull to refresh'; - } else if (infos.mode == PullToRefreshIndicatorMode.canceled) { - text = 'Cancel refresh'; - } - - final TextStyle ts = const TextStyle( - color: Colors.grey, - ).copyWith(fontSize: 14); - - final double dragOffset = info?.dragOffset ?? 0.0; - - final DateTime time = lastRefreshTime ?? DateTime.now(); - final double top = -hideHeight + dragOffset; - return Container( - height: dragOffset, - color: color ?? Colors.transparent, - // padding: EdgeInsets.only(top: dragOffset / 3), - // padding: EdgeInsets.only(bottom: 5.0), - child: Stack( - children: [ - Positioned( - left: 0.0, - right: 0.0, - top: top, - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Expanded( - child: Container( - alignment: Alignment.centerRight, - margin: const EdgeInsets.only(right: 12.0), - child: RefreshImage(top, null), - ), - ), - Column( - children: [ - Text(text, style: ts), - Text( - 'Last updated:${DateFormat('yyyy-MM-dd hh:mm').format(time)}', - style: ts.copyWith(fontSize: 14), - ) - ], - ), - const Spacer(), - ], - ), - ) - ], - ), - ); - } -} - -class RefreshImage extends StatelessWidget { - const RefreshImage(this.top, Key? key) : super(key: key); - - final double top; - - @override - Widget build(BuildContext context) { - const double imageSize = 30; - return ExtendedImage.asset( - 'assets/flutterCandies_grey.png', - width: imageSize, - height: imageSize, - afterPaintImage: (Canvas canvas, Rect rect, ui.Image image, Paint paint) { - final double imageHeight = image.height.toDouble(); - final double imageWidth = image.width.toDouble(); - final Size size = rect.size; - final double y = - (1 - min(top / (refreshHeight - hideHeight), 1)) * imageHeight; - - canvas.drawImageRect( - image, - Rect.fromLTWH(0.0, y, imageWidth, imageHeight - y), - Rect.fromLTWH(rect.left, rect.top + y / imageHeight * size.height, - size.width, (imageHeight - y) / imageHeight * size.height), - Paint() - ..colorFilter = - const ColorFilter.mode(Color(0xFFea5504), BlendMode.srcIn) - ..isAntiAlias = false - ..filterQuality = FilterQuality.low, - ); - - //canvas.restore(); - }, - ); - } -} diff --git a/lib/pages/dynamics/widgets/content_panel.dart b/lib/pages/dynamics/widgets/content_panel.dart index e1beaeb2..28451dde 100644 --- a/lib/pages/dynamics/widgets/content_panel.dart +++ b/lib/pages/dynamics/widgets/content_panel.dart @@ -1,11 +1,11 @@ // 内容 +import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:pilipala/common/widgets/badge.dart'; import 'package:pilipala/common/widgets/network_img_layer.dart'; import 'package:pilipala/models/dynamics/result.dart'; -import 'package:pilipala/pages/preview/index.dart'; - +import 'package:pilipala/plugin/pl_gallery/index.dart'; import 'rich_node_panel.dart'; // ignore: must_be_immutable @@ -59,17 +59,15 @@ class _ContentState extends State { (pictureItem.height != null && pictureItem.width != null ? pictureItem.height! / pictureItem.width! : 1); - return GestureDetector( - onTap: () { - showDialog( - useSafeArea: false, - context: context, - builder: (context) { - return ImagePreview(initialPage: 0, imgList: picList); - }, - ); + return Hero( + tag: pictureItem.url!, + placeholderBuilder: + (BuildContext context, Size heroSize, Widget child) { + return child; }, - child: Container( + child: GestureDetector( + onTap: () => onPreviewImg(picList, 1, context), + child: Container( padding: const EdgeInsets.only(top: 4), constraints: BoxConstraints(maxHeight: maxHeight), width: box.maxWidth / 2, @@ -91,7 +89,9 @@ class _ContentState extends State { ) : const SizedBox(), ], - )), + ), + ), + ), ); }, ), @@ -102,26 +102,23 @@ class _ContentState extends State { List list = []; for (var i = 0; i < len; i++) { picList.add(pics[i].url!); + } + for (var i = 0; i < len; i++) { list.add( LayoutBuilder( builder: (context, BoxConstraints box) { double maxWidth = box.maxWidth.truncateToDouble(); - return GestureDetector( - onTap: () { - showDialog( - useSafeArea: false, - context: context, - builder: (context) { - return ImagePreview(initialPage: i, imgList: picList); - }, - ); - }, - child: NetworkImgLayer( - src: pics[i].url, - width: maxWidth, - height: maxWidth, - origAspectRatio: - pics[i].width!.toInt() / pics[i].height!.toInt(), + return Hero( + tag: picList[i], + child: GestureDetector( + onTap: () => onPreviewImg(picList, i, context), + child: NetworkImgLayer( + src: pics[i].url, + width: maxWidth, + height: maxWidth, + origAspectRatio: + pics[i].width!.toInt() / pics[i].height!.toInt(), + ), ), ); }, @@ -163,6 +160,43 @@ class _ContentState extends State { ); } + void onPreviewImg(picList, initIndex, context) { + Navigator.of(context).push( + HeroDialogRoute( + builder: (BuildContext context) => InteractiveviewerGallery( + sources: picList, + initIndex: initIndex, + itemBuilder: ( + BuildContext context, + int index, + bool isFocus, + bool enablePageView, + ) { + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () { + if (enablePageView) { + Navigator.of(context).pop(); + } + }, + child: Center( + child: Hero( + tag: picList[index], + child: CachedNetworkImage( + fadeInDuration: const Duration(milliseconds: 0), + imageUrl: picList[index], + fit: BoxFit.contain, + ), + ), + ), + ); + }, + onPageChanged: (int pageIndex) {}, + ), + ), + ); + } + @override Widget build(BuildContext context) { TextStyle authorStyle = diff --git a/lib/pages/dynamics/widgets/pic_panel.dart b/lib/pages/dynamics/widgets/pic_panel.dart index 4e94e6fd..783fe89b 100644 --- a/lib/pages/dynamics/widgets/pic_panel.dart +++ b/lib/pages/dynamics/widgets/pic_panel.dart @@ -1,9 +1,47 @@ +import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:pilipala/common/constants.dart'; import 'package:pilipala/common/widgets/badge.dart'; import 'package:pilipala/common/widgets/network_img_layer.dart'; -import 'package:pilipala/pages/preview/index.dart'; +import 'package:pilipala/plugin/pl_gallery/index.dart'; + +void onPreviewImg(currentUrl, picList, initIndex, context) { + Navigator.of(context).push( + HeroDialogRoute( + builder: (BuildContext context) => InteractiveviewerGallery( + sources: picList, + initIndex: initIndex, + itemBuilder: ( + BuildContext context, + int index, + bool isFocus, + bool enablePageView, + ) { + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () { + if (enablePageView) { + Navigator.of(context).pop(); + } + }, + child: Center( + child: Hero( + tag: picList[index], + child: CachedNetworkImage( + fadeInDuration: const Duration(milliseconds: 0), + imageUrl: picList[index], + fit: BoxFit.contain, + ), + ), + ), + ); + }, + onPageChanged: (int pageIndex) {}, + ), + ), + ); +} Widget picWidget(item, context) { String type = item.modules.moduleDynamic.major.type; @@ -21,25 +59,25 @@ Widget picWidget(item, context) { List list = []; for (var i = 0; i < len; i++) { picList.add(pictures[i].src ?? pictures[i].url); + } + for (var i = 0; i < len; i++) { list.add( LayoutBuilder( builder: (context, BoxConstraints box) { - return GestureDetector( - onTap: () { - showDialog( - useSafeArea: false, - context: context, - builder: (context) { - return ImagePreview(initialPage: i, imgList: picList); - }, - ); + return Hero( + tag: picList[i], + placeholderBuilder: + (BuildContext context, Size heroSize, Widget child) { + return child; }, - child: NetworkImgLayer( - src: pictures[i].src ?? pictures[i].url, - width: box.maxWidth, - height: box.maxWidth, + child: GestureDetector( + onTap: () => onPreviewImg(picList[i], picList, i, context), + child: NetworkImgLayer( + src: pictures[i].src ?? pictures[i].url, + width: box.maxWidth, + height: box.maxWidth, + ), ), - // ), ); }, ), diff --git a/lib/pages/main/controller.dart b/lib/pages/main/controller.dart index 849e16d5..ad2a7781 100644 --- a/lib/pages/main/controller.dart +++ b/lib/pages/main/controller.dart @@ -27,6 +27,7 @@ class MainController extends GetxController { RxBool userLogin = false.obs; late Rx dynamicBadgeType = DynamicBadgeMode.number.obs; late bool enableGradientBg; + bool imgPreviewStatus = false; @override void onInit() { diff --git a/lib/pages/preview/controller.dart b/lib/pages/preview/controller.dart deleted file mode 100644 index bb06b275..00000000 --- a/lib/pages/preview/controller.dart +++ /dev/null @@ -1,50 +0,0 @@ -import 'dart:io'; - -import 'package:device_info_plus/device_info_plus.dart'; -import 'package:flutter_smart_dialog/flutter_smart_dialog.dart'; -import 'package:get/get.dart'; -import 'package:dio/dio.dart'; -import 'package:path_provider/path_provider.dart'; -import 'package:permission_handler/permission_handler.dart'; -import 'package:share_plus/share_plus.dart'; - -class PreviewController extends GetxController { - DeviceInfoPlugin deviceInfo = DeviceInfoPlugin(); - RxInt initialPage = 0.obs; - RxInt currentPage = 1.obs; - RxList imgList = [].obs; - bool storage = true; - bool videos = true; - bool photos = true; - String currentImgUrl = ''; - - requestPermission() async { - Map statuses = await [ - Permission.storage, - // Permission.photos - ].request(); - - statuses[Permission.storage].toString(); - // final photosInfo = statuses[Permission.photos].toString(); - } - - // 图片分享 - void onShareImg() async { - SmartDialog.showLoading(); - var response = await Dio().get(imgList[initialPage.value], - options: Options(responseType: ResponseType.bytes)); - final temp = await getTemporaryDirectory(); - SmartDialog.dismiss(); - String imgName = - "plpl_pic_${DateTime.now().toString().split('-').join()}.jpg"; - var path = '${temp.path}/$imgName'; - File(path).writeAsBytesSync(response.data); - Share.shareXFiles([XFile(path)], subject: imgList[initialPage.value]); - } - - void onChange(int index) { - initialPage.value = index; - currentPage.value = index + 1; - currentImgUrl = imgList[index]; - } -} diff --git a/lib/pages/preview/index.dart b/lib/pages/preview/index.dart deleted file mode 100644 index 9fb82e8d..00000000 --- a/lib/pages/preview/index.dart +++ /dev/null @@ -1,4 +0,0 @@ -library preview; - -export './controller.dart'; -export './view.dart'; diff --git a/lib/pages/preview/view.dart b/lib/pages/preview/view.dart deleted file mode 100644 index 13868d37..00000000 --- a/lib/pages/preview/view.dart +++ /dev/null @@ -1,290 +0,0 @@ -// ignore_for_file: library_private_types_in_public_api - -import 'dart:io'; - -import 'package:dismissible_page/dismissible_page.dart'; -import 'package:flutter/services.dart'; -import 'package:flutter_smart_dialog/flutter_smart_dialog.dart'; -import 'package:get/get.dart'; -import 'package:flutter/material.dart'; -import 'package:extended_image/extended_image.dart'; -import 'package:pilipala/utils/download.dart'; -import 'controller.dart'; -import 'package:status_bar_control/status_bar_control.dart'; - -typedef DoubleClickAnimationListener = void Function(); - -class ImagePreview extends StatefulWidget { - final int? initialPage; - final List? imgList; - const ImagePreview({ - Key? key, - this.initialPage, - this.imgList, - }) : super(key: key); - - @override - _ImagePreviewState createState() => _ImagePreviewState(); -} - -class _ImagePreviewState extends State - with TickerProviderStateMixin { - final PreviewController _previewController = Get.put(PreviewController()); - // late AnimationController animationController; - late AnimationController _doubleClickAnimationController; - Animation? _doubleClickAnimation; - late DoubleClickAnimationListener _doubleClickAnimationListener; - List doubleTapScales = [1.0, 2.0]; - bool _dismissDisabled = false; - - @override - void initState() { - super.initState(); - - _previewController.initialPage.value = widget.initialPage!; - _previewController.currentPage.value = widget.initialPage! + 1; - _previewController.imgList.value = widget.imgList!; - _previewController.currentImgUrl = widget.imgList![widget.initialPage!]; - // animationController = AnimationController( - // vsync: this, duration: const Duration(milliseconds: 400)); - setStatusBar(); - _doubleClickAnimationController = AnimationController( - duration: const Duration(milliseconds: 250), vsync: this); - } - - onOpenMenu() { - showDialog( - context: context, - builder: (BuildContext context) { - return AlertDialog( - clipBehavior: Clip.hardEdge, - contentPadding: const EdgeInsets.fromLTRB(0, 12, 0, 12), - content: Column( - mainAxisSize: MainAxisSize.min, - children: [ - ListTile( - onTap: () { - _previewController.onShareImg(); - Get.back(); - }, - dense: true, - title: const Text('分享', style: TextStyle(fontSize: 14)), - ), - ListTile( - onTap: () { - Clipboard.setData( - ClipboardData(text: _previewController.currentImgUrl)) - .then((value) { - Get.back(); - SmartDialog.showToast('已复制到粘贴板'); - }).catchError((err) { - SmartDialog.showNotify( - msg: err.toString(), - notifyType: NotifyType.error, - ); - }); - }, - dense: true, - title: const Text('复制链接', style: TextStyle(fontSize: 14)), - ), - ListTile( - onTap: () { - Get.back(); - DownloadUtils.downloadImg(_previewController.currentImgUrl); - }, - dense: true, - title: const Text('保存到手机', style: TextStyle(fontSize: 14)), - ), - ], - ), - ); - }, - ); - } - - // 隐藏状态栏,避免遮挡图片内容 - setStatusBar() async { - if (Platform.isIOS || Platform.isAndroid) { - await StatusBarControl.setHidden(true, - animation: StatusBarAnimation.SLIDE); - } - } - - @override - void dispose() { - // animationController.dispose(); - try { - StatusBarControl.setHidden(false, animation: StatusBarAnimation.SLIDE); - } catch (_) {} - _doubleClickAnimationController.dispose(); - clearGestureDetailsCache(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - return Scaffold( - backgroundColor: Colors.black, - primary: false, - extendBody: true, - appBar: AppBar( - primary: false, - toolbarHeight: 0, - backgroundColor: Colors.black, - systemOverlayStyle: SystemUiOverlayStyle.dark, - ), - body: Stack( - children: [ - GestureDetector( - onLongPress: () => onOpenMenu(), - child: ExtendedImageGesturePageView.builder( - controller: ExtendedPageController( - initialPage: _previewController.initialPage.value, - pageSpacing: 0, - ), - onPageChanged: (int index) => _previewController.onChange(index), - canScrollPage: (GestureDetails? gestureDetails) => - gestureDetails!.totalScale! <= 1.0, - itemCount: widget.imgList!.length, - itemBuilder: (BuildContext context, int index) { - return ExtendedImage.network( - widget.imgList![index], - fit: BoxFit.contain, - mode: ExtendedImageMode.gesture, - onDoubleTap: (ExtendedImageGestureState state) { - final Offset? pointerDownPosition = - state.pointerDownPosition; - final double? begin = state.gestureDetails!.totalScale; - double end; - - //remove old - _doubleClickAnimation - ?.removeListener(_doubleClickAnimationListener); - - //stop pre - _doubleClickAnimationController.stop(); - - //reset to use - _doubleClickAnimationController.reset(); - - if (begin == doubleTapScales[0]) { - setState(() { - _dismissDisabled = true; - }); - end = doubleTapScales[1]; - } else { - setState(() { - _dismissDisabled = false; - }); - end = doubleTapScales[0]; - } - - _doubleClickAnimationListener = () { - state.handleDoubleTap( - scale: _doubleClickAnimation!.value, - doubleTapPosition: pointerDownPosition); - }; - _doubleClickAnimation = _doubleClickAnimationController - .drive(Tween(begin: begin, end: end)); - - _doubleClickAnimation! - .addListener(_doubleClickAnimationListener); - - _doubleClickAnimationController.forward(); - }, - // ignore: body_might_complete_normally_nullable - loadStateChanged: (ExtendedImageState state) { - if (state.extendedImageLoadState == LoadState.loading) { - final ImageChunkEvent? loadingProgress = - state.loadingProgress; - final double? progress = - loadingProgress?.expectedTotalBytes != null - ? loadingProgress!.cumulativeBytesLoaded / - loadingProgress.expectedTotalBytes! - : null; - return Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - SizedBox( - width: 150.0, - child: LinearProgressIndicator( - value: progress, - color: Colors.white, - ), - ), - // const SizedBox(height: 10.0), - // Text('${((progress ?? 0.0) * 100).toInt()}%',), - ], - ), - ); - } - }, - initGestureConfigHandler: (ExtendedImageState state) { - return GestureConfig( - inPageView: true, - initialScale: 1.0, - maxScale: 5.0, - animationMaxScale: 6.0, - initialAlignment: InitialAlignment.center, - ); - }, - ); - }, - ), - ), - Positioned( - left: 0, - right: 0, - bottom: 0, - child: Container( - padding: EdgeInsets.only( - left: 20, - right: 20, - bottom: MediaQuery.of(context).padding.bottom + 30), - decoration: const BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: [ - Colors.transparent, - Colors.black87, - ], - tileMode: TileMode.mirror, - ), - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - widget.imgList!.length > 1 - ? Obx( - () => Text.rich( - textAlign: TextAlign.center, - TextSpan( - style: const TextStyle( - color: Colors.white, fontSize: 16), - children: [ - TextSpan( - text: _previewController.currentPage - .toString()), - const TextSpan(text: ' / '), - TextSpan( - text: - widget.imgList!.length.toString()), - ]), - ), - ) - : const SizedBox(), - IconButton( - onPressed: () => Get.back(), - icon: const Icon(Icons.close, color: Colors.white), - ), - ], - )), - ), - ], - ), - ); - } -} diff --git a/lib/pages/video/detail/reply/widgets/reply_item.dart b/lib/pages/video/detail/reply/widgets/reply_item.dart index d8f696d4..c4c26e6b 100644 --- a/lib/pages/video/detail/reply/widgets/reply_item.dart +++ b/lib/pages/video/detail/reply/widgets/reply_item.dart @@ -1,4 +1,5 @@ import 'package:appscheme/appscheme.dart'; +import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; @@ -9,9 +10,10 @@ import 'package:pilipala/common/widgets/badge.dart'; import 'package:pilipala/common/widgets/network_img_layer.dart'; import 'package:pilipala/models/common/reply_type.dart'; import 'package:pilipala/models/video/reply/item.dart'; -import 'package:pilipala/pages/preview/index.dart'; +import 'package:pilipala/pages/main/index.dart'; import 'package:pilipala/pages/video/detail/index.dart'; import 'package:pilipala/pages/video/detail/reply_new/index.dart'; +import 'package:pilipala/plugin/pl_gallery/index.dart'; import 'package:pilipala/utils/app_scheme.dart'; import 'package:pilipala/utils/feed_back.dart'; import 'package:pilipala/utils/id_utils.dart'; @@ -540,6 +542,53 @@ InlineSpan buildContent( ); } + void onPreviewImg(picList, initIndex) { + final MainController mainController = Get.find(); + mainController.imgPreviewStatus = true; + Navigator.of(context).push( + HeroDialogRoute( + builder: (BuildContext context) => InteractiveviewerGallery( + sources: picList, + initIndex: initIndex, + itemBuilder: ( + BuildContext context, + int index, + bool isFocus, + bool enablePageView, + ) { + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () { + if (enablePageView) { + Navigator.of(context).pop(); + final MainController mainController = + Get.find(); + mainController.imgPreviewStatus = false; + } + }, + child: Center( + child: Hero( + tag: picList[index], + child: CachedNetworkImage( + fadeInDuration: const Duration(milliseconds: 0), + imageUrl: picList[index], + fit: BoxFit.contain, + ), + ), + ), + ); + }, + onPageChanged: (int pageIndex) {}, + onDismissed: (int value) { + print('onDismissed'); + final MainController mainController = Get.find(); + mainController.imgPreviewStatus = false; + }, + ), + ), + ); + } + // 分割文本并处理每个部分 content.message.splitMapJoin( pattern, @@ -831,38 +880,33 @@ InlineSpan buildContent( .truncateToDouble(); } catch (_) {} - return GestureDetector( - onTap: () { - showDialog( - useSafeArea: false, - context: context, - builder: (BuildContext context) { - return ImagePreview(initialPage: 0, imgList: picList); - }, - ); - }, - child: Container( - padding: const EdgeInsets.only(top: 4), - constraints: BoxConstraints(maxHeight: maxHeight), - width: box.maxWidth / 2, - height: height, - child: Stack( - children: [ - Positioned.fill( - child: NetworkImgLayer( - src: pictureItem['img_src'], - width: box.maxWidth / 2, - height: height, + return Hero( + tag: picList[0], + child: GestureDetector( + onTap: () => onPreviewImg(picList, 0), + child: Container( + padding: const EdgeInsets.only(top: 4), + constraints: BoxConstraints(maxHeight: maxHeight), + width: box.maxWidth / 2, + height: height, + child: Stack( + children: [ + Positioned.fill( + child: NetworkImgLayer( + src: picList[0], + width: box.maxWidth / 2, + height: height, + ), ), - ), - height > Get.size.height * 0.9 - ? const PBadge( - text: '长图', - right: 8, - bottom: 8, - ) - : const SizedBox(), - ], + height > Get.size.height * 0.9 + ? const PBadge( + text: '长图', + right: 8, + bottom: 8, + ) + : const SizedBox(), + ], + ), ), ), ); @@ -874,25 +918,22 @@ InlineSpan buildContent( List list = []; for (var i = 0; i < len; i++) { picList.add(content.pictures[i]['img_src']); + } + for (var i = 0; i < len; i++) { list.add( LayoutBuilder( builder: (context, BoxConstraints box) { - return GestureDetector( - onTap: () { - showDialog( - useSafeArea: false, - context: context, - builder: (context) { - return ImagePreview(initialPage: i, imgList: picList); - }, - ); - }, - child: NetworkImgLayer( - src: content.pictures[i]['img_src'], - width: box.maxWidth, - height: box.maxWidth, - origAspectRatio: content.pictures[i]['img_width'] / - content.pictures[i]['img_height']), + return Hero( + tag: picList[i], + child: GestureDetector( + onTap: () => onPreviewImg(picList, i), + child: NetworkImgLayer( + src: picList[i], + width: box.maxWidth, + height: box.maxWidth, + origAspectRatio: content.pictures[i]['img_width'] / + content.pictures[i]['img_height']), + ), ); }, ), diff --git a/lib/pages/video/detail/view.dart b/lib/pages/video/detail/view.dart index 0e4cf2cf..5e147687 100644 --- a/lib/pages/video/detail/view.dart +++ b/lib/pages/video/detail/view.dart @@ -15,6 +15,7 @@ import 'package:pilipala/http/user.dart'; import 'package:pilipala/models/common/search_type.dart'; import 'package:pilipala/pages/bangumi/introduction/index.dart'; import 'package:pilipala/pages/danmaku/view.dart'; +import 'package:pilipala/pages/main/index.dart'; import 'package:pilipala/pages/video/detail/reply/index.dart'; import 'package:pilipala/pages/video/detail/controller.dart'; import 'package:pilipala/pages/video/detail/introduction/index.dart'; @@ -240,6 +241,11 @@ class _VideoDetailPageState extends State @override // 离开当前页面时 void didPushNext() async { + final MainController mainController = Get.find(); + if (mainController.imgPreviewStatus) { + return; + } + /// 开启 if (setting.get(SettingBoxKey.enableAutoBrightness, defaultValue: false) as bool) { @@ -259,6 +265,11 @@ class _VideoDetailPageState extends State @override // 返回当前页面时 void didPopNext() async { + final MainController mainController = Get.find(); + if (mainController.imgPreviewStatus) { + return; + } + if (plPlayerController != null && plPlayerController!.videoPlayerController != null) { setState(() { diff --git a/lib/plugin/pl_gallery/custom_dismissible.dart b/lib/plugin/pl_gallery/custom_dismissible.dart new file mode 100644 index 00000000..05761cac --- /dev/null +++ b/lib/plugin/pl_gallery/custom_dismissible.dart @@ -0,0 +1,156 @@ +import 'package:flutter/material.dart'; + +/// A widget used to dismiss its [child]. +/// +/// Similar to [Dismissible] with some adjustments. +class CustomDismissible extends StatefulWidget { + const CustomDismissible({ + required this.child, + this.onDismissed, + this.dismissThreshold = 0.2, + this.enabled = true, + Key? key, + }) : super(key: key); + + final Widget child; + final double dismissThreshold; + final VoidCallback? onDismissed; + final bool enabled; + + @override + State createState() => _CustomDismissibleState(); +} + +class _CustomDismissibleState extends State + with SingleTickerProviderStateMixin { + late AnimationController _animateController; + late Animation _moveAnimation; + late Animation _scaleAnimation; + late Animation _opacityAnimation; + + double _dragExtent = 0; + bool _dragUnderway = false; + + bool get _isActive => _dragUnderway || _animateController.isAnimating; + + @override + void initState() { + super.initState(); + + _animateController = AnimationController( + duration: const Duration(milliseconds: 300), + vsync: this, + ); + + _updateMoveAnimation(); + } + + @override + void dispose() { + _animateController.dispose(); + + super.dispose(); + } + + void _updateMoveAnimation() { + final double end = _dragExtent.sign; + + _moveAnimation = _animateController.drive( + Tween( + begin: Offset.zero, + end: Offset(0, end), + ), + ); + + _scaleAnimation = _animateController.drive(Tween( + begin: 1, + end: 0.5, + )); + + _opacityAnimation = DecorationTween( + begin: const BoxDecoration(color: Color(0xFF000000)), + end: const BoxDecoration(color: Color(0x00000000)), + ).animate(_animateController); + } + + void _handleDragStart(DragStartDetails details) { + _dragUnderway = true; + + if (_animateController.isAnimating) { + _dragExtent = + _animateController.value * context.size!.height * _dragExtent.sign; + _animateController.stop(); + } else { + _dragExtent = 0.0; + _animateController.value = 0.0; + } + setState(_updateMoveAnimation); + } + + void _handleDragUpdate(DragUpdateDetails details) { + if (!_isActive || _animateController.isAnimating) { + return; + } + + final double delta = details.primaryDelta!; + final double oldDragExtent = _dragExtent; + + if (_dragExtent + delta < 0) { + _dragExtent += delta; + } else if (_dragExtent + delta > 0) { + _dragExtent += delta; + } + + if (oldDragExtent.sign != _dragExtent.sign) { + setState(_updateMoveAnimation); + } + + if (!_animateController.isAnimating) { + _animateController.value = _dragExtent.abs() / context.size!.height; + } + } + + void _handleDragEnd(DragEndDetails details) { + if (!_isActive || _animateController.isAnimating) { + return; + } + + _dragUnderway = false; + + if (_animateController.isCompleted) { + return; + } + + if (!_animateController.isDismissed) { + // if the dragged value exceeded the dismissThreshold, call onDismissed + // else animate back to initial position. + if (_animateController.value > widget.dismissThreshold) { + widget.onDismissed?.call(); + } else { + _animateController.reverse(); + } + } + } + + @override + Widget build(BuildContext context) { + final Widget content = DecoratedBoxTransition( + decoration: _opacityAnimation, + child: SlideTransition( + position: _moveAnimation, + child: ScaleTransition( + scale: _scaleAnimation, + child: widget.child, + ), + ), + ); + + return GestureDetector( + behavior: HitTestBehavior.translucent, + onVerticalDragStart: widget.enabled ? _handleDragStart : null, + onVerticalDragUpdate: widget.enabled ? _handleDragUpdate : null, + onVerticalDragEnd: widget.enabled ? _handleDragEnd : null, + child: content, + ); + } +} diff --git a/lib/plugin/pl_gallery/hero_dialog_route.dart b/lib/plugin/pl_gallery/hero_dialog_route.dart new file mode 100644 index 00000000..d9f129d2 --- /dev/null +++ b/lib/plugin/pl_gallery/hero_dialog_route.dart @@ -0,0 +1,63 @@ +import 'package:flutter/material.dart'; + +/// A [PageRoute] with a semi transparent background. +/// +/// Similar to calling [showDialog] except it can be used with a [Navigator] to +/// show a [Hero] animation. +class HeroDialogRoute extends PageRoute { + HeroDialogRoute({ + required this.builder, + this.onBackgroundTap, + }) : super(); + + final WidgetBuilder builder; + + /// Called when the background is tapped. + final VoidCallback? onBackgroundTap; + + @override + bool get opaque => false; + + @override + bool get barrierDismissible => true; + + @override + String? get barrierLabel => null; + + @override + Duration get transitionDuration => const Duration(milliseconds: 300); + + @override + bool get maintainState => true; + + @override + Color? get barrierColor => null; + + @override + Widget buildTransitions( + BuildContext context, + Animation animation, + Animation secondaryAnimation, + Widget child, + ) { + return FadeTransition( + opacity: CurvedAnimation(parent: animation, curve: Curves.easeOut), + child: child, + ); + } + + @override + Widget buildPage( + BuildContext context, + Animation animation, + Animation secondaryAnimation, + ) { + final Widget child = builder(context); + final Widget result = Semantics( + scopesRoute: true, + explicitChildNodes: true, + child: child, + ); + return result; + } +} diff --git a/lib/plugin/pl_gallery/index.dart b/lib/plugin/pl_gallery/index.dart new file mode 100644 index 00000000..5a6f04e1 --- /dev/null +++ b/lib/plugin/pl_gallery/index.dart @@ -0,0 +1,6 @@ +library pl_gallery; + +export './hero_dialog_route.dart'; +export './custom_dismissible.dart'; +export './interactiveviewer_gallery.dart'; +export './interactive_viewer_boundary.dart'; diff --git a/lib/plugin/pl_gallery/interactive_viewer_boundary.dart b/lib/plugin/pl_gallery/interactive_viewer_boundary.dart new file mode 100644 index 00000000..929b7a6d --- /dev/null +++ b/lib/plugin/pl_gallery/interactive_viewer_boundary.dart @@ -0,0 +1,117 @@ +import 'package:flutter/material.dart'; + +/// A callback for the [InteractiveViewerBoundary] that is called when the scale +/// changed. +typedef ScaleChanged = void Function(double scale); + +/// Builds an [InteractiveViewer] and provides callbacks that are called when a +/// horizontal boundary has been hit. +/// +/// The callbacks are called when an interaction ends by listening to the +/// [InteractiveViewer.onInteractionEnd] callback. +class InteractiveViewerBoundary extends StatefulWidget { + const InteractiveViewerBoundary({ + required this.child, + required this.boundaryWidth, + this.controller, + this.onScaleChanged, + this.onLeftBoundaryHit, + this.onRightBoundaryHit, + this.onNoBoundaryHit, + this.maxScale, + this.minScale, + Key? key, + }) : super(key: key); + + final Widget child; + + /// The max width this widget can have. + /// + /// If the [InteractiveViewer] can take up the entire screen width, this + /// should be set to `MediaQuery.of(context).size.width`. + final double boundaryWidth; + + /// The [TransformationController] for the [InteractiveViewer]. + final TransformationController? controller; + + /// Called when the scale changed after an interaction ended. + final ScaleChanged? onScaleChanged; + + /// Called when the left boundary has been hit after an interaction ended. + final VoidCallback? onLeftBoundaryHit; + + /// Called when the right boundary has been hit after an interaction ended. + final VoidCallback? onRightBoundaryHit; + + /// Called when no boundary has been hit after an interaction ended. + final VoidCallback? onNoBoundaryHit; + + final double? maxScale; + + final double? minScale; + + @override + InteractiveViewerBoundaryState createState() => + InteractiveViewerBoundaryState(); +} + +class InteractiveViewerBoundaryState extends State { + TransformationController? _controller; + + double? _scale; + + @override + void initState() { + super.initState(); + + _controller = widget.controller ?? TransformationController(); + } + + @override + void dispose() { + _controller!.dispose(); + + super.dispose(); + } + + void _updateBoundaryDetection() { + final double scale = _controller!.value.row0[0]; + + if (_scale != scale) { + // the scale changed + _scale = scale; + widget.onScaleChanged?.call(scale); + } + + if (scale <= 1.01) { + // cant hit any boundaries when the child is not scaled + return; + } + + final double xOffset = _controller!.value.row0[3]; + final double boundaryWidth = widget.boundaryWidth; + final double boundaryEnd = boundaryWidth * scale; + final double xPos = boundaryEnd + xOffset; + + if (boundaryEnd.round() == xPos.round()) { + // left boundary hit + widget.onLeftBoundaryHit?.call(); + } else if (boundaryWidth.round() == xPos.round()) { + // right boundary hit + widget.onRightBoundaryHit?.call(); + } else { + widget.onNoBoundaryHit?.call(); + } + } + + @override + Widget build(BuildContext context) { + return InteractiveViewer( + maxScale: widget.maxScale!, + minScale: widget.minScale!, + transformationController: _controller, + onInteractionEnd: (_) => _updateBoundaryDetection(), + child: widget.child, + ); + } +} diff --git a/lib/plugin/pl_gallery/interactiveviewer_gallery.dart b/lib/plugin/pl_gallery/interactiveviewer_gallery.dart new file mode 100644 index 00000000..03ff4642 --- /dev/null +++ b/lib/plugin/pl_gallery/interactiveviewer_gallery.dart @@ -0,0 +1,399 @@ +library interactiveviewer_gallery; + +import 'dart:io'; + +import 'package:dio/dio.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_smart_dialog/flutter_smart_dialog.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:pilipala/utils/download.dart'; +import 'package:share_plus/share_plus.dart'; +import 'package:status_bar_control/status_bar_control.dart'; +import 'custom_dismissible.dart'; +import 'interactive_viewer_boundary.dart'; + +/// Builds a carousel controlled by a [PageView] for the tweet media sources. +/// +/// Used for showing a full screen view of the [TweetMedia] sources. +/// +/// The sources can be panned and zoomed interactively using an +/// [InteractiveViewer]. +/// An [InteractiveViewerBoundary] is used to detect when the boundary of the +/// source is hit after zooming in to disable or enable the swiping gesture of +/// the [PageView]. +/// +typedef IndexedFocusedWidgetBuilder = Widget Function( + BuildContext context, int index, bool isFocus, bool enablePageView); + +typedef IndexedTagStringBuilder = String Function(int index); + +class InteractiveviewerGallery extends StatefulWidget { + const InteractiveviewerGallery({ + required this.sources, + required this.initIndex, + required this.itemBuilder, + this.maxScale = 4.5, + this.minScale = 1.0, + this.onPageChanged, + this.onDismissed, + Key? key, + }) : super(key: key); + + /// The sources to show. + final List sources; + + /// The index of the first source in [sources] to show. + final int initIndex; + + /// The item content + final IndexedFocusedWidgetBuilder itemBuilder; + + final double maxScale; + + final double minScale; + + final ValueChanged? onPageChanged; + + final ValueChanged? onDismissed; + + @override + State createState() => + _InteractiveviewerGalleryState(); +} + +class _InteractiveviewerGalleryState extends State + with SingleTickerProviderStateMixin { + PageController? _pageController; + TransformationController? _transformationController; + + /// The controller to animate the transformation value of the + /// [InteractiveViewer] when it should reset. + late AnimationController _animationController; + Animation? _animation; + + /// `true` when an source is zoomed in and not at the at a horizontal boundary + /// to disable the [PageView]. + bool _enablePageView = true; + + /// `true` when an source is zoomed in to disable the [CustomDismissible]. + bool _enableDismiss = true; + + late Offset _doubleTapLocalPosition; + + int? currentIndex; + + @override + void initState() { + super.initState(); + + _pageController = PageController(initialPage: widget.initIndex); + + _transformationController = TransformationController(); + + _animationController = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 300), + ) + ..addListener(() { + _transformationController!.value = + _animation?.value ?? Matrix4.identity(); + }) + ..addStatusListener((AnimationStatus status) { + if (status == AnimationStatus.completed && !_enableDismiss) { + setState(() { + _enableDismiss = true; + }); + } + }); + + currentIndex = widget.initIndex; + setStatusBar(); + } + + setStatusBar() async { + if (Platform.isIOS || Platform.isAndroid) { + await StatusBarControl.setHidden(true, + animation: StatusBarAnimation.FADE); + } + } + + @override + void dispose() { + _pageController!.dispose(); + _animationController.dispose(); + try { + StatusBarControl.setHidden(false, animation: StatusBarAnimation.FADE); + } catch (_) {} + super.dispose(); + } + + /// When the source gets scaled up, the swipe up / down to dismiss gets + /// disabled. + /// + /// When the scale resets, the dismiss and the page view swiping gets enabled. + void _onScaleChanged(double scale) { + final bool initialScale = scale <= widget.minScale; + + if (initialScale) { + if (!_enableDismiss) { + setState(() { + _enableDismiss = true; + }); + } + + if (!_enablePageView) { + setState(() { + _enablePageView = true; + }); + } + } else { + if (_enableDismiss) { + setState(() { + _enableDismiss = false; + }); + } + + if (_enablePageView) { + setState(() { + _enablePageView = false; + }); + } + } + } + + /// When the left boundary has been hit after scaling up the source, the page + /// view swiping gets enabled if it has a page to swipe to. + void _onLeftBoundaryHit() { + if (!_enablePageView && _pageController!.page!.floor() > 0) { + setState(() { + _enablePageView = true; + }); + } + } + + /// When the right boundary has been hit after scaling up the source, the page + /// view swiping gets enabled if it has a page to swipe to. + void _onRightBoundaryHit() { + if (!_enablePageView && + _pageController!.page!.floor() < widget.sources.length - 1) { + setState(() { + _enablePageView = true; + }); + } + } + + /// When the source has been scaled up and no horizontal boundary has been hit, + /// the page view swiping gets disabled. + void _onNoBoundaryHit() { + if (_enablePageView) { + setState(() { + _enablePageView = false; + }); + } + } + + /// When the page view changed its page, the source will animate back into the + /// original scale if it was scaled up. + /// + /// Additionally the swipe up / down to dismiss gets enabled. + void _onPageChanged(int page) { + setState(() { + currentIndex = page; + }); + widget.onPageChanged?.call(page); + if (_transformationController!.value != Matrix4.identity()) { + // animate the reset for the transformation of the interactive viewer + + _animation = Matrix4Tween( + begin: _transformationController!.value, + end: Matrix4.identity(), + ).animate( + CurveTween(curve: Curves.easeOut).animate(_animationController), + ); + + _animationController.forward(from: 0); + } + } + + @override + Widget build(BuildContext context) { + return InteractiveViewerBoundary( + controller: _transformationController, + boundaryWidth: MediaQuery.of(context).size.width, + onScaleChanged: _onScaleChanged, + onLeftBoundaryHit: _onLeftBoundaryHit, + onRightBoundaryHit: _onRightBoundaryHit, + onNoBoundaryHit: _onNoBoundaryHit, + maxScale: widget.maxScale, + minScale: widget.minScale, + child: Stack(children: [ + CustomDismissible( + onDismissed: () { + Navigator.of(context).pop(); + widget.onDismissed?.call(_pageController!.page!.floor()); + }, + enabled: _enableDismiss, + child: PageView.builder( + onPageChanged: _onPageChanged, + controller: _pageController, + physics: + _enablePageView ? null : const NeverScrollableScrollPhysics(), + itemCount: widget.sources.length, + itemBuilder: (BuildContext context, int index) { + return GestureDetector( + onDoubleTapDown: (TapDownDetails details) { + _doubleTapLocalPosition = details.localPosition; + }, + onDoubleTap: onDoubleTap, + child: widget.itemBuilder( + context, + index, + index == currentIndex, + _enablePageView, + ), + ); + }, + ), + ), + Positioned( + bottom: 0, + left: 0, + right: 0, + child: Container( + padding: EdgeInsets.fromLTRB( + 12, 8, 20, MediaQuery.of(context).padding.bottom + 8), + decoration: _enablePageView + ? BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + Colors.transparent, + Colors.black.withOpacity(0.3) + ], + ), + ) + : null, + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + IconButton( + icon: const Icon(Icons.close, color: Colors.white), + onPressed: () { + Navigator.of(context).pop(); + widget.onDismissed?.call(_pageController!.page!.floor()); + }, + ), + widget.sources.length > 1 + ? Text( + "${currentIndex! + 1}/${widget.sources.length}", + style: const TextStyle(color: Colors.white), + ) + : const SizedBox(), + PopupMenuButton( + itemBuilder: (context) { + return [ + PopupMenuItem( + value: 0, + onTap: () => onShareImg(widget.sources[currentIndex!]), + child: const Text("分享图片"), + ), + PopupMenuItem( + value: 1, + onTap: () { + Clipboard.setData(ClipboardData( + text: + widget.sources[currentIndex!].toString())) + .then((value) { + SmartDialog.showToast('已复制到粘贴板'); + }).catchError((err) { + SmartDialog.showNotify( + msg: err.toString(), + notifyType: NotifyType.error, + ); + }); + }, + child: const Text("复制图片"), + ), + PopupMenuItem( + value: 2, + onTap: () { + DownloadUtils.downloadImg( + widget.sources[currentIndex!]); + }, + child: const Text("保存图片"), + ), + ]; + }, + child: const Icon(Icons.more_horiz, color: Colors.white), + ), + ], + ), + ), + ), + ]), + ); + } + + // 图片分享 + void onShareImg(String imgUrl) async { + SmartDialog.showLoading(); + var response = await Dio() + .get(imgUrl, options: Options(responseType: ResponseType.bytes)); + final temp = await getTemporaryDirectory(); + SmartDialog.dismiss(); + String imgName = + "plpl_pic_${DateTime.now().toString().split('-').join()}.jpg"; + var path = '${temp.path}/$imgName'; + File(path).writeAsBytesSync(response.data); + Share.shareXFiles([XFile(path)], subject: imgUrl); + } + + onDoubleTap() { + Matrix4 matrix = _transformationController!.value.clone(); + double currentScale = matrix.row0.x; + + double targetScale = widget.minScale; + + if (currentScale <= widget.minScale) { + targetScale = widget.maxScale * 0.7; + } + + double offSetX = targetScale == 1.0 + ? 0.0 + : -_doubleTapLocalPosition.dx * (targetScale - 1); + double offSetY = targetScale == 1.0 + ? 0.0 + : -_doubleTapLocalPosition.dy * (targetScale - 1); + + matrix = Matrix4.fromList([ + targetScale, + matrix.row1.x, + matrix.row2.x, + matrix.row3.x, + matrix.row0.y, + targetScale, + matrix.row2.y, + matrix.row3.y, + matrix.row0.z, + matrix.row1.z, + targetScale, + matrix.row3.z, + offSetX, + offSetY, + matrix.row2.w, + matrix.row3.w + ]); + + _animation = Matrix4Tween( + begin: _transformationController!.value, + end: matrix, + ).animate( + CurveTween(curve: Curves.easeOut).animate(_animationController), + ); + _animationController + .forward(from: 0) + .whenComplete(() => _onScaleChanged(targetScale)); + } +} diff --git a/lib/router/app_pages.dart b/lib/router/app_pages.dart index a6b48f0d..7840c126 100644 --- a/lib/router/app_pages.dart +++ b/lib/router/app_pages.dart @@ -70,14 +70,6 @@ class Routes { CustomGetPage(name: '/hot', page: () => const HotPage()), // 视频详情 CustomGetPage(name: '/video', page: () => const VideoDetailPage()), - // 图片预览 - // GetPage( - // name: '/preview', - // page: () => const ImagePreview(), - // transition: Transition.fade, - // transitionDuration: const Duration(milliseconds: 300), - // showCupertinoParallax: false, - // ), // CustomGetPage(name: '/webview', page: () => const WebviewPage()), // 设置 diff --git a/pubspec.lock b/pubspec.lock index c73ce395..a46127f9 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -441,22 +441,6 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "5.0.1" - extended_image: - dependency: "direct main" - description: - name: extended_image - sha256: d7f091d068fcac7246c4b22a84b8dac59a62e04d29a5c172710c696e67a22f94 - url: "https://pub.flutter-io.cn" - source: hosted - version: "8.2.0" - extended_image_library: - dependency: transitive - description: - name: extended_image_library - sha256: c9caee8fe9b6547bd41c960c4f2d1ef8e34321804de6a1777f1d614a24247ad6 - url: "https://pub.flutter-io.cn" - source: hosted - version: "4.0.4" extended_list: dependency: transitive description: @@ -726,14 +710,6 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "2.2.0" - http_client_helper: - dependency: transitive - description: - name: http_client_helper - sha256: "8a9127650734da86b5c73760de2b404494c968a3fd55602045ffec789dac3cb1" - url: "https://pub.flutter-io.cn" - source: hosted - version: "3.0.0" http_multi_server: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index badb658a..093ab3b4 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -49,7 +49,6 @@ dependencies: # 图片 cached_network_image: ^3.3.0 - extended_image: ^8.2.0 saver_gallery: ^3.0.1 # 存储