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