From beb7eb1aea9870486770901ef84996d360f7f600 Mon Sep 17 00:00:00 2001 From: dom Date: Sat, 14 Feb 2026 10:16:48 +0800 Subject: [PATCH] refa image preview Signed-off-by: dom --- ...ge_horizontal_drag_gesture_recognizer.dart | 46 +- .../gesture/image_tap_gesture_recognizer.dart | 23 + .../widgets/image/custom_grid_view.dart | 2 + .../widgets/image_viewer/gallery_viewer.dart | 599 ++++++++ .../hero_dialog_route.dart | 0 lib/common/widgets/image_viewer/image.dart | 675 ++++++++ .../image_viewer/loading_indicator.dart | 148 ++ lib/common/widgets/image_viewer/viewer.dart | 448 ++++++ .../interactive_viewer.dart | 1360 ----------------- .../interactive_viewer_boundary.dart | 194 --- .../interactiveviewer_gallery.dart | 478 ------ lib/models/common/image_preview_type.dart | 9 + lib/pages/video/view.dart | 2 +- lib/utils/page_utils.dart | 17 +- 14 files changed, 1935 insertions(+), 2066 deletions(-) create mode 100644 lib/common/widgets/gesture/image_tap_gesture_recognizer.dart create mode 100644 lib/common/widgets/image_viewer/gallery_viewer.dart rename lib/common/widgets/{interactiveviewer_gallery => image_viewer}/hero_dialog_route.dart (100%) create mode 100644 lib/common/widgets/image_viewer/image.dart create mode 100644 lib/common/widgets/image_viewer/loading_indicator.dart create mode 100644 lib/common/widgets/image_viewer/viewer.dart delete mode 100644 lib/common/widgets/interactiveviewer_gallery/interactive_viewer.dart delete mode 100644 lib/common/widgets/interactiveviewer_gallery/interactive_viewer_boundary.dart delete mode 100644 lib/common/widgets/interactiveviewer_gallery/interactiveviewer_gallery.dart diff --git a/lib/common/widgets/gesture/image_horizontal_drag_gesture_recognizer.dart b/lib/common/widgets/gesture/image_horizontal_drag_gesture_recognizer.dart index 66fbe61d1..c0e4be60f 100644 --- a/lib/common/widgets/gesture/image_horizontal_drag_gesture_recognizer.dart +++ b/lib/common/widgets/gesture/image_horizontal_drag_gesture_recognizer.dart @@ -1,5 +1,7 @@ import 'package:flutter/gestures.dart'; -import 'package:flutter/material.dart' show TransformationController; + +typedef IsBoundaryAllowed = + bool Function(Offset? initialPosition, OffsetPair lastPosition); class ImageHorizontalDragGestureRecognizer extends HorizontalDragGestureRecognizer { @@ -7,14 +9,22 @@ class ImageHorizontalDragGestureRecognizer super.debugOwner, super.supportedDevices, super.allowedButtonsFilter, - required this.width, - required this.transformationController, }); Offset? _initialPosition; - double width; - final TransformationController transformationController; + IsBoundaryAllowed? isBoundaryAllowed; + + int? _pointer; + + @override + void addPointer(PointerDownEvent event) { + if (_pointer == event.pointer) { + return; + } + _pointer = event.pointer; + super.addPointer(event); + } @override void addAllowedPointer(PointerDownEvent event) { @@ -22,24 +32,6 @@ class ImageHorizontalDragGestureRecognizer _initialPosition = event.position; } - bool _isBoundaryAllowed() { - if (_initialPosition == null) { - return true; - } - final storage = transformationController.value.storage; - final scale = storage[0]; - if (scale <= 1.0) { - return true; - } - final double xOffset = storage[12]; - final double boundaryEnd = width * scale; - final int xPos = (boundaryEnd + xOffset).round(); - return (boundaryEnd.round() == xPos && - lastPosition.global.dx > _initialPosition!.dx) || - (width.round() == xPos && - lastPosition.global.dx < _initialPosition!.dx); - } - @override bool hasSufficientGlobalDistanceToAccept( PointerDeviceKind pointerDeviceKind, @@ -47,6 +39,12 @@ class ImageHorizontalDragGestureRecognizer ) { return globalDistanceMoved.abs() > computeHitSlop(pointerDeviceKind, gestureSettings) && - _isBoundaryAllowed(); + (isBoundaryAllowed?.call(_initialPosition, lastPosition) ?? true); + } + + @override + void dispose() { + isBoundaryAllowed = null; + super.dispose(); } } diff --git a/lib/common/widgets/gesture/image_tap_gesture_recognizer.dart b/lib/common/widgets/gesture/image_tap_gesture_recognizer.dart new file mode 100644 index 000000000..2e8fee18b --- /dev/null +++ b/lib/common/widgets/gesture/image_tap_gesture_recognizer.dart @@ -0,0 +1,23 @@ +import 'package:flutter/gestures.dart' + show TapGestureRecognizer, PointerDownEvent; + +class ImageTapGestureRecognizer extends TapGestureRecognizer { + ImageTapGestureRecognizer({ + super.debugOwner, + super.supportedDevices, + super.allowedButtonsFilter, + super.preAcceptSlopTolerance, + super.postAcceptSlopTolerance, + }); + + int? _pointer; + + @override + void addPointer(PointerDownEvent event) { + if (_pointer == event.pointer) { + return; + } + _pointer = event.pointer; + super.addPointer(event); + } +} diff --git a/lib/common/widgets/image/custom_grid_view.dart b/lib/common/widgets/image/custom_grid_view.dart index 548f03516..6f79c1ffd 100644 --- a/lib/common/widgets/image/custom_grid_view.dart +++ b/lib/common/widgets/image/custom_grid_view.dart @@ -97,6 +97,8 @@ class CustomGridView extends StatelessWidget { liveUrl: isLive ? item.liveUrl : null, width: isLive ? item.width.toInt() : null, height: isLive ? item.height.toInt() : null, + // width: item.width.toInt(), + // height: item.height.toInt(), ); }, ).toList(); diff --git a/lib/common/widgets/image_viewer/gallery_viewer.dart b/lib/common/widgets/image_viewer/gallery_viewer.dart new file mode 100644 index 000000000..d05d1dca3 --- /dev/null +++ b/lib/common/widgets/image_viewer/gallery_viewer.dart @@ -0,0 +1,599 @@ +/* + * This file is part of PiliPlus + * + * PiliPlus is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * PiliPlus is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with PiliPlus. If not, see . + */ + +import 'dart:io' show File, Platform; +import 'dart:ui' as ui; + +import 'package:PiliPlus/common/widgets/flutter/page/page_view.dart'; +import 'package:PiliPlus/common/widgets/gesture/image_horizontal_drag_gesture_recognizer.dart'; +import 'package:PiliPlus/common/widgets/gesture/image_tap_gesture_recognizer.dart'; +import 'package:PiliPlus/common/widgets/image_viewer/image.dart'; +import 'package:PiliPlus/common/widgets/image_viewer/loading_indicator.dart'; +import 'package:PiliPlus/common/widgets/image_viewer/viewer.dart'; +import 'package:PiliPlus/common/widgets/scroll_physics.dart'; +import 'package:PiliPlus/models/common/image_preview_type.dart'; +import 'package:PiliPlus/utils/extension/string_ext.dart'; +import 'package:PiliPlus/utils/image_utils.dart'; +import 'package:PiliPlus/utils/page_utils.dart'; +import 'package:PiliPlus/utils/platform_utils.dart'; +import 'package:PiliPlus/utils/storage_pref.dart'; +import 'package:PiliPlus/utils/utils.dart'; +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:easy_debounce/easy_throttle.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart' hide Image, PageView; +import 'package:flutter/services.dart' show HapticFeedback; +import 'package:get/get.dart'; +import 'package:media_kit/media_kit.dart'; +import 'package:media_kit_video/media_kit_video.dart'; + +/// +/// created by dom on 2026/02/14 +/// + +class GalleryViewer extends StatefulWidget { + const GalleryViewer({ + super.key, + this.minScale = 1.0, + this.maxScale = 8.0, + required this.quality, + required this.sources, + this.initIndex = 1, + }); + + final double minScale; + final double maxScale; + final int quality; + final List sources; + final int initIndex; + + @override + State createState() => _GalleryViewerState(); +} + +class _GalleryViewerState extends State + with SingleTickerProviderStateMixin { + late Size _containerSize; + late final int _quality; + late final RxInt _currIndex; + late final List _keys; + + Player? _player; + Player get _effectivePlayer => _player ??= Player(); + VideoController? _videoController; + VideoController get _effectiveVideoController => + _videoController ??= VideoController(_effectivePlayer); + + late final PageController _pageController; + + late final ImageTapGestureRecognizer _tapGestureRecognizer; + late final ImageHorizontalDragGestureRecognizer + _horizontalDragGestureRecognizer; + late final LongPressGestureRecognizer _longPressGestureRecognizer; + + final Rx _matrix = Rx(Matrix4.identity()); + late final AnimationController _animateController; + late final Animation _opacityAnimation; + double dx = 0, dy = 0; + + Offset _offset = Offset.zero; + bool _dragging = false; + + bool get _isActive => _dragging || _animateController.isAnimating; + + String _getActualUrl(String url) { + return _quality != 100 + ? ImageUtils.thumbnailUrl(url, _quality) + : url.http2https; + } + + @override + void initState() { + super.initState(); + _quality = Pref.previewQ; + _currIndex = widget.initIndex.obs; + _playIfNeeded(widget.initIndex); + _keys = List.generate(widget.sources.length, (_) => GlobalKey()); + + _pageController = PageController(initialPage: widget.initIndex); + + final gestureSettings = MediaQuery.maybeGestureSettingsOf(Get.context!); + _tapGestureRecognizer = ImageTapGestureRecognizer() + ..onTap = _onTap + ..gestureSettings = gestureSettings; + if (PlatformUtils.isDesktop) { + _tapGestureRecognizer.onSecondaryTapUp = _showDesktopMenu; + } + _horizontalDragGestureRecognizer = ImageHorizontalDragGestureRecognizer() + ..gestureSettings = gestureSettings; + _longPressGestureRecognizer = LongPressGestureRecognizer() + ..onLongPress = _onLongPress + ..gestureSettings = gestureSettings; + + _animateController = AnimationController( + duration: const Duration(milliseconds: 300), + vsync: this, + ); + + _opacityAnimation = _animateController.drive( + DecorationTween( + begin: const BoxDecoration(color: Colors.black), + end: const BoxDecoration(color: Colors.transparent), + ), + ); + + _animateController.addListener(_updateTransformation); + } + + void _updateTransformation() { + final val = _animateController.value; + final scale = ui.lerpDouble(1.0, 0.25, val)!; + + // Matrix4.identity() + // ..translateByDouble(size.width / 2, size.height / 2, 0, 1) + // ..translateByDouble(size.width * val * dx, size.height * val * dy, 0, 1) + // ..scaleByDouble(scale, scale, 1, 1) + // ..translateByDouble(-size.width / 2, -size.height / 2, 0, 1); + + final tmp = (1.0 - scale) / 2.0; + _matrix.value = Matrix4.diagonal3Values(scale, scale, scale) + ..setTranslationRaw( + _containerSize.width * (val * dx + tmp), + _containerSize.height * (val * dy + tmp), + 0, + ); + } + + void _updateMoveAnimation() { + dy = _offset.dy.sign; + if (dy == 0) { + dx = 0; + } else { + dx = _offset.dx / _offset.dy.abs(); + } + } + + bool isAnimating() => _animateController.value != 0; + + void _onDragStart(ScaleStartDetails details) { + _dragging = true; + + if (_animateController.isAnimating) { + _animateController.stop(); + } else { + _offset = Offset.zero; + _animateController.value = 0.0; + } + _updateMoveAnimation(); + } + + void _onDragUpdate(ScaleUpdateDetails details) { + if (!_isActive || _animateController.isAnimating) { + return; + } + + _offset += details.focalPointDelta; + _updateMoveAnimation(); + + if (!_animateController.isAnimating) { + _animateController.value = _offset.dy.abs() / _containerSize.height; + } + } + + void _onDragEnd(ScaleEndDetails details) { + if (!_isActive || _animateController.isAnimating) { + return; + } + + _dragging = false; + + if (_animateController.isCompleted) { + return; + } + + if (!_animateController.isDismissed) { + if (_animateController.value > 0.2) { + Get.back(); + } else { + _animateController.reverse(); + } + } + } + + @override + void dispose() { + _player?.dispose(); + _player = null; + _videoController = null; + _pageController.dispose(); + _animateController + ..removeListener(_updateTransformation) + ..dispose(); + _tapGestureRecognizer.dispose(); + _longPressGestureRecognizer.dispose(); + _currIndex.close(); + _matrix.close(); + if (widget.quality != _quality) { + for (final item in widget.sources) { + if (item.sourceType == SourceType.networkImage) { + CachedNetworkImageProvider(_getActualUrl(item.url)).evict(); + } + } + } + super.dispose(); + } + + void _onPointerDown(PointerDownEvent event) { + _tapGestureRecognizer.addPointer(event); + _longPressGestureRecognizer.addPointer(event); + } + + @override + Widget build(BuildContext context) { + return Listener( + behavior: .opaque, + onPointerDown: _onPointerDown, + child: DecoratedBoxTransition( + decoration: _opacityAnimation, + child: Stack( + clipBehavior: .none, + children: [ + LayoutBuilder( + builder: (context, constraints) { + _containerSize = constraints.biggest; + return Obx( + () => Transform( + transform: _matrix.value, + child: + PageView.builder( + controller: _pageController, + onPageChanged: _onPageChanged, + physics: const CustomTabBarViewScrollPhysics( + parent: AlwaysScrollableScrollPhysics(), + ), + itemCount: widget.sources.length, + itemBuilder: _itemBuilder, + horizontalDragGestureRecognizer: () => + _horizontalDragGestureRecognizer, + ), + ), + ); + }, + ), + _buildIndicator, + ], + ), + ), + ); + } + + Widget get _buildIndicator => Positioned( + bottom: 0, + left: 0, + right: 0, + child: IgnorePointer( + child: Container( + padding: + MediaQuery.viewPaddingOf(context) + + const EdgeInsets.fromLTRB(12, 8, 20, 8), + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + Colors.transparent, + Colors.black.withValues(alpha: 0.3), + ], + ), + ), + alignment: Alignment.center, + child: Obx( + () => Text( + "${_currIndex.value + 1}/${widget.sources.length}", + style: const TextStyle(color: Colors.white), + ), + ), + ), + ), + ); + + void _playIfNeeded(int index) { + final item = widget.sources[index]; + if (item.sourceType == .livePhoto) { + _effectivePlayer.open(Media(item.liveUrl!)); + } + } + + void _onPageChanged(int index) { + _player?.pause(); + _playIfNeeded(index); + _currIndex.value = index; + } + + late final ValueChanged? _onChangePage = widget.sources.length == 1 + ? null + : (int offset) { + final currPage = _pageController.page?.round() ?? 0; + final nextPage = (currPage + offset).clamp( + 0, + widget.sources.length - 1, + ); + if (nextPage != currPage) { + _pageController.animateToPage( + nextPage, + duration: const Duration(milliseconds: 200), + curve: Curves.ease, + ); + } + }; + + Widget _itemBuilder(BuildContext context, int index) { + final item = widget.sources[index]; + return Hero( + tag: item.url, + child: switch (item.sourceType) { + .fileImage => Image.file( + key: _keys[index], + File(item.url), + filterQuality: .low, + minScale: widget.minScale, + maxScale: widget.maxScale, + containerSize: _containerSize, + isAnimating: isAnimating, + onDragStart: _onDragStart, + onDragUpdate: _onDragUpdate, + onDragEnd: _onDragEnd, + tapGestureRecognizer: _tapGestureRecognizer, + horizontalDragGestureRecognizer: _horizontalDragGestureRecognizer, + onChangePage: _onChangePage, + ), + .networkImage => Image( + key: _keys[index], + image: CachedNetworkImageProvider(_getActualUrl(item.url)), + minScale: widget.minScale, + maxScale: widget.maxScale, + containerSize: _containerSize, + tapGestureRecognizer: _tapGestureRecognizer, + horizontalDragGestureRecognizer: _horizontalDragGestureRecognizer, + onChangePage: _onChangePage, + frameBuilder: (context, child, frame, wasSynchronouslyLoaded) { + if (wasSynchronouslyLoaded) { + return child; + } + if (frame == null) { + if (widget.quality == _quality) { + return const SizedBox.expand(); + } else { + return Image( + image: CachedNetworkImageProvider( + ImageUtils.thumbnailUrl(item.url, widget.quality), + ), + minScale: widget.minScale, + maxScale: widget.maxScale, + containerSize: _containerSize, + isAnimating: isAnimating, + onDragStart: null, + onDragUpdate: null, + onDragEnd: null, + tapGestureRecognizer: _tapGestureRecognizer, + horizontalDragGestureRecognizer: + _horizontalDragGestureRecognizer, + onChangePage: _onChangePage, + ); + // final isLongPic = item.isLongPic; + // return CachedNetworkImage( + // fadeInDuration: Duration.zero, + // fadeOutDuration: Duration.zero, + // // fit: isLongPic ? .fitWidth : null, + // // alignment: isLongPic ? .topCenter : .center, + // imageUrl: ImageUtils.thumbnailUrl(item.url, widget.quality), + // placeholder: (_, _) => const SizedBox.expand(), + // ); + } + } + return child; + }, + loadingBuilder: loadingBuilder, + isAnimating: isAnimating, + onDragStart: _onDragStart, + onDragUpdate: _onDragUpdate, + onDragEnd: _onDragEnd, + ), + .livePhoto => Obx( + key: _keys[index], + () => _currIndex.value == index + ? Viewer( + minScale: widget.minScale, + maxScale: widget.maxScale, + containerSize: _containerSize, + childSize: _containerSize, + isAnimating: isAnimating, + onDragStart: _onDragStart, + onDragUpdate: _onDragUpdate, + onDragEnd: _onDragEnd, + tapGestureRecognizer: _tapGestureRecognizer, + horizontalDragGestureRecognizer: + _horizontalDragGestureRecognizer, + onChangePage: _onChangePage, + child: AbsorbPointer( + child: Video( + controller: _effectiveVideoController, + fill: Colors.transparent, + ), + ), + ) + : const SizedBox.shrink(), + ), + }, + ); + } + + void _onTap() { + EasyThrottle.throttle( + 'VIEWER_TAP', + const Duration(milliseconds: 555), + Get.back, + ); + } + + void _onLongPress() { + final item = widget.sources[_currIndex.value]; + if (item.sourceType == .fileImage) return; + HapticFeedback.mediumImpact(); + showDialog( + context: context, + builder: (context) => AlertDialog( + clipBehavior: Clip.hardEdge, + contentPadding: const EdgeInsets.symmetric(vertical: 12), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (PlatformUtils.isMobile) + ListTile( + onTap: () { + Get.back(); + ImageUtils.onShareImg(item.url); + }, + dense: true, + title: const Text('分享', style: TextStyle(fontSize: 14)), + ), + ListTile( + onTap: () { + Get.back(); + Utils.copyText(item.url); + }, + dense: true, + title: const Text('复制链接', style: TextStyle(fontSize: 14)), + ), + ListTile( + onTap: () { + Get.back(); + ImageUtils.downloadImg([item.url]); + }, + dense: true, + title: const Text('保存图片', style: TextStyle(fontSize: 14)), + ), + if (PlatformUtils.isDesktop) + ListTile( + onTap: () { + Get.back(); + PageUtils.launchURL(item.url); + }, + dense: true, + title: const Text('网页打开', style: TextStyle(fontSize: 14)), + ) + else if (widget.sources.length > 1) + ListTile( + onTap: () { + Get.back(); + ImageUtils.downloadImg( + widget.sources.map((item) => item.url).toList(), + ); + }, + dense: true, + title: const Text('保存全部图片', style: TextStyle(fontSize: 14)), + ), + if (item.sourceType == SourceType.livePhoto) + ListTile( + onTap: () { + Get.back(); + ImageUtils.downloadLivePhoto( + url: item.url, + liveUrl: item.liveUrl!, + width: item.width!, + height: item.height!, + ); + }, + dense: true, + title: Text( + '保存${Platform.isIOS ? ' Live Photo' : '视频'}', + style: const TextStyle(fontSize: 14), + ), + ), + ], + ), + ), + ); + } + + void _showDesktopMenu(TapUpDetails details) { + final item = widget.sources[_currIndex.value]; + if (item.sourceType == .fileImage) return; + showMenu( + context: context, + position: PageUtils.menuPosition(details.globalPosition), + items: [ + PopupMenuItem( + height: 42, + onTap: () => Utils.copyText(item.url), + child: const Text('复制链接', style: TextStyle(fontSize: 14)), + ), + PopupMenuItem( + height: 42, + onTap: () => ImageUtils.downloadImg([item.url]), + child: const Text('保存图片', style: TextStyle(fontSize: 14)), + ), + PopupMenuItem( + height: 42, + onTap: () => PageUtils.launchURL(item.url), + child: const Text('网页打开', style: TextStyle(fontSize: 14)), + ), + if (item.sourceType == SourceType.livePhoto) + PopupMenuItem( + height: 42, + onTap: () => ImageUtils.downloadLivePhoto( + url: item.url, + liveUrl: item.liveUrl!, + width: item.width!, + height: item.height!, + ), + child: const Text('保存视频', style: TextStyle(fontSize: 14)), + ), + ], + ); + } + + Widget loadingBuilder( + BuildContext context, + Widget child, + ImageChunkEvent? loadingProgress, + ) { + if (loadingProgress != null) { + if (loadingProgress.cumulativeBytesLoaded != + loadingProgress.expectedTotalBytes && + loadingProgress.expectedTotalBytes != null) { + return Stack( + fit: .expand, + alignment: .center, + clipBehavior: .none, + children: [ + child, + Center( + child: LoadingIndicator( + size: 39.4, + progress: + loadingProgress.cumulativeBytesLoaded / + loadingProgress.expectedTotalBytes!, + ), + ), + ], + ); + } + } + return child; + } +} diff --git a/lib/common/widgets/interactiveviewer_gallery/hero_dialog_route.dart b/lib/common/widgets/image_viewer/hero_dialog_route.dart similarity index 100% rename from lib/common/widgets/interactiveviewer_gallery/hero_dialog_route.dart rename to lib/common/widgets/image_viewer/hero_dialog_route.dart diff --git a/lib/common/widgets/image_viewer/image.dart b/lib/common/widgets/image_viewer/image.dart new file mode 100644 index 000000000..ab3add1e3 --- /dev/null +++ b/lib/common/widgets/image_viewer/image.dart @@ -0,0 +1,675 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:io' show File; + +import 'package:PiliPlus/common/widgets/gesture/image_horizontal_drag_gesture_recognizer.dart'; +import 'package:PiliPlus/common/widgets/gesture/image_tap_gesture_recognizer.dart'; +import 'package:PiliPlus/common/widgets/image_viewer/viewer.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/scheduler.dart'; +import 'package:flutter/semantics.dart'; + +class Image extends StatefulWidget { + const Image({ + super.key, + required this.image, + this.frameBuilder, + this.loadingBuilder, + this.errorBuilder, + this.semanticLabel, + this.excludeFromSemantics = false, + this.width, + this.height, + this.color, + this.opacity, + this.colorBlendMode, + this.fit, + this.alignment = Alignment.center, + this.repeat = ImageRepeat.noRepeat, + this.centerSlice, + this.matchTextDirection = false, + this.gaplessPlayback = false, + this.isAntiAlias = false, + this.filterQuality = FilterQuality.medium, + required this.minScale, + required this.maxScale, + required this.containerSize, + required this.isAnimating, + required this.onDragStart, + required this.onDragUpdate, + required this.onDragEnd, + required this.tapGestureRecognizer, + required this.horizontalDragGestureRecognizer, + required this.onChangePage, + }); + + Image.network( + String src, { + super.key, + double scale = 1.0, + this.frameBuilder, + this.loadingBuilder, + this.errorBuilder, + this.semanticLabel, + this.excludeFromSemantics = false, + this.width, + this.height, + this.color, + this.opacity, + this.colorBlendMode, + this.fit, + this.alignment = Alignment.center, + this.repeat = ImageRepeat.noRepeat, + this.centerSlice, + this.matchTextDirection = false, + this.gaplessPlayback = false, + this.filterQuality = FilterQuality.medium, + this.isAntiAlias = false, + Map? headers, + int? cacheWidth, + int? cacheHeight, + WebHtmlElementStrategy webHtmlElementStrategy = + WebHtmlElementStrategy.never, + required this.minScale, + required this.maxScale, + required this.containerSize, + required this.isAnimating, + required this.onDragStart, + required this.onDragUpdate, + required this.onDragEnd, + required this.tapGestureRecognizer, + required this.horizontalDragGestureRecognizer, + required this.onChangePage, + }) : image = ResizeImage.resizeIfNeeded( + cacheWidth, + cacheHeight, + NetworkImage( + src, + scale: scale, + headers: headers, + webHtmlElementStrategy: webHtmlElementStrategy, + ), + ), + assert(cacheWidth == null || cacheWidth > 0), + assert(cacheHeight == null || cacheHeight > 0); + + Image.file( + File file, { + super.key, + double scale = 1.0, + this.frameBuilder, + this.errorBuilder, + this.semanticLabel, + this.excludeFromSemantics = false, + this.width, + this.height, + this.color, + this.opacity, + this.colorBlendMode, + this.fit, + this.alignment = Alignment.center, + this.repeat = ImageRepeat.noRepeat, + this.centerSlice, + this.matchTextDirection = false, + this.gaplessPlayback = false, + this.isAntiAlias = false, + this.filterQuality = FilterQuality.medium, + int? cacheWidth, + int? cacheHeight, + required this.minScale, + required this.maxScale, + required this.containerSize, + required this.isAnimating, + required this.onDragStart, + required this.onDragUpdate, + required this.onDragEnd, + required this.tapGestureRecognizer, + required this.horizontalDragGestureRecognizer, + required this.onChangePage, + }) : assert( + !kIsWeb, + 'Image.file is not supported on Flutter Web. ' + 'Consider using either Image.asset or Image.network instead.', + ), + image = ResizeImage.resizeIfNeeded( + cacheWidth, + cacheHeight, + FileImage(file, scale: scale), + ), + loadingBuilder = null, + assert(cacheWidth == null || cacheWidth > 0), + assert(cacheHeight == null || cacheHeight > 0); + + Image.asset( + String name, { + super.key, + AssetBundle? bundle, + this.frameBuilder, + this.errorBuilder, + this.semanticLabel, + this.excludeFromSemantics = false, + double? scale, + this.width, + this.height, + this.color, + this.opacity, + this.colorBlendMode, + this.fit, + this.alignment = Alignment.center, + this.repeat = ImageRepeat.noRepeat, + this.centerSlice, + this.matchTextDirection = false, + this.gaplessPlayback = false, + this.isAntiAlias = false, + String? package, + this.filterQuality = FilterQuality.medium, + int? cacheWidth, + int? cacheHeight, + required this.minScale, + required this.maxScale, + required this.containerSize, + required this.isAnimating, + required this.onDragStart, + required this.onDragUpdate, + required this.onDragEnd, + required this.tapGestureRecognizer, + required this.horizontalDragGestureRecognizer, + required this.onChangePage, + }) : image = ResizeImage.resizeIfNeeded( + cacheWidth, + cacheHeight, + scale != null + ? ExactAssetImage( + name, + bundle: bundle, + scale: scale, + package: package, + ) + : AssetImage(name, bundle: bundle, package: package), + ), + loadingBuilder = null, + assert(cacheWidth == null || cacheWidth > 0), + assert(cacheHeight == null || cacheHeight > 0); + + Image.memory( + Uint8List bytes, { + super.key, + double scale = 1.0, + this.frameBuilder, + this.errorBuilder, + this.semanticLabel, + this.excludeFromSemantics = false, + this.width, + this.height, + this.color, + this.opacity, + this.colorBlendMode, + this.fit, + this.alignment = Alignment.center, + this.repeat = ImageRepeat.noRepeat, + this.centerSlice, + this.matchTextDirection = false, + this.gaplessPlayback = false, + this.isAntiAlias = false, + this.filterQuality = FilterQuality.medium, + int? cacheWidth, + int? cacheHeight, + required this.minScale, + required this.maxScale, + required this.containerSize, + required this.isAnimating, + required this.onDragStart, + required this.onDragUpdate, + required this.onDragEnd, + required this.tapGestureRecognizer, + required this.horizontalDragGestureRecognizer, + required this.onChangePage, + }) : image = ResizeImage.resizeIfNeeded( + cacheWidth, + cacheHeight, + MemoryImage(bytes, scale: scale), + ), + loadingBuilder = null, + assert(cacheWidth == null || cacheWidth > 0), + assert(cacheHeight == null || cacheHeight > 0); + + final ImageProvider image; + + final ImageFrameBuilder? frameBuilder; + + final ImageLoadingBuilder? loadingBuilder; + + final ImageErrorWidgetBuilder? errorBuilder; + + final double? width; + + final double? height; + + final Color? color; + + final Animation? opacity; + + final FilterQuality filterQuality; + + final BlendMode? colorBlendMode; + + final BoxFit? fit; + + final AlignmentGeometry alignment; + + final ImageRepeat repeat; + + final Rect? centerSlice; + + final bool matchTextDirection; + + final bool gaplessPlayback; + + final String? semanticLabel; + + final bool excludeFromSemantics; + + final bool isAntiAlias; + + final double minScale; + final double maxScale; + final Size containerSize; + + final ValueGetter isAnimating; + final ValueChanged? onDragStart; + final ValueChanged? onDragUpdate; + final ValueChanged? onDragEnd; + final ValueChanged? onChangePage; + + final ImageTapGestureRecognizer tapGestureRecognizer; + final ImageHorizontalDragGestureRecognizer horizontalDragGestureRecognizer; + + @override + State createState() => _ImageState(); + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties + ..add(DiagnosticsProperty('image', image)) + ..add(DiagnosticsProperty('frameBuilder', frameBuilder)) + ..add( + DiagnosticsProperty('loadingBuilder', loadingBuilder), + ) + ..add(DoubleProperty('width', width, defaultValue: null)) + ..add(DoubleProperty('height', height, defaultValue: null)) + ..add(ColorProperty('color', color, defaultValue: null)) + ..add( + DiagnosticsProperty?>( + 'opacity', + opacity, + defaultValue: null, + ), + ) + ..add( + EnumProperty( + 'colorBlendMode', + colorBlendMode, + defaultValue: null, + ), + ) + ..add(EnumProperty('fit', fit, defaultValue: null)) + ..add( + DiagnosticsProperty( + 'alignment', + alignment, + defaultValue: null, + ), + ) + ..add( + EnumProperty( + 'repeat', + repeat, + defaultValue: ImageRepeat.noRepeat, + ), + ) + ..add( + DiagnosticsProperty( + 'centerSlice', + centerSlice, + defaultValue: null, + ), + ) + ..add( + FlagProperty( + 'matchTextDirection', + value: matchTextDirection, + ifTrue: 'match text direction', + ), + ) + ..add( + StringProperty('semanticLabel', semanticLabel, defaultValue: null), + ) + ..add( + DiagnosticsProperty( + 'this.excludeFromSemantics', + excludeFromSemantics, + ), + ) + ..add(EnumProperty('filterQuality', filterQuality)); + } +} + +class _ImageState extends State with WidgetsBindingObserver { + ImageStream? _imageStream; + ImageInfo? _imageInfo; + ImageChunkEvent? _loadingProgress; + bool _isListeningToStream = false; + int? _frameNumber; + bool _wasSynchronouslyLoaded = false; + late DisposableBuildContext> _scrollAwareContext; + Object? _lastException; + StackTrace? _lastStack; + ImageStreamCompleterHandle? _completerHandle; + + bool _isPaused = false; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addObserver(this); + _scrollAwareContext = DisposableBuildContext>(this); + } + + @override + void dispose() { + assert(_imageStream != null); + WidgetsBinding.instance.removeObserver(this); + _stopListeningToStream(); + _completerHandle?.dispose(); + _scrollAwareContext.dispose(); + _replaceImage(info: null); + super.dispose(); + } + + @override + void didChangeDependencies() { + _resolveImage(); + + _isPaused = + !TickerMode.valuesOf(context).enabled || + (MediaQuery.maybeDisableAnimationsOf(context) ?? false); + + if (_isPaused && _frameNumber != null) { + _stopListeningToStream(keepStreamAlive: true); + } else { + _listenToStream(); + } + + super.didChangeDependencies(); + } + + @override + void didUpdateWidget(Image oldWidget) { + super.didUpdateWidget(oldWidget); + if (_isListeningToStream && + (widget.loadingBuilder == null) != (oldWidget.loadingBuilder == null)) { + final ImageStreamListener oldListener = _getListener(); + _imageStream!.addListener(_getListener(recreateListener: true)); + _imageStream!.removeListener(oldListener); + } + if (widget.image != oldWidget.image) { + _resolveImage(); + _listenToStream(); + } + } + + @override + void reassemble() { + _resolveImage(); + super.reassemble(); + } + + void _resolveImage() { + final provider = ScrollAwareImageProvider( + context: _scrollAwareContext, + imageProvider: widget.image, + ); + final ImageStream newStream = provider.resolve( + createLocalImageConfiguration( + context, + size: widget.width != null && widget.height != null + ? Size(widget.width!, widget.height!) + : null, + ), + ); + _updateSourceStream(newStream); + } + + ImageStreamListener? _imageStreamListener; + ImageStreamListener _getListener({bool recreateListener = false}) { + if (_imageStreamListener == null || recreateListener) { + _lastException = null; + _lastStack = null; + _imageStreamListener = ImageStreamListener( + _handleImageFrame, + onChunk: widget.loadingBuilder == null ? null : _handleImageChunk, + onError: widget.errorBuilder != null || kDebugMode + ? (Object error, StackTrace? stackTrace) { + setState(() { + _lastException = error; + _lastStack = stackTrace; + }); + assert(() { + if (widget.errorBuilder == null) { + throw error; + } + return true; + }()); + } + : null, + ); + } + return _imageStreamListener!; + } + + void _handleImageFrame(ImageInfo imageInfo, bool synchronousCall) { + setState(() { + _replaceImage(info: imageInfo); + _loadingProgress = null; + _lastException = null; + _lastStack = null; + _frameNumber = _frameNumber == null ? 0 : _frameNumber! + 1; + _wasSynchronouslyLoaded = _wasSynchronouslyLoaded | synchronousCall; + }); + if (_isPaused) { + _stopListeningToStream(keepStreamAlive: true); + } + } + + void _handleImageChunk(ImageChunkEvent event) { + assert(widget.loadingBuilder != null); + setState(() { + _loadingProgress = event; + _lastException = null; + _lastStack = null; + }); + } + + void _replaceImage({required ImageInfo? info}) { + final ImageInfo? oldImageInfo = _imageInfo; + if (oldImageInfo != null) { + SchedulerBinding.instance.addPostFrameCallback( + (Duration duration) => oldImageInfo.dispose(), + debugLabel: 'Image.disposeOldInfo', + ); + } + _imageInfo = info; + } + + void _updateSourceStream(ImageStream newStream) { + if (_imageStream?.key == newStream.key) { + return; + } + + if (_isListeningToStream) { + _imageStream!.removeListener(_getListener()); + } + + if (!widget.gaplessPlayback) { + setState(() { + _replaceImage(info: null); + }); + } + + setState(() { + _loadingProgress = null; + _frameNumber = null; + _wasSynchronouslyLoaded = false; + }); + + _imageStream = newStream; + if (_isListeningToStream) { + _imageStream!.addListener(_getListener()); + } + } + + void _listenToStream() { + if (_isListeningToStream) { + return; + } + + _isListeningToStream = true; + _imageStream!.addListener(_getListener()); + _completerHandle?.dispose(); + _completerHandle = null; + } + + void _stopListeningToStream({bool keepStreamAlive = false}) { + if (!_isListeningToStream) { + return; + } + + if (keepStreamAlive && + _completerHandle == null && + _imageStream?.completer != null) { + _completerHandle = _imageStream!.completer!.keepAlive(); + } + + if (_imageStream!.completer != null && widget.errorBuilder != null) { + _imageStream!.completer!.addEphemeralErrorListener( + ( + Object exception, + StackTrace? stackTrace, + ) {}, + ); + } + _imageStream!.removeListener(_getListener()); + _isListeningToStream = false; + } + + Widget _debugBuildErrorWidget(BuildContext context, Object error) { + return Stack( + alignment: Alignment.center, + children: [ + const Positioned.fill(child: Placeholder(color: Color(0xCF8D021F))), + Padding( + padding: const EdgeInsets.all(4.0), + child: FittedBox( + child: Text( + '$error', + textAlign: TextAlign.center, + textDirection: TextDirection.ltr, + style: const TextStyle( + shadows: [Shadow(blurRadius: 1.0)], + ), + ), + ), + ), + ], + ); + } + + @override + Widget build(BuildContext context) { + if (_lastException != null) { + if (widget.errorBuilder != null) { + return widget.errorBuilder!(context, _lastException!, _lastStack); + } + if (kDebugMode) { + return _debugBuildErrorWidget(context, _lastException!); + } + } + + Widget result; + if (_imageInfo != null) { + // final isLongPic = + // _imageInfo!.image.height / _imageInfo!.image.width > + // StyleString.imgMaxRatio; + result = Viewer( + minScale: widget.minScale, + maxScale: widget.maxScale, + containerSize: widget.containerSize, + childSize: Size( + _imageInfo!.image.width.toDouble(), + _imageInfo!.image.height.toDouble(), + ), + isAnimating: widget.isAnimating, + onDragStart: widget.onDragStart, + onDragUpdate: widget.onDragUpdate, + onDragEnd: widget.onDragEnd, + tapGestureRecognizer: widget.tapGestureRecognizer, + horizontalDragGestureRecognizer: widget.horizontalDragGestureRecognizer, + onChangePage: widget.onChangePage, + child: RawImage( + image: _imageInfo!.image, + ), + ); + } else { + result = const SizedBox.expand(); + } + + if (!widget.excludeFromSemantics) { + result = Semantics( + container: widget.semanticLabel != null, + image: true, + label: widget.semanticLabel ?? '', + child: result, + ); + } + + if (widget.frameBuilder != null) { + result = widget.frameBuilder!( + context, + result, + _frameNumber, + _wasSynchronouslyLoaded, + ); + } + + if (widget.loadingBuilder != null) { + result = widget.loadingBuilder!(context, result, _loadingProgress); + } + + return result; + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder description) { + super.debugFillProperties(description); + description + ..add(DiagnosticsProperty('stream', _imageStream)) + ..add(DiagnosticsProperty('pixels', _imageInfo)) + ..add( + DiagnosticsProperty( + 'loadingProgress', + _loadingProgress, + ), + ) + ..add(DiagnosticsProperty('frameNumber', _frameNumber)) + ..add( + DiagnosticsProperty( + 'wasSynchronouslyLoaded', + _wasSynchronouslyLoaded, + ), + ); + } +} diff --git a/lib/common/widgets/image_viewer/loading_indicator.dart b/lib/common/widgets/image_viewer/loading_indicator.dart new file mode 100644 index 000000000..3835e6980 --- /dev/null +++ b/lib/common/widgets/image_viewer/loading_indicator.dart @@ -0,0 +1,148 @@ +/* + * This file is part of PiliPlus + * + * PiliPlus is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * PiliPlus is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with PiliPlus. If not, see . + */ + +import 'dart:io' show Platform; +import 'dart:math' show pi; + +import 'package:flutter/material.dart'; + +/// +/// created by dom on 2026/02/14 +/// + +class LoadingIndicator extends LeafRenderObjectWidget { + const LoadingIndicator({ + super.key, + required this.size, + required this.progress, + }); + + final double size; + final double progress; + + @override + RenderObject createRenderObject(BuildContext context) { + return RenderLoadingIndicator( + preferredSize: size, + progress: progress, + ); + } + + @override + void updateRenderObject( + BuildContext context, + RenderLoadingIndicator renderObject, + ) { + renderObject + ..preferredSize = size + ..progress = progress; + } +} + +class RenderLoadingIndicator extends RenderBox { + RenderLoadingIndicator({ + required double preferredSize, + required double progress, + }) : _preferredSize = preferredSize, + _progress = progress; + + double _preferredSize; + double get preferredSize => _preferredSize; + set preferredSize(double value) { + if (_preferredSize == value) return; + _preferredSize = value; + markNeedsLayout(); + } + + double _progress; + double get progress => _progress; + set progress(double value) { + if (_progress == value) return; + _progress = value; + markNeedsPaint(); + } + + @override + void performLayout() { + size = constraints.constrainDimensions(_preferredSize, _preferredSize); + } + + @override + void paint(PaintingContext context, Offset offset) { + if (_progress == 0) { + return; + } + const padding = 8.0; + const strokeWidth = 1.4; + const startAngle = -pi / 2; + + final paint = Paint()..isAntiAlias = true; + final size = this.size; + final radius = size.width / 2 - strokeWidth; + final center = size.center(.zero); + + if (Platform.isIOS) { + context.canvas + ..drawCircle( + center, + radius, + paint + ..strokeWidth = strokeWidth + ..color = Colors.white, + ) + ..drawCircle( + center, + radius - strokeWidth, + paint..color = const Color(0x80000000), + ) + ..drawArc( + Rect.fromCircle(center: center, radius: radius - padding), + startAngle, + progress * 2 * pi, + true, + paint..color = Colors.white, + ); + } else { + context.canvas + ..drawCircle( + center, + radius, + paint + ..style = .fill + ..color = const Color(0x80000000), + ) + ..drawCircle( + center, + radius, + paint + ..style = .stroke + ..strokeWidth = strokeWidth + ..color = Colors.white, + ) + ..drawArc( + Rect.fromCircle(center: center, radius: radius - padding), + startAngle, + progress * 2 * pi, + true, + paint..style = .fill, + ); + } + } + + @override + bool get isRepaintBoundary => true; +} diff --git a/lib/common/widgets/image_viewer/viewer.dart b/lib/common/widgets/image_viewer/viewer.dart new file mode 100644 index 000000000..d7703ac93 --- /dev/null +++ b/lib/common/widgets/image_viewer/viewer.dart @@ -0,0 +1,448 @@ +/* + * This file is part of PiliPlus + * + * PiliPlus is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * PiliPlus is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with PiliPlus. If not, see . + */ + +import 'dart:math' as math; + +import 'package:PiliPlus/common/constants.dart'; +import 'package:PiliPlus/common/widgets/gesture/image_horizontal_drag_gesture_recognizer.dart'; +import 'package:PiliPlus/common/widgets/gesture/image_tap_gesture_recognizer.dart'; +import 'package:easy_debounce/easy_throttle.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/physics.dart' show FrictionSimulation; +import 'package:flutter/services.dart' show HardwareKeyboard; +import 'package:get/get_core/src/get_main.dart'; +import 'package:get/get_navigation/src/extension_navigation.dart' + show GetNavigation; + +/// +/// created by dom on 2026/02/14 +/// + +class Viewer extends StatefulWidget { + const Viewer({ + super.key, + required this.minScale, + required this.maxScale, + required this.containerSize, + required this.childSize, + required this.isAnimating, + required this.onDragStart, + required this.onDragUpdate, + required this.onDragEnd, + required this.tapGestureRecognizer, + required this.horizontalDragGestureRecognizer, + required this.onChangePage, + required this.child, + }); + + final double minScale; + final double maxScale; + final Size containerSize; + final Size childSize; + final Widget child; + + final ValueGetter isAnimating; + final ValueChanged? onDragStart; + final ValueChanged? onDragUpdate; + final ValueChanged? onDragEnd; + final ValueChanged? onChangePage; + + final ImageTapGestureRecognizer tapGestureRecognizer; + final ImageHorizontalDragGestureRecognizer horizontalDragGestureRecognizer; + + @override + State createState() => _ViewerState(); +} + +class _ViewerState extends State with SingleTickerProviderStateMixin { + static const double _interactionEndFrictionCoefficient = 0.0001; // 0.0000135 + static const double _scaleFactor = kDefaultMouseScrollToScaleFactor; + + _GestureType? _gestureType; + + late double _scale; + double? _scaleStart; + late Offset _position; + Offset? _referenceFocalPoint; + + late Size _imageSize; + + late final ImageTapGestureRecognizer _tapGestureRecognizer; + late final ImageHorizontalDragGestureRecognizer + _horizontalDragGestureRecognizer; + late final ScaleGestureRecognizer _scaleGestureRecognizer; + late final DoubleTapGestureRecognizer _doubleTapGestureRecognizer; + + Offset? _downPos; + AnimationController? _animationController; + AnimationController get _effectiveAnimationController => + _animationController ??= AnimationController( + vsync: this, + duration: const Duration(milliseconds: 300), + )..addListener(_listener); + late final _tween = Matrix4Tween(); + late final _animatable = _tween.chain(CurveTween(curve: Curves.easeOut)); + + void _listener() { + final storage = _animatable.evaluate(_effectiveAnimationController); + _scale = storage[0]; + _position = Offset(storage[12], storage[13]); + setState(() {}); + } + + void _reset() { + _scale = 1.0; + _position = .zero; + } + + void _initSize() { + _reset(); + _imageSize = applyBoxFit( + .scaleDown, + widget.childSize, + widget.containerSize, + ).destination; + // if (_imageSize.height / _imageSize.width > StyleString.imgMaxRatio) { + // _imageSize = applyBoxFit( + // .fitWidth, + // widget.childSize, + // widget.containerSize, + // ).destination; + // final containerWidth = widget.containerSize.width; + // final containerHeight = widget.containerSize.height; + // _scale = containerWidth / _imageSize.width; + // final imageHeight = _imageSize.height * _scale; + // _position = Offset( + // (1 - _scale) * containerWidth / 2, + // (imageHeight - _scale * containerHeight) / 2, + // ); + // } + } + + @override + void initState() { + super.initState(); + _initSize(); + + _tapGestureRecognizer = widget.tapGestureRecognizer; + _horizontalDragGestureRecognizer = widget.horizontalDragGestureRecognizer; + + final gestureSettings = MediaQuery.maybeGestureSettingsOf(Get.context!); + _scaleGestureRecognizer = ScaleGestureRecognizer(debugOwner: this) + ..dragStartBehavior = .start + ..onStart = _onScaleStart + ..onUpdate = _onScaleUpdate + ..onEnd = _onScaleEnd + ..gestureSettings = gestureSettings; + _doubleTapGestureRecognizer = DoubleTapGestureRecognizer(debugOwner: this) + ..onDoubleTapDown = _onDoubleTapDown + ..onDoubleTap = _onDoubleTap + ..gestureSettings = gestureSettings; + } + + @override + void didUpdateWidget(Viewer oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.containerSize != widget.containerSize || + oldWidget.childSize != widget.childSize) { + _initSize(); + } + } + + @override + void dispose() { + _animationController + ?..removeListener(_listener) + ..dispose(); + _animationController = null; + _scaleGestureRecognizer.dispose(); + _doubleTapGestureRecognizer.dispose(); + super.dispose(); + } + + Offset _toScene(Offset localFocalPoint) { + return (localFocalPoint - _position) / _scale; + } + + Offset _clampPosition(Offset offset, double scale) { + final containerSize = widget.containerSize; + final containerWidth = containerSize.width; + final containerHeight = containerSize.height; + final imageWidth = _imageSize.width * scale; + final imageHeight = _imageSize.height * scale; + + final dx = (1 - scale) * containerWidth / 2; + final dxOffset = (imageWidth - containerWidth) / 2; + + final dy = (1 - scale) * containerHeight / 2; + final dyOffset = (imageHeight - containerHeight) / 2; + + return Offset( + imageWidth > containerWidth + ? clampDouble(offset.dx, dx - dxOffset, dx + dxOffset) + : dx, + imageHeight > containerHeight + ? clampDouble(offset.dy, dy - dyOffset, dy + dyOffset) + : dy, + ); + } + + Offset _matrixTranslate(Offset translation) { + if (translation == .zero) { + return _position; + } + return _clampPosition(_position + translation * _scale, _scale); + } + + void _onDoubleTapDown(TapDownDetails details) { + _downPos = details.localPosition; + } + + void _onDoubleTap() { + EasyThrottle.throttle( + 'VIEWER_TAP', + const Duration(milliseconds: 555), + _handleDoubleTap, + ); + } + + void _handleDoubleTap() { + final Matrix4 begin; + final Matrix4 end; + if (_scale == 1.0) { + final imageWidth = _imageSize.width; + final imageHeight = _imageSize.height; + final isLongPic = imageHeight / imageWidth >= StyleString.imgMaxRatio; + double scale = widget.maxScale * 0.6; + if (isLongPic) { + scale = widget.containerSize.width / _imageSize.width; + } else { + scale = widget.maxScale * 0.6; + } + if (scale <= widget.minScale) { + scale = widget.maxScale; + } + begin = Matrix4.identity(); + final position = _clampPosition(_downPos! * (1 - scale), scale); + end = Matrix4.identity() + ..translateByDouble(position.dx, position.dy, 0.0, 1.0) + ..scaleByDouble(scale, scale, scale, 1.0); + } else { + begin = Matrix4.identity() + ..translateByDouble(_position.dx, _position.dy, 0.0, 1.0) + ..scaleByDouble(_scale, _scale, _scale, 1.0); + end = Matrix4.identity(); + } + _tween + ..begin = begin + ..end = end; + _effectiveAnimationController + ..duration = const Duration(milliseconds: 300) + ..forward(from: 0); + } + + void _onScaleStart(ScaleStartDetails details) { + if (widget.isAnimating() || (details.pointerCount < 2 && _scale == 1.0)) { + widget.onDragStart?.call(details); + return; + } + + _scaleStart = _scale; + _referenceFocalPoint = _toScene(details.localFocalPoint); + } + + void _onScaleUpdate(ScaleUpdateDetails details) { + if (widget.isAnimating() || (details.pointerCount < 2 && _scale == 1.0)) { + widget.onDragUpdate?.call(details); + return; + } + + if (details.scale != 1.0) { + _gestureType = .scale; + _scale = clampDouble( + _scaleStart! * details.scale, + widget.minScale, + widget.maxScale, + ); + + final Offset focalPointSceneScaled = _toScene(details.localFocalPoint); + _position = _matrixTranslate( + focalPointSceneScaled - _referenceFocalPoint!, + ); + setState(() {}); + } else { + _gestureType = .pan; + final Offset focalPointScene = _toScene(details.localFocalPoint); + final Offset translationChange = focalPointScene - _referenceFocalPoint!; + _position = _matrixTranslate(translationChange); + _referenceFocalPoint = _toScene(details.localFocalPoint); + setState(() {}); + } + } + + /// ref [InteractiveViewer] + void _onScaleEnd(ScaleEndDetails details) { + if (widget.isAnimating() || (details.pointerCount < 2 && _scale == 1.0)) { + widget.onDragEnd?.call(details); + return; + } + + switch (_gestureType) { + case _GestureType.pan: + if (details.velocity.pixelsPerSecond.distance < kMinFlingVelocity) { + return; + } + final FrictionSimulation frictionSimulationX = FrictionSimulation( + _interactionEndFrictionCoefficient, + _position.dx, + details.velocity.pixelsPerSecond.dx, + ); + final FrictionSimulation frictionSimulationY = FrictionSimulation( + _interactionEndFrictionCoefficient, + _position.dy, + details.velocity.pixelsPerSecond.dy, + ); + final double tFinal = _getFinalTime( + details.velocity.pixelsPerSecond.distance, + _interactionEndFrictionCoefficient, + ); + final position = _clampPosition( + Offset(frictionSimulationX.finalX, frictionSimulationY.finalX), + _scale, + ); + _tween + ..begin = (Matrix4.identity() + ..translateByDouble(_position.dx, _position.dy, 0.0, 1.0) + ..scaleByDouble(_scale, _scale, _scale, 1.0)) + ..end = (Matrix4.identity() + ..translateByDouble(position.dx, position.dy, 0.0, 1.0) + ..scaleByDouble(_scale, _scale, _scale, 1.0)); + _effectiveAnimationController + ..duration = Duration(milliseconds: (tFinal * 1000).round()) + ..forward(from: 0); + case _GestureType.scale: + // if (details.scaleVelocity.abs() < 0.1) { + // return; + // } + // final double scale = _scale; + // final FrictionSimulation frictionSimulation = FrictionSimulation( + // _interactionEndFrictionCoefficient * _scaleFactor, + // scale, + // details.scaleVelocity / 10, + // ); + // final double tFinal = _getFinalTime( + // details.scaleVelocity.abs(), + // _interactionEndFrictionCoefficient, + // effectivelyMotionless: 0.1, + // ); + // _scaleAnimation = _scaleController.drive( + // Tween( + // begin: scale, + // end: frictionSimulation.x(tFinal), + // ).chain(CurveTween(curve: Curves.decelerate)), + // )..addListener(_handleScaleAnimation); + // _effectiveAnimationController + // ..duration = Duration(milliseconds: (tFinal * 1000).round()) + // ..forward(from: 0); + case null: + } + _gestureType = null; + } + + @override + Widget build(BuildContext context) { + final matrix = Matrix4.identity() + ..translateByDouble(_position.dx, _position.dy, 0.0, 1.0) + ..scaleByDouble(_scale, _scale, _scale, 1.0); + return Listener( + behavior: .opaque, + onPointerDown: _onPointerDown, + onPointerPanZoomStart: _onPointerPanZoomStart, + onPointerSignal: _onPointerSignal, + child: ClipRRect( + child: Transform( + transform: matrix, + child: widget.child, + ), + ), + ); + } + + void _onPointerDown(PointerDownEvent event) { + _tapGestureRecognizer.addPointer(event); + _doubleTapGestureRecognizer.addPointer(event); + _horizontalDragGestureRecognizer + ..isBoundaryAllowed = _isBoundaryAllowed + ..addPointer(event); + _scaleGestureRecognizer.addPointer(event); + } + + void _onPointerPanZoomStart(PointerPanZoomStartEvent event) { + _scaleGestureRecognizer.addPointerPanZoom(event); + } + + bool _isBoundaryAllowed(Offset? initialPosition, OffsetPair lastPosition) { + if (initialPosition == null) { + return true; + } + if (_scale <= 1.0) { + return true; + } + final containerWidth = widget.containerSize.width; + final imageWidth = _imageSize.width * _scale; + final dx = (1 - _scale) * containerWidth / 2; + final dxOffset = (imageWidth - containerWidth) / 2; + if (initialPosition.dx < lastPosition.global.dx) { + return _position.dx == dx + dxOffset; + } else { + return _position.dx == dx - dxOffset; + } + } + + void _onPointerSignal(PointerSignalEvent event) { + if (event is PointerScrollEvent) { + if (widget.onChangePage != null && + !HardwareKeyboard.instance.isControlPressed) { + widget.onChangePage!.call(event.scrollDelta.dy < 0 ? -1 : 1); + return; + } + final double scaleChange = math.exp(-event.scrollDelta.dy / _scaleFactor); + final Offset local = event.localPosition; + final Offset focalPointScene = _toScene(local); + _scale = clampDouble( + _scale * scaleChange, + widget.minScale, + widget.maxScale, + ); + final Offset focalPointSceneScaled = _toScene(local); + _position = _matrixTranslate(focalPointSceneScaled - focalPointScene); + setState(() {}); + } + } +} + +enum _GestureType { pan, scale } + +double _getFinalTime( + double velocity, + double drag, { + double effectivelyMotionless = 10, +}) { + return math.log(effectivelyMotionless / velocity) / math.log(drag / 100); +} diff --git a/lib/common/widgets/interactiveviewer_gallery/interactive_viewer.dart b/lib/common/widgets/interactiveviewer_gallery/interactive_viewer.dart deleted file mode 100644 index 1008efa5b..000000000 --- a/lib/common/widgets/interactiveviewer_gallery/interactive_viewer.dart +++ /dev/null @@ -1,1360 +0,0 @@ -// Copyright 2014 The Flutter Authors. All rights reserved. -// Use of this source code is governed by a BSD-style license that can be -// found in the LICENSE file. - -// ignore_for_file: uri_does_not_exist_in_doc_import - -/// @docImport 'editable_text.dart'; -/// @docImport 'scroll_view.dart'; -/// @docImport 'table.dart'; -library; - -import 'dart:math' as math; - -import 'package:flutter/foundation.dart' show clampDouble; -import 'package:flutter/gestures.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter/physics.dart'; -import 'package:vector_math/vector_math_64.dart' show Quad, Vector3; - -/// A widget that enables pan and zoom interactions with its child. -/// -/// {@youtube 560 315 https://www.youtube.com/watch?v=zrn7V3bMJvg} -/// -/// The user can transform the child by dragging to pan or pinching to zoom. -/// -/// By default, InteractiveViewer clips its child using [Clip.hardEdge]. -/// To prevent this behavior, consider setting [clipBehavior] to [Clip.none]. -/// When [clipBehavior] is [Clip.none], InteractiveViewer may draw outside of -/// its original area of the screen, such as when a child is zoomed in and -/// increases in size. However, it will not receive gestures outside of its original area. -/// To prevent dead areas where InteractiveViewer does not receive gestures, -/// don't set [clipBehavior] or be sure that the InteractiveViewer widget is the -/// size of the area that should be interactive. -/// -/// See also: -/// * The [Flutter Gallery's transformations demo](https://github.com/flutter/gallery/blob/main/lib/demos/reference/transformations_demo.dart), -/// which includes the use of InteractiveViewer. -/// * The [flutter-go demo](https://github.com/justinmc/flutter-go), which includes robust positioning of an InteractiveViewer child -/// that works for all screen sizes and child sizes. -/// * The [Lazy Flutter Performance Session](https://www.youtube.com/watch?v=qax_nOpgz7E), which includes the use of an InteractiveViewer to -/// performantly view subsets of a large set of widgets using the builder constructor. -/// -/// {@tool dartpad} -/// This example shows a simple Container that can be panned and zoomed. -/// -/// ** See code in examples/api/lib/widgets/interactive_viewer/interactive_viewer.0.dart ** -/// {@end-tool} -@immutable -class InteractiveViewer extends StatefulWidget { - /// Create an InteractiveViewer. - InteractiveViewer({ - super.key, - this.clipBehavior = Clip.hardEdge, - this.panAxis = PanAxis.free, - this.boundaryMargin = EdgeInsets.zero, - this.constrained = true, - // These default scale values were eyeballed as reasonable limits for common - // use cases. - this.maxScale = 2.5, - this.minScale = 0.8, - this.interactionEndFrictionCoefficient = _kDrag, - this.onInteractionEnd, - this.onInteractionStart, - this.onInteractionUpdate, - this.panEnabled = true, - this.scaleEnabled = true, - this.scaleFactor = kDefaultMouseScrollToScaleFactor, - this.transformationController, - this.alignment, - this.trackpadScrollCausesScale = false, - this.onPanStart, - this.onPanUpdate, - this.onPanEnd, - this.isAnimating, - required Widget this.child, - }) : assert(minScale > 0), - assert(interactionEndFrictionCoefficient > 0), - assert(minScale.isFinite), - assert(maxScale > 0), - assert(!maxScale.isNaN), - assert(maxScale >= minScale), - // boundaryMargin must be either fully infinite or fully finite, but not - // a mix of both. - assert( - (boundaryMargin.horizontal.isInfinite && - boundaryMargin.vertical.isInfinite) || - (boundaryMargin.top.isFinite && - boundaryMargin.right.isFinite && - boundaryMargin.bottom.isFinite && - boundaryMargin.left.isFinite), - ), - builder = null; - - /// Creates an InteractiveViewer for a child that is created on demand. - /// - /// Can be used to render a child that changes in response to the current - /// transformation. - /// - /// See the [builder] attribute docs for an example of using it to optimize a - /// large child. - InteractiveViewer.builder({ - super.key, - this.clipBehavior = Clip.hardEdge, - this.panAxis = PanAxis.free, - this.boundaryMargin = EdgeInsets.zero, - // These default scale values were eyeballed as reasonable limits for common - // use cases. - this.maxScale = 2.5, - this.minScale = 0.8, - this.interactionEndFrictionCoefficient = _kDrag, - this.onInteractionEnd, - this.onInteractionStart, - this.onInteractionUpdate, - this.panEnabled = true, - this.scaleEnabled = true, - this.scaleFactor = 200.0, - this.transformationController, - this.alignment, - this.trackpadScrollCausesScale = false, - this.onPanStart, - this.onPanUpdate, - this.onPanEnd, - this.isAnimating, - required InteractiveViewerWidgetBuilder this.builder, - }) : assert(minScale > 0), - assert(interactionEndFrictionCoefficient > 0), - assert(minScale.isFinite), - assert(maxScale > 0), - assert(!maxScale.isNaN), - assert(maxScale >= minScale), - // boundaryMargin must be either fully infinite or fully finite, but not - // a mix of both. - assert( - (boundaryMargin.horizontal.isInfinite && - boundaryMargin.vertical.isInfinite) || - (boundaryMargin.top.isFinite && - boundaryMargin.right.isFinite && - boundaryMargin.bottom.isFinite && - boundaryMargin.left.isFinite), - ), - constrained = false, - child = null; - - final ValueGetter? isAnimating; - final ValueChanged? onPanStart; - final ValueChanged? onPanUpdate; - final ValueChanged? onPanEnd; - - /// The alignment of the child's origin, relative to the size of the box. - final Alignment? alignment; - - /// If set to [Clip.none], the child may extend beyond the size of the InteractiveViewer, - /// but it will not receive gestures in these areas. - /// Be sure that the InteractiveViewer is the desired size when using [Clip.none]. - /// - /// Defaults to [Clip.hardEdge]. - final Clip clipBehavior; - - /// When set to [PanAxis.aligned], panning is only allowed in the horizontal - /// axis or the vertical axis, diagonal panning is not allowed. - /// - /// When set to [PanAxis.vertical] or [PanAxis.horizontal] panning is only - /// allowed in the specified axis. For example, if set to [PanAxis.vertical], - /// panning will only be allowed in the vertical axis. And if set to [PanAxis.horizontal], - /// panning will only be allowed in the horizontal axis. - /// - /// When set to [PanAxis.free] panning is allowed in all directions. - /// - /// Defaults to [PanAxis.free]. - final PanAxis panAxis; - - /// A margin for the visible boundaries of the child. - /// - /// Any transformation that results in the viewport being able to view outside - /// of the boundaries will be stopped at the boundary. The boundaries do not - /// rotate with the rest of the scene, so they are always aligned with the - /// viewport. - /// - /// To produce no boundaries at all, pass infinite [EdgeInsets], such as - /// `EdgeInsets.all(double.infinity)`. - /// - /// No edge can be NaN. - /// - /// Defaults to [EdgeInsets.zero], which results in boundaries that are the - /// exact same size and position as the [child]. - final EdgeInsets boundaryMargin; - - /// Builds the child of this widget. - /// - /// Passed with the [InteractiveViewer.builder] constructor. Otherwise, the - /// [child] parameter must be passed directly, and this is null. - /// - /// {@tool dartpad} - /// This example shows how to use builder to create a [Table] whose cell - /// contents are only built when they are visible. Built and remove cells are - /// logged in the console for illustration. - /// - /// ** See code in examples/api/lib/widgets/interactive_viewer/interactive_viewer.builder.0.dart ** - /// {@end-tool} - /// - /// See also: - /// - /// * [ListView.builder], which follows a similar pattern. - final InteractiveViewerWidgetBuilder? builder; - - /// The child [Widget] that is transformed by InteractiveViewer. - /// - /// If the [InteractiveViewer.builder] constructor is used, then this will be - /// null, otherwise it is required. - final Widget? child; - - /// Whether the normal size constraints at this point in the widget tree are - /// applied to the child. - /// - /// If set to false, then the child will be given infinite constraints. This - /// is often useful when a child should be bigger than the InteractiveViewer. - /// - /// For example, for a child which is bigger than the viewport but can be - /// panned to reveal parts that were initially offscreen, [constrained] must - /// be set to false to allow it to size itself properly. If [constrained] is - /// true and the child can only size itself to the viewport, then areas - /// initially outside of the viewport will not be able to receive user - /// interaction events. If experiencing regions of the child that are not - /// receptive to user gestures, make sure [constrained] is false and the child - /// is sized properly. - /// - /// Defaults to true. - /// - /// {@tool dartpad} - /// This example shows how to create a pannable table. Because the table is - /// larger than the entire screen, setting [constrained] to false is necessary - /// to allow it to be drawn to its full size. The parts of the table that - /// exceed the screen size can then be panned into view. - /// - /// ** See code in examples/api/lib/widgets/interactive_viewer/interactive_viewer.constrained.0.dart ** - /// {@end-tool} - final bool constrained; - - /// If false, the user will be prevented from panning. - /// - /// Defaults to true. - /// - /// See also: - /// - /// * [scaleEnabled], which is similar but for scale. - final bool panEnabled; - - /// If false, the user will be prevented from scaling. - /// - /// Defaults to true. - /// - /// See also: - /// - /// * [panEnabled], which is similar but for panning. - final bool scaleEnabled; - - /// {@macro flutter.gestures.scale.trackpadScrollCausesScale} - final bool trackpadScrollCausesScale; - - /// Determines the amount of scale to be performed per pointer scroll. - /// - /// Defaults to [kDefaultMouseScrollToScaleFactor]. - /// - /// Increasing this value above the default causes scaling to feel slower, - /// while decreasing it causes scaling to feel faster. - /// - /// The amount of scale is calculated as the exponential function of the - /// [PointerScrollEvent.scrollDelta] to [scaleFactor] ratio. In the Flutter - /// engine, the mousewheel [PointerScrollEvent.scrollDelta] is hardcoded to 20 - /// per scroll, while a trackpad scroll can be any amount. - /// - /// Affects only pointer device scrolling, not pinch to zoom. - final double scaleFactor; - - /// The maximum allowed scale. - /// - /// The scale will be clamped between this and [minScale] inclusively. - /// - /// Defaults to 2.5. - /// - /// Must be greater than zero and greater than [minScale]. - final double maxScale; - - /// The minimum allowed scale. - /// - /// The scale will be clamped between this and [maxScale] inclusively. - /// - /// Scale is also affected by [boundaryMargin]. If the scale would result in - /// viewing beyond the boundary, then it will not be allowed. By default, - /// boundaryMargin is EdgeInsets.zero, so scaling below 1.0 will not be - /// allowed in most cases without first increasing the boundaryMargin. - /// - /// Defaults to 0.8. - /// - /// Must be a finite number greater than zero and less than [maxScale]. - final double minScale; - - /// Changes the deceleration behavior after a gesture. - /// - /// Defaults to 0.0000135. - /// - /// Must be a finite number greater than zero. - final double interactionEndFrictionCoefficient; - - /// Called when the user ends a pan or scale gesture on the widget. - /// - /// At the time this is called, the [TransformationController] will have - /// already been updated to reflect the change caused by the interaction, - /// though a pan may cause an inertia animation after this is called as well. - /// - /// {@template flutter.widgets.InteractiveViewer.onInteractionEnd} - /// Will be called even if the interaction is disabled with [panEnabled] or - /// [scaleEnabled] for both touch gestures and mouse interactions. - /// - /// A [GestureDetector] wrapping the InteractiveViewer will not respond to - /// [GestureDetector.onScaleStart], [GestureDetector.onScaleUpdate], and - /// [GestureDetector.onScaleEnd]. Use [onInteractionStart], - /// [onInteractionUpdate], and [onInteractionEnd] to respond to those - /// gestures. - /// {@endtemplate} - /// - /// See also: - /// - /// * [onInteractionStart], which handles the start of the same interaction. - /// * [onInteractionUpdate], which handles an update to the same interaction. - final GestureScaleEndCallback? onInteractionEnd; - - /// Called when the user begins a pan or scale gesture on the widget. - /// - /// At the time this is called, the [TransformationController] will not have - /// changed due to this interaction. - /// - /// {@macro flutter.widgets.InteractiveViewer.onInteractionEnd} - /// - /// The coordinates provided in the details' `focalPoint` and - /// `localFocalPoint` are normal Flutter event coordinates, not - /// InteractiveViewer scene coordinates. See - /// [TransformationController.toScene] for how to convert these coordinates to - /// scene coordinates relative to the child. - /// - /// See also: - /// - /// * [onInteractionUpdate], which handles an update to the same interaction. - /// * [onInteractionEnd], which handles the end of the same interaction. - final GestureScaleStartCallback? onInteractionStart; - - /// Called when the user updates a pan or scale gesture on the widget. - /// - /// At the time this is called, the [TransformationController] will have - /// already been updated to reflect the change caused by the interaction, if - /// the interaction caused the matrix to change. - /// - /// {@macro flutter.widgets.InteractiveViewer.onInteractionEnd} - /// - /// The coordinates provided in the details' `focalPoint` and - /// `localFocalPoint` are normal Flutter event coordinates, not - /// InteractiveViewer scene coordinates. See - /// [TransformationController.toScene] for how to convert these coordinates to - /// scene coordinates relative to the child. - /// - /// See also: - /// - /// * [onInteractionStart], which handles the start of the same interaction. - /// * [onInteractionEnd], which handles the end of the same interaction. - final GestureScaleUpdateCallback? onInteractionUpdate; - - /// A [TransformationController] for the transformation performed on the - /// child. - /// - /// Whenever the child is transformed, the [Matrix4] value is updated and all - /// listeners are notified. If the value is set, InteractiveViewer will update - /// to respect the new value. - /// - /// {@tool dartpad} - /// This example shows how transformationController can be used to animate the - /// transformation back to its starting position. - /// - /// ** See code in examples/api/lib/widgets/interactive_viewer/interactive_viewer.transformation_controller.0.dart ** - /// {@end-tool} - /// - /// See also: - /// - /// * [ValueNotifier], the parent class of TransformationController. - /// * [TextEditingController] for an example of another similar pattern. - final TransformationController? transformationController; - - // Used as the coefficient of friction in the inertial translation animation. - // This value was eyeballed to give a feel similar to Google Photos. - static const double _kDrag = 0.0000135; - - /// Returns the closest point to the given point on the given line segment. - @visibleForTesting - static Vector3 getNearestPointOnLine(Vector3 point, Vector3 l1, Vector3 l2) { - final double lengthSquared = - math.pow(l2.x - l1.x, 2.0).toDouble() + - math.pow(l2.y - l1.y, 2.0).toDouble(); - - // In this case, l1 == l2. - if (lengthSquared == 0) { - return l1; - } - - // Calculate how far down the line segment the closest point is and return - // the point. - final Vector3 l1P = point - l1; - final Vector3 l1L2 = l2 - l1; - final double fraction = clampDouble( - l1P.dot(l1L2) / lengthSquared, - 0.0, - 1.0, - ); - return l1 + l1L2 * fraction; - } - - /// Given a quad, return its axis aligned bounding box. - @visibleForTesting - static Quad getAxisAlignedBoundingBox(Quad quad) { - final double minX = math.min( - quad.point0.x, - math.min(quad.point1.x, math.min(quad.point2.x, quad.point3.x)), - ); - final double minY = math.min( - quad.point0.y, - math.min(quad.point1.y, math.min(quad.point2.y, quad.point3.y)), - ); - final double maxX = math.max( - quad.point0.x, - math.max(quad.point1.x, math.max(quad.point2.x, quad.point3.x)), - ); - final double maxY = math.max( - quad.point0.y, - math.max(quad.point1.y, math.max(quad.point2.y, quad.point3.y)), - ); - return Quad.points( - Vector3(minX, minY, 0), - Vector3(maxX, minY, 0), - Vector3(maxX, maxY, 0), - Vector3(minX, maxY, 0), - ); - } - - /// Returns true iff the point is inside the rectangle given by the Quad, - /// inclusively. - /// Algorithm from https://math.stackexchange.com/a/190373. - @visibleForTesting - static bool pointIsInside(Vector3 point, Quad quad) { - final Vector3 aM = point - quad.point0; - final Vector3 aB = quad.point1 - quad.point0; - final Vector3 aD = quad.point3 - quad.point0; - - final double aMAB = aM.dot(aB); - final double aBAB = aB.dot(aB); - final double aMAD = aM.dot(aD); - final double aDAD = aD.dot(aD); - - return 0 <= aMAB && aMAB <= aBAB && 0 <= aMAD && aMAD <= aDAD; - } - - /// Get the point inside (inclusively) the given Quad that is nearest to the - /// given Vector3. - @visibleForTesting - static Vector3 getNearestPointInside(Vector3 point, Quad quad) { - // If the point is inside the axis aligned bounding box, then it's ok where - // it is. - if (pointIsInside(point, quad)) { - return point; - } - - // Otherwise, return the nearest point on the quad. - final List closestPoints = [ - InteractiveViewer.getNearestPointOnLine(point, quad.point0, quad.point1), - InteractiveViewer.getNearestPointOnLine(point, quad.point1, quad.point2), - InteractiveViewer.getNearestPointOnLine(point, quad.point2, quad.point3), - InteractiveViewer.getNearestPointOnLine(point, quad.point3, quad.point0), - ]; - double minDistance = double.infinity; - late Vector3 closestOverall; - for (final Vector3 closePoint in closestPoints) { - final double distance = math.sqrt( - math.pow(point.x - closePoint.x, 2) + - math.pow(point.y - closePoint.y, 2), - ); - if (distance < minDistance) { - minDistance = distance; - closestOverall = closePoint; - } - } - return closestOverall; - } - - @override - State createState() => _InteractiveViewerState(); -} - -class _InteractiveViewerState extends State - with TickerProviderStateMixin { - late TransformationController _transformer = - widget.transformationController ?? TransformationController(); - - final GlobalKey _childKey = GlobalKey(); - final GlobalKey _parentKey = GlobalKey(); - Animation? _animation; - Animation? _scaleAnimation; - late Offset _scaleAnimationFocalPoint; - late AnimationController _controller; - late AnimationController _scaleController; - Axis? _currentAxis; // Used with panAxis. - Offset? _referenceFocalPoint; // Point where the current gesture began. - double? _scaleStart; // Scale value at start of scaling gesture. - double? _rotationStart = 0.0; // Rotation at start of rotation gesture. - double _currentRotation = 0.0; // Rotation of _transformationController.value. - _GestureType? _gestureType; - - // TODO(justinmc): Add rotateEnabled parameter to the widget and remove this - // hardcoded value when the rotation feature is implemented. - // https://github.com/flutter/flutter/issues/57698 - final bool _rotateEnabled = false; - - // The _boundaryRect is calculated by adding the boundaryMargin to the size of - // the child. - Rect get _boundaryRect { - assert(_childKey.currentContext != null); - assert(!widget.boundaryMargin.left.isNaN); - assert(!widget.boundaryMargin.right.isNaN); - assert(!widget.boundaryMargin.top.isNaN); - assert(!widget.boundaryMargin.bottom.isNaN); - - final RenderBox childRenderBox = - _childKey.currentContext!.findRenderObject()! as RenderBox; - final Size childSize = childRenderBox.size; - final Rect boundaryRect = widget.boundaryMargin.inflateRect( - Offset.zero & childSize, - ); - assert( - !boundaryRect.isEmpty, - "InteractiveViewer's child must have nonzero dimensions.", - ); - // Boundaries that are partially infinite are not allowed because Matrix4's - // rotation and translation methods don't handle infinites well. - assert( - boundaryRect.isFinite || - (boundaryRect.left.isInfinite && - boundaryRect.top.isInfinite && - boundaryRect.right.isInfinite && - boundaryRect.bottom.isInfinite), - 'boundaryRect must either be infinite in all directions or finite in all directions.', - ); - return boundaryRect; - } - - // The Rect representing the child's parent. - Rect get _viewport { - assert(_parentKey.currentContext != null); - final RenderBox parentRenderBox = - _parentKey.currentContext!.findRenderObject()! as RenderBox; - return Offset.zero & parentRenderBox.size; - } - - // Return a new matrix representing the given matrix after applying the given - // translation. - Matrix4 _matrixTranslate(Matrix4 matrix, Offset translation) { - if (translation == Offset.zero) { - return matrix.clone(); - } - - final Offset alignedTranslation; - - if (_currentAxis != null) { - alignedTranslation = switch (widget.panAxis) { - PanAxis.horizontal => _alignAxis(translation, Axis.horizontal), - PanAxis.vertical => _alignAxis(translation, Axis.vertical), - PanAxis.aligned => _alignAxis(translation, _currentAxis!), - PanAxis.free => translation, - }; - } else { - alignedTranslation = translation; - } - - final Matrix4 nextMatrix = matrix.clone() - ..translateByDouble(alignedTranslation.dx, alignedTranslation.dy, 0, 1); - - // Transform the viewport to determine where its four corners will be after - // the child has been transformed. - final Quad nextViewport = _transformViewport(nextMatrix, _viewport); - - // If the boundaries are infinite, then no need to check if the translation - // fits within them. - if (_boundaryRect.isInfinite) { - return nextMatrix; - } - - // Expand the boundaries with rotation. This prevents the problem where a - // mismatch in orientation between the viewport and boundaries effectively - // limits translation. With this approach, all points that are visible with - // no rotation are visible after rotation. - final Quad boundariesAabbQuad = _getAxisAlignedBoundingBoxWithRotation( - _boundaryRect, - _currentRotation, - ); - - // If the given translation fits completely within the boundaries, allow it. - final Offset offendingDistance = _exceedsBy( - boundariesAabbQuad, - nextViewport, - ); - if (offendingDistance == Offset.zero) { - return nextMatrix; - } - - // Desired translation goes out of bounds, so translate to the nearest - // in-bounds point instead. - final Offset nextTotalTranslation = _getMatrixTranslation(nextMatrix); - final double currentScale = matrix.getMaxScaleOnAxis(); - final Offset correctedTotalTranslation = Offset( - nextTotalTranslation.dx - offendingDistance.dx * currentScale, - nextTotalTranslation.dy - offendingDistance.dy * currentScale, - ); - // TODO(justinmc): This needs some work to handle rotation properly. The - // idea is that the boundaries are axis aligned (boundariesAabbQuad), but - // calculating the translation to put the viewport inside that Quad is more - // complicated than this when rotated. - // https://github.com/flutter/flutter/issues/57698 - final Matrix4 correctedMatrix = matrix.clone() - ..setTranslation( - Vector3( - correctedTotalTranslation.dx, - correctedTotalTranslation.dy, - 0.0, - ), - ); - - // Double check that the corrected translation fits. - final Quad correctedViewport = _transformViewport( - correctedMatrix, - _viewport, - ); - final Offset offendingCorrectedDistance = _exceedsBy( - boundariesAabbQuad, - correctedViewport, - ); - if (offendingCorrectedDistance == Offset.zero) { - return correctedMatrix; - } - - // If the corrected translation doesn't fit in either direction, don't allow - // any translation at all. This happens when the viewport is larger than the - // entire boundary. - if (offendingCorrectedDistance.dx != 0.0 && - offendingCorrectedDistance.dy != 0.0) { - return matrix.clone(); - } - - // Otherwise, allow translation in only the direction that fits. This - // happens when the viewport is larger than the boundary in one direction. - final Offset unidirectionalCorrectedTotalTranslation = Offset( - offendingCorrectedDistance.dx == 0.0 ? correctedTotalTranslation.dx : 0.0, - offendingCorrectedDistance.dy == 0.0 ? correctedTotalTranslation.dy : 0.0, - ); - return matrix.clone()..setTranslation( - Vector3( - unidirectionalCorrectedTotalTranslation.dx, - unidirectionalCorrectedTotalTranslation.dy, - 0.0, - ), - ); - } - - // Return a new matrix representing the given matrix after applying the given - // scale. - Matrix4 _matrixScale(Matrix4 matrix, double scale) { - if (scale == 1.0) { - return matrix.clone(); - } - assert(scale != 0.0); - - // Don't allow a scale that results in an overall scale beyond min/max - // scale. - final double currentScale = _transformer.value.getMaxScaleOnAxis(); - final double totalScale = math.max( - currentScale * scale, - // Ensure that the scale cannot make the child so big that it can't fit - // inside the boundaries (in either direction). - math.max( - _viewport.width / _boundaryRect.width, - _viewport.height / _boundaryRect.height, - ), - ); - final double clampedTotalScale = clampDouble( - totalScale, - widget.minScale, - widget.maxScale, - ); - final double clampedScale = clampedTotalScale / currentScale; - return matrix.clone() - ..scaleByDouble(clampedScale, clampedScale, clampedScale, 1); - } - - // Return a new matrix representing the given matrix after applying the given - // rotation. - Matrix4 _matrixRotate(Matrix4 matrix, double rotation, Offset focalPoint) { - if (rotation == 0) { - return matrix.clone(); - } - final Offset focalPointScene = _transformer.toScene(focalPoint); - return matrix.clone() - ..translateByDouble(focalPointScene.dx, focalPointScene.dy, 0, 1) - ..rotateZ(-rotation) - ..translateByDouble(-focalPointScene.dx, -focalPointScene.dy, 0, 1); - } - - // Returns true iff the given _GestureType is enabled. - bool _gestureIsSupported(_GestureType? gestureType) { - return switch (gestureType) { - _GestureType.rotate => _rotateEnabled, - _GestureType.scale => widget.scaleEnabled, - _GestureType.pan || null => widget.panEnabled, - }; - } - - // Decide which type of gesture this is by comparing the amount of scale - // and rotation in the gesture, if any. Scale starts at 1 and rotation - // starts at 0. Pan will have no scale and no rotation because it uses only one - // finger. - _GestureType _getGestureType(ScaleUpdateDetails details) { - final double scale = !widget.scaleEnabled ? 1.0 : details.scale; - final double rotation = !_rotateEnabled ? 0.0 : details.rotation; - if ((scale - 1).abs() > rotation.abs()) { - return _GestureType.scale; - } else if (rotation != 0.0) { - return _GestureType.rotate; - } else { - return _GestureType.pan; - } - } - - // Handle the start of a gesture. All of pan, scale, and rotate are handled - // with GestureDetector's scale gesture. - void _onScaleStart(ScaleStartDetails details) { - if (widget.isAnimating?.call() == true || - (details.pointerCount < 2 && _transformer.value.storage[0] == 1.0)) { - widget.onPanStart?.call(details); - return; - } - - widget.onInteractionStart?.call(details); - - if (_controller.isAnimating) { - _controller - ..stop() - ..reset(); - _animation?.removeListener(_handleInertiaAnimation); - _animation = null; - } - if (_scaleController.isAnimating) { - _scaleController - ..stop() - ..reset(); - _scaleAnimation?.removeListener(_handleScaleAnimation); - _scaleAnimation = null; - } - - _gestureType = null; - _currentAxis = null; - _scaleStart = _transformer.value.getMaxScaleOnAxis(); - _referenceFocalPoint = _transformer.toScene(details.localFocalPoint); - _rotationStart = _currentRotation; - } - - // Handle an update to an ongoing gesture. All of pan, scale, and rotate are - // handled with GestureDetector's scale gesture. - void _onScaleUpdate(ScaleUpdateDetails details) { - if (widget.isAnimating?.call() == true || - (details.pointerCount < 2 && _transformer.value.storage[0] == 1.0)) { - widget.onPanUpdate?.call(details); - return; - } - - final double scale = _transformer.value.getMaxScaleOnAxis(); - _scaleAnimationFocalPoint = details.localFocalPoint; - final Offset focalPointScene = _transformer.toScene( - details.localFocalPoint, - ); - - if (_gestureType == _GestureType.pan) { - // When a gesture first starts, it sometimes has no change in scale and - // rotation despite being a two-finger gesture. Here the gesture is - // allowed to be reinterpreted as its correct type after originally - // being marked as a pan. - _gestureType = _getGestureType(details); - } else { - _gestureType ??= _getGestureType(details); - } - if (!_gestureIsSupported(_gestureType)) { - widget.onInteractionUpdate?.call(details); - return; - } - - switch (_gestureType!) { - case _GestureType.scale: - assert(_scaleStart != null); - // details.scale gives us the amount to change the scale as of the - // start of this gesture, so calculate the amount to scale as of the - // previous call to _onScaleUpdate. - final double desiredScale = _scaleStart! * details.scale; - final double scaleChange = desiredScale / scale; - _transformer.value = _matrixScale(_transformer.value, scaleChange); - - // While scaling, translate such that the user's two fingers stay on - // the same places in the scene. That means that the focal point of - // the scale should be on the same place in the scene before and after - // the scale. - final Offset focalPointSceneScaled = _transformer.toScene( - details.localFocalPoint, - ); - _transformer.value = _matrixTranslate( - _transformer.value, - focalPointSceneScaled - _referenceFocalPoint!, - ); - - // details.localFocalPoint should now be at the same location as the - // original _referenceFocalPoint point. If it's not, that's because - // the translate came in contact with a boundary. In that case, update - // _referenceFocalPoint so subsequent updates happen in relation to - // the new effective focal point. - final Offset focalPointSceneCheck = _transformer.toScene( - details.localFocalPoint, - ); - if (_round(_referenceFocalPoint!) != _round(focalPointSceneCheck)) { - _referenceFocalPoint = focalPointSceneCheck; - } - - case _GestureType.rotate: - if (details.rotation == 0.0) { - widget.onInteractionUpdate?.call(details); - return; - } - final double desiredRotation = _rotationStart! + details.rotation; - _transformer.value = _matrixRotate( - _transformer.value, - _currentRotation - desiredRotation, - details.localFocalPoint, - ); - _currentRotation = desiredRotation; - - case _GestureType.pan: - if (_referenceFocalPoint == null) { - return; - } - // details may have a change in scale here when scaleEnabled is false. - // In an effort to keep the behavior similar whether or not scaleEnabled - // is true, these gestures are thrown away. - if (details.scale != 1.0) { - widget.onInteractionUpdate?.call(details); - return; - } - _currentAxis ??= _getPanAxis(_referenceFocalPoint!, focalPointScene); - // Translate so that the same point in the scene is underneath the - // focal point before and after the movement. - final Offset translationChange = - focalPointScene - _referenceFocalPoint!; - _transformer.value = _matrixTranslate( - _transformer.value, - translationChange, - ); - _referenceFocalPoint = _transformer.toScene(details.localFocalPoint); - } - widget.onInteractionUpdate?.call(details); - } - - // Handle the end of a gesture of _GestureType. All of pan, scale, and rotate - // are handled with GestureDetector's scale gesture. - void _onScaleEnd(ScaleEndDetails details) { - if (widget.isAnimating?.call() == true || - (details.pointerCount < 2 && _transformer.value.storage[0] == 1.0)) { - widget.onPanEnd?.call(details); - return; - } - - widget.onInteractionEnd?.call(details); - _scaleStart = null; - _rotationStart = null; - _referenceFocalPoint = null; - - _animation?.removeListener(_handleInertiaAnimation); - _scaleAnimation?.removeListener(_handleScaleAnimation); - _controller.reset(); - _scaleController.reset(); - - if (!_gestureIsSupported(_gestureType)) { - _currentAxis = null; - return; - } - - switch (_gestureType) { - case _GestureType.pan: - if (details.velocity.pixelsPerSecond.distance < kMinFlingVelocity) { - _currentAxis = null; - return; - } - final Vector3 translationVector = _transformer.value.getTranslation(); - final Offset translation = Offset( - translationVector.x, - translationVector.y, - ); - final FrictionSimulation frictionSimulationX = FrictionSimulation( - widget.interactionEndFrictionCoefficient, - translation.dx, - details.velocity.pixelsPerSecond.dx, - ); - final FrictionSimulation frictionSimulationY = FrictionSimulation( - widget.interactionEndFrictionCoefficient, - translation.dy, - details.velocity.pixelsPerSecond.dy, - ); - final double tFinal = _getFinalTime( - details.velocity.pixelsPerSecond.distance, - widget.interactionEndFrictionCoefficient, - ); - _animation = _controller.drive( - Tween( - begin: translation, - end: Offset( - frictionSimulationX.finalX, - frictionSimulationY.finalX, - ), - ).chain(CurveTween(curve: Curves.decelerate)), - ); - _controller.duration = Duration(milliseconds: (tFinal * 1000).round()); - _animation!.addListener(_handleInertiaAnimation); - _controller.forward(); - case _GestureType.scale: - if (details.scaleVelocity.abs() < 0.1) { - _currentAxis = null; - return; - } - final double scale = _transformer.value.getMaxScaleOnAxis(); - final FrictionSimulation frictionSimulation = FrictionSimulation( - widget.interactionEndFrictionCoefficient * widget.scaleFactor, - scale, - details.scaleVelocity / 10, - ); - final double tFinal = _getFinalTime( - details.scaleVelocity.abs(), - widget.interactionEndFrictionCoefficient, - effectivelyMotionless: 0.1, - ); - _scaleAnimation = _scaleController.drive( - Tween( - begin: scale, - end: frictionSimulation.x(tFinal), - ).chain(CurveTween(curve: Curves.decelerate)), - ); - _scaleController.duration = Duration( - milliseconds: (tFinal * 1000).round(), - ); - _scaleAnimation!.addListener(_handleScaleAnimation); - _scaleController.forward(); - case _GestureType.rotate || null: - break; - } - } - - // Handle mousewheel and web trackpad scroll events. - void _receivedPointerSignal(PointerSignalEvent event) { - final Offset local = event.localPosition; - final Offset global = event.position; - final double scaleChange; - if (event is PointerScrollEvent) { - if (event.kind == PointerDeviceKind.trackpad && - !widget.trackpadScrollCausesScale) { - // Trackpad scroll, so treat it as a pan. - widget.onInteractionStart?.call( - ScaleStartDetails(focalPoint: global, localFocalPoint: local), - ); - - final Offset localDelta = PointerEvent.transformDeltaViaPositions( - untransformedEndPosition: global + event.scrollDelta, - untransformedDelta: event.scrollDelta, - transform: event.transform, - ); - - if (!_gestureIsSupported(_GestureType.pan)) { - widget.onInteractionUpdate?.call( - ScaleUpdateDetails( - focalPoint: global - event.scrollDelta, - localFocalPoint: local - event.scrollDelta, - focalPointDelta: -localDelta, - ), - ); - widget.onInteractionEnd?.call(ScaleEndDetails()); - return; - } - - final Offset focalPointScene = _transformer.toScene(local); - final Offset newFocalPointScene = _transformer.toScene( - local - localDelta, - ); - - _transformer.value = _matrixTranslate( - _transformer.value, - newFocalPointScene - focalPointScene, - ); - - widget.onInteractionUpdate?.call( - ScaleUpdateDetails( - focalPoint: global - event.scrollDelta, - localFocalPoint: local - localDelta, - focalPointDelta: -localDelta, - ), - ); - widget.onInteractionEnd?.call(ScaleEndDetails()); - return; - } - // Ignore left and right mouse wheel scroll. - if (event.scrollDelta.dy == 0.0) { - return; - } - scaleChange = math.exp(-event.scrollDelta.dy / widget.scaleFactor); - } else if (event is PointerScaleEvent) { - scaleChange = event.scale; - } else { - return; - } - widget.onInteractionStart?.call( - ScaleStartDetails(focalPoint: global, localFocalPoint: local), - ); - - if (!_gestureIsSupported(_GestureType.scale)) { - widget.onInteractionUpdate?.call( - ScaleUpdateDetails( - focalPoint: global, - localFocalPoint: local, - scale: scaleChange, - ), - ); - widget.onInteractionEnd?.call(ScaleEndDetails()); - return; - } - - final Offset focalPointScene = _transformer.toScene(local); - _transformer.value = _matrixScale(_transformer.value, scaleChange); - - // After scaling, translate such that the event's position is at the - // same scene point before and after the scale. - final Offset focalPointSceneScaled = _transformer.toScene(local); - _transformer.value = _matrixTranslate( - _transformer.value, - focalPointSceneScaled - focalPointScene, - ); - - widget.onInteractionUpdate?.call( - ScaleUpdateDetails( - focalPoint: global, - localFocalPoint: local, - scale: scaleChange, - ), - ); - widget.onInteractionEnd?.call(ScaleEndDetails()); - } - - void _handleInertiaAnimation() { - if (!_controller.isAnimating) { - _currentAxis = null; - _animation?.removeListener(_handleInertiaAnimation); - _animation = null; - _controller.reset(); - return; - } - // Translate such that the resulting translation is _animation.value. - final Vector3 translationVector = _transformer.value.getTranslation(); - final Offset translation = Offset(translationVector.x, translationVector.y); - _transformer.value = _matrixTranslate( - _transformer.value, - _transformer.toScene(_animation!.value) - - _transformer.toScene(translation), - ); - } - - void _handleScaleAnimation() { - if (!_scaleController.isAnimating) { - _currentAxis = null; - _scaleAnimation?.removeListener(_handleScaleAnimation); - _scaleAnimation = null; - _scaleController.reset(); - return; - } - final double desiredScale = _scaleAnimation!.value; - final double scaleChange = - desiredScale / _transformer.value.getMaxScaleOnAxis(); - final Offset referenceFocalPoint = _transformer.toScene( - _scaleAnimationFocalPoint, - ); - _transformer.value = _matrixScale(_transformer.value, scaleChange); - - // While scaling, translate such that the user's two fingers stay on - // the same places in the scene. That means that the focal point of - // the scale should be on the same place in the scene before and after - // the scale. - final Offset focalPointSceneScaled = _transformer.toScene( - _scaleAnimationFocalPoint, - ); - _transformer.value = _matrixTranslate( - _transformer.value, - focalPointSceneScaled - referenceFocalPoint, - ); - } - - void _handleTransformation() { - // A change to the TransformationController's value is a change to the - // state. - setState(() {}); - } - - @override - void initState() { - super.initState(); - _controller = AnimationController(vsync: this); - _scaleController = AnimationController(vsync: this); - - _transformer.addListener(_handleTransformation); - } - - @override - void didUpdateWidget(InteractiveViewer oldWidget) { - super.didUpdateWidget(oldWidget); - - final TransformationController? newController = - widget.transformationController; - if (newController == oldWidget.transformationController) { - return; - } - _transformer.removeListener(_handleTransformation); - if (oldWidget.transformationController == null) { - _transformer.dispose(); - } - _transformer = newController ?? TransformationController(); - _transformer.addListener(_handleTransformation); - } - - @override - void dispose() { - _controller.dispose(); - _scaleController.dispose(); - _transformer.removeListener(_handleTransformation); - if (widget.transformationController == null) { - _transformer.dispose(); - } - super.dispose(); - } - - @override - Widget build(BuildContext context) { - Widget child; - if (widget.child != null) { - child = _InteractiveViewerBuilt( - childKey: _childKey, - clipBehavior: widget.clipBehavior, - constrained: widget.constrained, - matrix: _transformer.value, - alignment: widget.alignment, - child: widget.child!, - ); - } else { - // When using InteractiveViewer.builder, then constrained is false and the - // viewport is the size of the constraints. - assert(widget.builder != null); - assert(!widget.constrained); - child = LayoutBuilder( - builder: (BuildContext context, BoxConstraints constraints) { - final Matrix4 matrix = _transformer.value; - return _InteractiveViewerBuilt( - childKey: _childKey, - clipBehavior: widget.clipBehavior, - constrained: widget.constrained, - alignment: widget.alignment, - matrix: matrix, - child: widget.builder!( - context, - _transformViewport(matrix, Offset.zero & constraints.biggest), - ), - ); - }, - ); - } - - return Listener( - key: _parentKey, - onPointerSignal: _receivedPointerSignal, - child: GestureDetector( - behavior: HitTestBehavior.opaque, // Necessary when panning off screen. - onScaleEnd: _onScaleEnd, - onScaleStart: _onScaleStart, - onScaleUpdate: _onScaleUpdate, - trackpadScrollCausesScale: widget.trackpadScrollCausesScale, - trackpadScrollToScaleFactor: Offset(0, -1 / widget.scaleFactor), - child: child, - ), - ); - } -} - -// This widget allows us to easily swap in and out the LayoutBuilder in -// InteractiveViewer's depending on if it's using a builder or a child. -class _InteractiveViewerBuilt extends StatelessWidget { - const _InteractiveViewerBuilt({ - required this.child, - required this.childKey, - required this.clipBehavior, - required this.constrained, - required this.matrix, - required this.alignment, - }); - - final Widget child; - final GlobalKey childKey; - final Clip clipBehavior; - final bool constrained; - final Matrix4 matrix; - final Alignment? alignment; - - @override - Widget build(BuildContext context) { - Widget child = Transform( - transform: matrix, - alignment: alignment, - child: KeyedSubtree(key: childKey, child: this.child), - ); - - if (!constrained) { - child = OverflowBox( - alignment: Alignment.topLeft, - minWidth: 0.0, - minHeight: 0.0, - maxWidth: double.infinity, - maxHeight: double.infinity, - child: child, - ); - } - - return ClipRect(clipBehavior: clipBehavior, child: child); - } -} - -// A classification of relevant user gestures. Each contiguous user gesture is -// represented by exactly one _GestureType. -enum _GestureType { pan, scale, rotate } - -// Given a velocity and drag, calculate the time at which motion will come to -// a stop, within the margin of effectivelyMotionless. -double _getFinalTime( - double velocity, - double drag, { - double effectivelyMotionless = 10, -}) { - return math.log(effectivelyMotionless / velocity) / math.log(drag / 100); -} - -// Return the translation from the given Matrix4 as an Offset. -Offset _getMatrixTranslation(Matrix4 matrix) { - final Vector3 nextTranslation = matrix.getTranslation(); - return Offset(nextTranslation.x, nextTranslation.y); -} - -// Transform the four corners of the viewport by the inverse of the given -// matrix. This gives the viewport after the child has been transformed by the -// given matrix. The viewport transforms as the inverse of the child (i.e. -// moving the child left is equivalent to moving the viewport right). -Quad _transformViewport(Matrix4 matrix, Rect viewport) { - final Matrix4 inverseMatrix = matrix.clone()..invert(); - return Quad.points( - inverseMatrix.transform3( - Vector3(viewport.topLeft.dx, viewport.topLeft.dy, 0.0), - ), - inverseMatrix.transform3( - Vector3(viewport.topRight.dx, viewport.topRight.dy, 0.0), - ), - inverseMatrix.transform3( - Vector3(viewport.bottomRight.dx, viewport.bottomRight.dy, 0.0), - ), - inverseMatrix.transform3( - Vector3(viewport.bottomLeft.dx, viewport.bottomLeft.dy, 0.0), - ), - ); -} - -// Find the axis aligned bounding box for the rect rotated about its center by -// the given amount. -Quad _getAxisAlignedBoundingBoxWithRotation(Rect rect, double rotation) { - final Matrix4 rotationMatrix = Matrix4.identity() - ..translateByDouble(rect.size.width / 2, rect.size.height / 2, 0, 1) - ..rotateZ(rotation) - ..translateByDouble(-rect.size.width / 2, -rect.size.height / 2, 0, 1); - final Quad boundariesRotated = Quad.points( - rotationMatrix.transform3(Vector3(rect.left, rect.top, 0.0)), - rotationMatrix.transform3(Vector3(rect.right, rect.top, 0.0)), - rotationMatrix.transform3(Vector3(rect.right, rect.bottom, 0.0)), - rotationMatrix.transform3(Vector3(rect.left, rect.bottom, 0.0)), - ); - return InteractiveViewer.getAxisAlignedBoundingBox(boundariesRotated); -} - -// Return the amount that viewport lies outside of boundary. If the viewport -// is completely contained within the boundary (inclusively), then returns -// Offset.zero. -Offset _exceedsBy(Quad boundary, Quad viewport) { - final List viewportPoints = [ - viewport.point0, - viewport.point1, - viewport.point2, - viewport.point3, - ]; - Offset largestExcess = Offset.zero; - for (final Vector3 point in viewportPoints) { - final Vector3 pointInside = InteractiveViewer.getNearestPointInside( - point, - boundary, - ); - final Offset excess = Offset( - pointInside.x - point.x, - pointInside.y - point.y, - ); - if (excess.dx.abs() > largestExcess.dx.abs()) { - largestExcess = Offset(excess.dx, largestExcess.dy); - } - if (excess.dy.abs() > largestExcess.dy.abs()) { - largestExcess = Offset(largestExcess.dx, excess.dy); - } - } - - return _round(largestExcess); -} - -// Round the output values. This works around a precision problem where -// values that should have been zero were given as within 10^-10 of zero. -Offset _round(Offset offset) { - return Offset( - double.parse(offset.dx.toStringAsFixed(9)), - double.parse(offset.dy.toStringAsFixed(9)), - ); -} - -// Align the given offset to the given axis by allowing movement only in the -// axis direction. -Offset _alignAxis(Offset offset, Axis axis) { - return switch (axis) { - Axis.horizontal => Offset(offset.dx, 0.0), - Axis.vertical => Offset(0.0, offset.dy), - }; -} - -// Given two points, return the axis where the distance between the points is -// greatest. If they are equal, return null. -Axis? _getPanAxis(Offset point1, Offset point2) { - if (point1 == point2) { - return null; - } - final double x = point2.dx - point1.dx; - final double y = point2.dy - point1.dy; - return x.abs() > y.abs() ? Axis.horizontal : Axis.vertical; -} diff --git a/lib/common/widgets/interactiveviewer_gallery/interactive_viewer_boundary.dart b/lib/common/widgets/interactiveviewer_gallery/interactive_viewer_boundary.dart deleted file mode 100644 index f686f4d0c..000000000 --- a/lib/common/widgets/interactiveviewer_gallery/interactive_viewer_boundary.dart +++ /dev/null @@ -1,194 +0,0 @@ -import 'dart:ui' as ui; - -import 'package:PiliPlus/common/widgets/interactiveviewer_gallery/interactive_viewer.dart' - as custom; -import 'package:PiliPlus/common/widgets/only_layout_widget.dart'; -import 'package:flutter/material.dart'; - -/// https://github.com/qq326646683/interactiveviewer_gallery - -/// 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({ - super.key, - required this.child, - required this.boundaryWidth, - required this.controller, - required this.maxScale, - required this.minScale, - this.onDismissed, - this.dismissThreshold = 0.2, - this.onInteractionEnd, - }); - - final double dismissThreshold; - final VoidCallback? onDismissed; - - 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; - - final double maxScale; - - final double minScale; - - final GestureScaleEndCallback? onInteractionEnd; - - @override - InteractiveViewerBoundaryState createState() => - InteractiveViewerBoundaryState(); -} - -class InteractiveViewerBoundaryState extends State - with SingleTickerProviderStateMixin { - late final TransformationController _controller; - late final AnimationController _animateController; - late final Animation _opacityAnimation; - double dx = 0, dy = 0; - - Offset _offset = Offset.zero; - bool _dragging = false; - - late Size _size; - - bool get _isActive => _dragging || _animateController.isAnimating; - - @override - void initState() { - super.initState(); - _controller = widget.controller; - - _animateController = AnimationController( - duration: const Duration(milliseconds: 300), - vsync: this, - ); - - _opacityAnimation = _animateController.drive( - DecorationTween( - begin: const BoxDecoration(color: Colors.black), - end: const BoxDecoration(color: Colors.transparent), - ), - ); - - _animateController.addListener(_updateTransformation); - } - - void _updateTransformation() { - final val = _animateController.value; - final scale = ui.lerpDouble(1.0, 0.25, val)!; - - // Matrix4.identity() - // ..translateByDouble(size.width / 2, size.height / 2, 0, 1) - // ..translateByDouble(size.width * val * dx, size.height * val * dy, 0, 1) - // ..scaleByDouble(scale, scale, 1, 1) - // ..translateByDouble(-size.width / 2, -size.height / 2, 0, 1); - - final tmp = (1.0 - scale) / 2.0; - _controller.value = Matrix4.diagonal3Values(scale, scale, scale) - ..setTranslationRaw( - _size.width * (val * dx + tmp), - _size.height * (val * dy + tmp), - 0, - ); - } - - void _updateMoveAnimation() { - dy = _offset.dy.sign; - if (dy == 0) { - dx = 0; - } else { - dx = _offset.dx / _offset.dy.abs(); - } - } - - void _handleDragStart(ScaleStartDetails details) { - _dragging = true; - - if (_animateController.isAnimating) { - _animateController.stop(); - } else { - _offset = Offset.zero; - _animateController.value = 0.0; - } - _updateMoveAnimation(); - } - - void _handleDragUpdate(ScaleUpdateDetails details) { - if (!_isActive || _animateController.isAnimating) { - return; - } - - _offset += details.focalPointDelta; - _updateMoveAnimation(); - - if (!_animateController.isAnimating) { - _animateController.value = _offset.dy.abs() / _size.height; - } - } - - void _handleDragEnd(ScaleEndDetails details) { - if (!_isActive || _animateController.isAnimating) { - return; - } - - _dragging = 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 - void dispose() { - _animateController - ..removeListener(_updateTransformation) - ..dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - return LayoutSizeWidget( - onPerformLayout: (size) => _size = size, - child: DecoratedBoxTransition( - decoration: _opacityAnimation, - child: custom.InteractiveViewer( - maxScale: widget.maxScale, - minScale: widget.minScale, - transformationController: _controller, - onPanStart: _handleDragStart, - onPanUpdate: _handleDragUpdate, - onPanEnd: _handleDragEnd, - onInteractionEnd: widget.onInteractionEnd, - isAnimating: () => _animateController.value != 0, - child: widget.child, - ), - ), - ); - } -} diff --git a/lib/common/widgets/interactiveviewer_gallery/interactiveviewer_gallery.dart b/lib/common/widgets/interactiveviewer_gallery/interactiveviewer_gallery.dart deleted file mode 100644 index 2e084e52c..000000000 --- a/lib/common/widgets/interactiveviewer_gallery/interactiveviewer_gallery.dart +++ /dev/null @@ -1,478 +0,0 @@ -import 'dart:io'; - -import 'package:PiliPlus/common/widgets/flutter/page/page_view.dart'; -import 'package:PiliPlus/common/widgets/gesture/image_horizontal_drag_gesture_recognizer.dart'; -import 'package:PiliPlus/common/widgets/interactiveviewer_gallery/interactive_viewer_boundary.dart'; -import 'package:PiliPlus/common/widgets/scroll_physics.dart' - show CustomTabBarViewScrollPhysics; -import 'package:PiliPlus/models/common/image_preview_type.dart'; -import 'package:PiliPlus/utils/extension/string_ext.dart'; -import 'package:PiliPlus/utils/image_utils.dart'; -import 'package:PiliPlus/utils/page_utils.dart'; -import 'package:PiliPlus/utils/platform_utils.dart'; -import 'package:PiliPlus/utils/storage_pref.dart'; -import 'package:PiliPlus/utils/utils.dart'; -import 'package:cached_network_image/cached_network_image.dart'; -import 'package:easy_debounce/easy_throttle.dart'; -import 'package:flutter/material.dart' hide PageView; -import 'package:flutter/services.dart' show HapticFeedback; -import 'package:get/get.dart'; -import 'package:media_kit/media_kit.dart'; -import 'package:media_kit_video/media_kit_video.dart'; - -/// https://github.com/qq326646683/interactiveviewer_gallery - -/// 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, - ); - -typedef IndexedTagStringBuilder = String Function(int index); - -class InteractiveviewerGallery extends StatefulWidget { - const InteractiveviewerGallery({ - super.key, - required this.sources, - required this.initIndex, - this.itemBuilder, - this.maxScale = 8, - this.minScale = 1.0, - required this.quality, - this.onClose, - }); - - final int quality; - - /// 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 VoidCallback? onClose; - - @override - State createState() => - _InteractiveviewerGalleryState(); -} - -class _InteractiveviewerGalleryState extends State - with SingleTickerProviderStateMixin { - late final PageController _pageController; - late final TransformationController _transformationController; - - /// The controller to animate the transformation value of the - /// [InteractiveViewer] when it should reset. - late AnimationController _animationController; - - late final _tween = Matrix4Tween(); - late final _animatable = _tween.chain(CurveTween(curve: Curves.easeOut)); - - late final ImageHorizontalDragGestureRecognizer - _horizontalDragGestureRecognizer; - - late Offset _doubleTapLocalPosition; - - late double _width; - - late final RxInt currentIndex = widget.initIndex.obs; - - late final int _quality = Pref.previewQ; - - late final RxBool _hasScaled = false.obs; - - @override - void initState() { - super.initState(); - - _pageController = PageController(initialPage: widget.initIndex); - - _transformationController = TransformationController(); - - _horizontalDragGestureRecognizer = ImageHorizontalDragGestureRecognizer( - width: 0, - transformationController: _transformationController, - ); - - _animationController = AnimationController( - vsync: this, - duration: const Duration(milliseconds: 300), - )..addListener(listener); - - final item = widget.sources[currentIndex.value]; - if (item.sourceType == SourceType.livePhoto) { - _onPlay(item.liveUrl!); - } - } - - void listener() { - _transformationController.value = _animatable.evaluate( - _animationController, - ); - } - - @override - void didChangeDependencies() { - super.didChangeDependencies(); - _width = MediaQuery.widthOf(context); - _horizontalDragGestureRecognizer.width = _width; - } - - @override - void dispose() { - widget.onClose?.call(); - _player?.dispose(); - _pageController.dispose(); - _animationController - ..removeListener(listener) - ..dispose(); - _transformationController.dispose(); - // _horizontalDragGestureRecognizer.dispose(); // duplicate - if (widget.quality != _quality) { - for (final item in widget.sources) { - if (item.sourceType == SourceType.networkImage) { - CachedNetworkImageProvider(_getActualUrl(item.url)).evict(); - } - } - } - super.dispose(); - } - - void _onScaleChanged(double scale) { - _hasScaled.value = scale > 1.0; - } - - void _onPlay(String liveUrl) { - _player ??= Player(); - _videoController ??= VideoController(_player!); - _player!.open(Media(liveUrl)); - } - - /// 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) { - _player?.pause(); - currentIndex.value = page; - final item = widget.sources[page]; - if (item.sourceType == SourceType.livePhoto) { - _onPlay(item.liveUrl!); - } - if (_transformationController.value != Matrix4.identity()) { - // animate the reset for the transformation of the interactive viewer - - _tween - ..begin = _transformationController.value.clone() - ..end = Matrix4.identity(); - _animationController.forward(from: 0); - } - } - - String _getActualUrl(String url) { - return _quality != 100 - ? ImageUtils.thumbnailUrl(url, _quality) - : url.http2https; - } - - Player? _player; - VideoController? _videoController; - - @override - Widget build(BuildContext context) { - return Stack( - clipBehavior: Clip.none, - children: [ - InteractiveViewerBoundary( - controller: _transformationController, - boundaryWidth: _width, - maxScale: widget.maxScale, - minScale: widget.minScale, - onDismissed: Get.back, - onInteractionEnd: (_) => - _onScaleChanged(_transformationController.value.storage[0]), - child: PageView.builder( - onPageChanged: _onPageChanged, - controller: _pageController, - itemCount: widget.sources.length, - physics: const CustomTabBarViewScrollPhysics( - parent: AlwaysScrollableScrollPhysics(), - ), - itemBuilder: (BuildContext context, int index) { - final item = widget.sources[index]; - final isFileImg = item.sourceType == SourceType.fileImage; - return GestureDetector( - behavior: HitTestBehavior.opaque, - onTap: () => EasyThrottle.throttle( - 'preview', - const Duration(milliseconds: 555), - Get.back, - ), - onDoubleTapDown: (TapDownDetails details) { - _doubleTapLocalPosition = details.localPosition; - }, - onDoubleTap: () => EasyThrottle.throttle( - 'preview', - const Duration(milliseconds: 555), - onDoubleTap, - ), - onLongPress: !isFileImg ? () => onLongPress(item) : null, - onSecondaryTapUp: PlatformUtils.isDesktop && !isFileImg - ? (e) => _showDesktopMenu(e.globalPosition, item) - : null, - child: widget.itemBuilder != null - ? widget.itemBuilder!( - context, - index, - index == currentIndex.value, - ) - : _itemBuilder(index, item), - ); - }, - horizontalDragGestureRecognizer: () => - _horizontalDragGestureRecognizer, - ), - ), - Positioned( - bottom: 0, - left: 0, - right: 0, - child: Obx( - () => Container( - padding: - MediaQuery.viewPaddingOf(context) + - const EdgeInsets.fromLTRB(12, 8, 20, 8), - decoration: _hasScaled.value - ? null - : BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: [ - Colors.transparent, - Colors.black.withValues(alpha: 0.3), - ], - ), - ), - alignment: Alignment.center, - child: Obx( - () => Text( - "${currentIndex.value + 1}/${widget.sources.length}", - style: const TextStyle(color: Colors.white), - ), - ), - ), - ), - ), - ], - ); - } - - Widget _itemBuilder(int index, SourceModel item) { - return Center( - child: Hero( - tag: item.url, - child: switch (item.sourceType) { - SourceType.fileImage => Image.file( - File(item.url), - filterQuality: FilterQuality.low, - ), - SourceType.networkImage => CachedNetworkImage( - fadeInDuration: Duration.zero, - fadeOutDuration: Duration.zero, - imageUrl: _getActualUrl(item.url), - placeholderFadeInDuration: Duration.zero, - placeholder: (context, url) { - if (widget.quality == _quality) { - return const SizedBox.expand(); - } - return CachedNetworkImage( - fadeInDuration: Duration.zero, - fadeOutDuration: Duration.zero, - imageUrl: ImageUtils.thumbnailUrl(item.url, widget.quality), - placeholder: (_, _) => const SizedBox.expand(), - ); - }, - ), - SourceType.livePhoto => Obx( - () => currentIndex.value == index - ? IgnorePointer( - child: Video( - controller: _videoController!, - fill: Colors.transparent, - ), - ) - : const SizedBox.shrink(), - ), - }, - ), - ); - } - - void onDoubleTap() { - final matrix = _transformationController.value.clone(); - final currentScale = matrix.storage[0]; - - double targetScale = widget.minScale; - - if (currentScale <= widget.minScale) { - targetScale = widget.maxScale * 0.4; - } - - final double dx, dy; - if (targetScale == 1.0) { - dx = dy = 0; - } else { - final tmp = 1 - targetScale; - dx = _doubleTapLocalPosition.dx * tmp; - dy = _doubleTapLocalPosition.dy * tmp; - } - - matrix - ..[0] = targetScale - ..[5] = targetScale - ..[10] = targetScale - ..[12] = dx - ..[13] = dy; - - _tween - ..begin = _transformationController.value.clone() - ..end = matrix; - - _animationController - .forward(from: 0) - .whenComplete(() => _onScaleChanged(targetScale)); - } - - void onLongPress(SourceModel item) { - HapticFeedback.mediumImpact(); - showDialog( - context: context, - builder: (context) => AlertDialog( - clipBehavior: Clip.hardEdge, - contentPadding: const EdgeInsets.symmetric(vertical: 12), - content: Column( - mainAxisSize: MainAxisSize.min, - children: [ - if (PlatformUtils.isMobile) - ListTile( - onTap: () { - Get.back(); - ImageUtils.onShareImg(item.url); - }, - dense: true, - title: const Text('分享', style: TextStyle(fontSize: 14)), - ), - ListTile( - onTap: () { - Get.back(); - Utils.copyText(item.url); - }, - dense: true, - title: const Text('复制链接', style: TextStyle(fontSize: 14)), - ), - ListTile( - onTap: () { - Get.back(); - ImageUtils.downloadImg([item.url]); - }, - dense: true, - title: const Text('保存图片', style: TextStyle(fontSize: 14)), - ), - if (PlatformUtils.isDesktop) - ListTile( - onTap: () { - Get.back(); - PageUtils.launchURL(item.url); - }, - dense: true, - title: const Text('网页打开', style: TextStyle(fontSize: 14)), - ) - else if (widget.sources.length > 1) - ListTile( - onTap: () { - Get.back(); - ImageUtils.downloadImg( - widget.sources.map((item) => item.url).toList(), - ); - }, - dense: true, - title: const Text('保存全部图片', style: TextStyle(fontSize: 14)), - ), - if (item.sourceType == SourceType.livePhoto) - ListTile( - onTap: () { - Get.back(); - ImageUtils.downloadLivePhoto( - url: item.url, - liveUrl: item.liveUrl!, - width: item.width!, - height: item.height!, - ); - }, - dense: true, - title: Text( - '保存${Platform.isIOS ? ' Live Photo' : '视频'}', - style: const TextStyle(fontSize: 14), - ), - ), - ], - ), - ), - ); - } - - void _showDesktopMenu(Offset offset, SourceModel item) { - showMenu( - context: context, - position: PageUtils.menuPosition(offset), - items: [ - PopupMenuItem( - height: 42, - onTap: () => Utils.copyText(item.url), - child: const Text('复制链接', style: TextStyle(fontSize: 14)), - ), - PopupMenuItem( - height: 42, - onTap: () => ImageUtils.downloadImg([item.url]), - child: const Text('保存图片', style: TextStyle(fontSize: 14)), - ), - PopupMenuItem( - height: 42, - onTap: () => PageUtils.launchURL(item.url), - child: const Text('网页打开', style: TextStyle(fontSize: 14)), - ), - if (item.sourceType == SourceType.livePhoto) - PopupMenuItem( - height: 42, - onTap: () => ImageUtils.downloadLivePhoto( - url: item.url, - liveUrl: item.liveUrl!, - width: item.width!, - height: item.height!, - ), - child: const Text('保存视频', style: TextStyle(fontSize: 14)), - ), - ], - ); - } -} diff --git a/lib/models/common/image_preview_type.dart b/lib/models/common/image_preview_type.dart index 45e62a897..b882c7e6a 100644 --- a/lib/models/common/image_preview_type.dart +++ b/lib/models/common/image_preview_type.dart @@ -1,3 +1,5 @@ +import 'package:PiliPlus/common/constants.dart'; + enum SourceType { fileImage, networkImage, livePhoto } class SourceModel { @@ -14,4 +16,11 @@ class SourceModel { this.width, this.height, }); + + bool get isLongPic { + if (width != null && height != null) { + return height! / width! > StyleString.imgMaxRatio; + } + return false; + } } diff --git a/lib/pages/video/view.dart b/lib/pages/video/view.dart index bfee2d660..610136ea5 100644 --- a/lib/pages/video/view.dart +++ b/lib/pages/video/view.dart @@ -6,7 +6,7 @@ import 'dart:ui'; import 'package:PiliPlus/common/constants.dart'; import 'package:PiliPlus/common/widgets/custom_icon.dart'; import 'package:PiliPlus/common/widgets/image/network_img_layer.dart'; -import 'package:PiliPlus/common/widgets/interactiveviewer_gallery/hero_dialog_route.dart'; +import 'package:PiliPlus/common/widgets/image_viewer/hero_dialog_route.dart'; import 'package:PiliPlus/common/widgets/keep_alive_wrapper.dart'; import 'package:PiliPlus/common/widgets/scroll_physics.dart'; import 'package:PiliPlus/http/loading_state.dart'; diff --git a/lib/utils/page_utils.dart b/lib/utils/page_utils.dart index 3a5dbb1fd..bd404e869 100644 --- a/lib/utils/page_utils.dart +++ b/lib/utils/page_utils.dart @@ -1,7 +1,7 @@ import 'dart:math'; -import 'package:PiliPlus/common/widgets/interactiveviewer_gallery/hero_dialog_route.dart'; -import 'package:PiliPlus/common/widgets/interactiveviewer_gallery/interactiveviewer_gallery.dart'; +import 'package:PiliPlus/common/widgets/image_viewer/gallery_viewer.dart'; +import 'package:PiliPlus/common/widgets/image_viewer/hero_dialog_route.dart'; import 'package:PiliPlus/grpc/im.dart'; import 'package:PiliPlus/http/dynamics.dart'; import 'package:PiliPlus/http/loading_state.dart'; @@ -51,12 +51,11 @@ abstract final class PageUtils { }) { return Get.key.currentState!.push( HeroDialogRoute( - pageBuilder: (context, animation, secondaryAnimation) => - InteractiveviewerGallery( - sources: imgList, - initIndex: initialPage, - quality: quality ?? GlobalData().imgQuality, - ), + pageBuilder: (context, animation, secondaryAnimation) => GalleryViewer( + sources: imgList, + initIndex: initialPage, + quality: quality ?? GlobalData().imgQuality, + ), ), ); } @@ -392,7 +391,7 @@ abstract final class PageUtils { ) { state.showBottomSheet( constraints: const BoxConstraints(), - (context) => InteractiveviewerGallery( + (context) => GalleryViewer( sources: imgList, initIndex: index, quality: GlobalData().imgQuality,