zoomable_image.dart 10.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313
  1. import 'dart:async';
  2. import 'dart:io';
  3. import "package:flutter/foundation.dart";
  4. import 'package:flutter/material.dart';
  5. import 'package:flutter/widgets.dart';
  6. import 'package:logging/logging.dart';
  7. import 'package:photo_view/photo_view.dart';
  8. import 'package:photos/core/cache/thumbnail_in_memory_cache.dart';
  9. import "package:photos/core/configuration.dart";
  10. import 'package:photos/core/constants.dart';
  11. import 'package:photos/core/event_bus.dart';
  12. import 'package:photos/db/files_db.dart';
  13. import 'package:photos/events/files_updated_event.dart';
  14. import 'package:photos/events/local_photos_updated_event.dart';
  15. import "package:photos/models/file/extensions/file_props.dart";
  16. import 'package:photos/models/file/file.dart';
  17. import "package:photos/models/metadata/file_magic.dart";
  18. import "package:photos/services/file_magic_service.dart";
  19. import 'package:photos/ui/common/loading_widget.dart';
  20. import 'package:photos/utils/file_util.dart';
  21. import 'package:photos/utils/image_util.dart';
  22. import 'package:photos/utils/thumbnail_util.dart';
  23. import "package:photos/utils/toast_util.dart";
  24. class ZoomableImage extends StatefulWidget {
  25. final EnteFile photo;
  26. final Function(bool)? shouldDisableScroll;
  27. final String? tagPrefix;
  28. final Decoration? backgroundDecoration;
  29. final bool shouldCover;
  30. const ZoomableImage(
  31. this.photo, {
  32. Key? key,
  33. this.shouldDisableScroll,
  34. required this.tagPrefix,
  35. this.backgroundDecoration,
  36. this.shouldCover = false,
  37. }) : super(key: key);
  38. @override
  39. State<ZoomableImage> createState() => _ZoomableImageState();
  40. }
  41. class _ZoomableImageState extends State<ZoomableImage>
  42. with SingleTickerProviderStateMixin {
  43. late Logger _logger;
  44. late EnteFile _photo;
  45. ImageProvider? _imageProvider;
  46. bool _loadedSmallThumbnail = false;
  47. bool _loadingLargeThumbnail = false;
  48. bool _loadedLargeThumbnail = false;
  49. bool _loadingFinalImage = false;
  50. bool _loadedFinalImage = false;
  51. ValueChanged<PhotoViewScaleState>? _scaleStateChangedCallback;
  52. bool _isZooming = false;
  53. PhotoViewController _photoViewController = PhotoViewController();
  54. int? _thumbnailWidth;
  55. late int _currentUserID;
  56. @override
  57. void initState() {
  58. _photo = widget.photo;
  59. _logger = Logger("ZoomableImage_" + _photo.tag);
  60. debugPrint('initState for ${_photo.toString()}');
  61. _scaleStateChangedCallback = (value) {
  62. if (widget.shouldDisableScroll != null) {
  63. widget.shouldDisableScroll!(value != PhotoViewScaleState.initial);
  64. }
  65. _isZooming = value != PhotoViewScaleState.initial;
  66. debugPrint("isZooming = $_isZooming, currentState $value");
  67. // _logger.info('is reakky zooming $_isZooming with state $value');
  68. };
  69. _currentUserID = Configuration.instance.getUserID()!;
  70. super.initState();
  71. }
  72. @override
  73. void dispose() {
  74. _photoViewController.dispose();
  75. super.dispose();
  76. }
  77. @override
  78. Widget build(BuildContext context) {
  79. if (_photo.isRemoteFile) {
  80. _loadNetworkImage();
  81. } else {
  82. _loadLocalImage(context);
  83. }
  84. Widget content;
  85. if (_imageProvider != null) {
  86. content = PhotoViewGestureDetectorScope(
  87. axis: Axis.vertical,
  88. child: PhotoView(
  89. imageProvider: _imageProvider,
  90. controller: _photoViewController,
  91. scaleStateChangedCallback: _scaleStateChangedCallback,
  92. minScale: widget.shouldCover
  93. ? PhotoViewComputedScale.covered
  94. : PhotoViewComputedScale.contained,
  95. gaplessPlayback: true,
  96. heroAttributes: PhotoViewHeroAttributes(
  97. tag: widget.tagPrefix! + _photo.tag,
  98. ),
  99. backgroundDecoration: widget.backgroundDecoration as BoxDecoration?,
  100. ),
  101. );
  102. } else {
  103. content = const EnteLoadingWidget();
  104. }
  105. final GestureDragUpdateCallback? verticalDragCallback = _isZooming
  106. ? null
  107. : (d) => {
  108. if (!_isZooming && d.delta.dy > dragSensitivity)
  109. {Navigator.of(context).pop()},
  110. };
  111. return GestureDetector(
  112. onVerticalDragUpdate: verticalDragCallback,
  113. child: content,
  114. );
  115. }
  116. void _loadNetworkImage() {
  117. if (!_loadedSmallThumbnail && !_loadedFinalImage) {
  118. final cachedThumbnail = ThumbnailInMemoryLruCache.get(_photo);
  119. if (cachedThumbnail != null) {
  120. _imageProvider = Image.memory(cachedThumbnail).image;
  121. _loadedSmallThumbnail = true;
  122. } else {
  123. getThumbnailFromServer(_photo).then((file) {
  124. final imageProvider = Image.memory(file).image;
  125. if (mounted) {
  126. precacheImage(imageProvider, context).then((value) {
  127. if (mounted) {
  128. setState(() {
  129. _imageProvider = imageProvider;
  130. _loadedSmallThumbnail = true;
  131. });
  132. }
  133. }).catchError((e) {
  134. _logger.severe("Could not load image " + _photo.toString());
  135. _loadedSmallThumbnail = true;
  136. });
  137. }
  138. });
  139. }
  140. }
  141. if (!_loadedFinalImage && !_loadingFinalImage) {
  142. _loadingFinalImage = true;
  143. getFileFromServer(_photo).then((file) {
  144. _onFinalImageLoaded(
  145. Image.file(
  146. file!,
  147. gaplessPlayback: true,
  148. ).image,
  149. );
  150. });
  151. }
  152. }
  153. void _loadLocalImage(BuildContext context) {
  154. if (!_loadedSmallThumbnail &&
  155. !_loadedLargeThumbnail &&
  156. !_loadedFinalImage) {
  157. final cachedThumbnail =
  158. ThumbnailInMemoryLruCache.get(_photo, thumbnailSmallSize);
  159. if (cachedThumbnail != null) {
  160. _imageProvider = Image.memory(cachedThumbnail).image;
  161. _loadedSmallThumbnail = true;
  162. }
  163. }
  164. if (!_loadingLargeThumbnail &&
  165. !_loadedLargeThumbnail &&
  166. !_loadedFinalImage) {
  167. _loadingLargeThumbnail = true;
  168. getThumbnailFromLocal(_photo, size: thumbnailLargeSize, quality: 100)
  169. .then((cachedThumbnail) {
  170. if (cachedThumbnail != null) {
  171. _onLargeThumbnailLoaded(Image.memory(cachedThumbnail).image, context);
  172. }
  173. });
  174. }
  175. if (!_loadingFinalImage && !_loadedFinalImage) {
  176. _loadingFinalImage = true;
  177. getFile(
  178. _photo,
  179. isOrigin: Platform.isIOS &&
  180. _isGIF(), // since on iOS GIFs playback only when origin-files are loaded
  181. ).then((file) {
  182. if (file != null && file.existsSync()) {
  183. _onFinalImageLoaded(Image.file(file).image);
  184. } else {
  185. _logger.info("File was deleted " + _photo.toString());
  186. if (_photo.uploadedFileID != null) {
  187. _photo.localID = null;
  188. FilesDB.instance.update(_photo);
  189. _loadNetworkImage();
  190. } else {
  191. FilesDB.instance.deleteLocalFile(_photo);
  192. Bus.instance.fire(
  193. LocalPhotosUpdatedEvent(
  194. [_photo],
  195. type: EventType.deletedFromDevice,
  196. source: "zoomPreview",
  197. ),
  198. );
  199. }
  200. }
  201. });
  202. }
  203. }
  204. void _onLargeThumbnailLoaded(
  205. ImageProvider imageProvider,
  206. BuildContext context,
  207. ) {
  208. if (mounted && !_loadedFinalImage) {
  209. precacheImage(imageProvider, context).then((value) {
  210. if (mounted && !_loadedFinalImage) {
  211. setState(() {
  212. _imageProvider = imageProvider;
  213. _loadedLargeThumbnail = true;
  214. });
  215. }
  216. });
  217. }
  218. }
  219. void _onFinalImageLoaded(ImageProvider imageProvider) {
  220. if (mounted) {
  221. precacheImage(imageProvider, context).then((value) async {
  222. if (mounted) {
  223. await _updatePhotoViewController(
  224. previewImageProvider: _imageProvider,
  225. finalImageProvider: imageProvider,
  226. );
  227. setState(() {
  228. _imageProvider = imageProvider;
  229. _loadedFinalImage = true;
  230. _logger.info("Final image loaded");
  231. });
  232. }
  233. });
  234. }
  235. }
  236. Future<void> _updatePhotoViewController({
  237. required ImageProvider? previewImageProvider,
  238. required ImageProvider finalImageProvider,
  239. }) async {
  240. final bool shouldFixPosition = previewImageProvider != null &&
  241. _isZooming &&
  242. _photoViewController.scale != null;
  243. ImageInfo? finalImageInfo;
  244. if(shouldFixPosition) {
  245. if (kDebugMode) {
  246. showToast(context,
  247. 'Updating photo scale zooming: $_isZooming and scale: ${_photoViewController.scale}');
  248. }
  249. final prevImageInfo = await getImageInfo(previewImageProvider);
  250. finalImageInfo = await getImageInfo(finalImageProvider);
  251. final scale = _photoViewController.scale! /
  252. (finalImageInfo.image.width / prevImageInfo.image.width);
  253. final currentPosition = _photoViewController.value.position;
  254. final positionScaleFactor = 1 / scale;
  255. final newPosition = currentPosition.scale(
  256. positionScaleFactor,
  257. positionScaleFactor,
  258. );
  259. _photoViewController = PhotoViewController(
  260. initialPosition: newPosition,
  261. initialScale: scale,
  262. );
  263. }
  264. final bool canUpdateMetadata = _photo.canEditMetaInfo;
  265. // forcefully get finalImageInfo is dimensions are not available in metadata
  266. if (finalImageInfo == null && canUpdateMetadata && !_photo.hasDimensions) {
  267. finalImageInfo = await getImageInfo(finalImageProvider);
  268. }
  269. if (finalImageInfo != null && canUpdateMetadata) {
  270. _updateAspectRatioIfNeeded(_photo, finalImageInfo).ignore();
  271. }
  272. }
  273. // Fallback logic to finish back fill and update aspect
  274. // ratio if needed.
  275. Future<void> _updateAspectRatioIfNeeded(
  276. EnteFile enteFile,
  277. ImageInfo imageInfo,
  278. ) async {
  279. final int h = imageInfo.image.height, w = imageInfo.image.width;
  280. if (h != enteFile.height || w != enteFile.width) {
  281. if (kDebugMode) {
  282. showToast(context, 'Updating aspect ratio');
  283. }
  284. _logger.info('Updating aspect ratio for $enteFile to $h:$w');
  285. await FileMagicService.instance.updatePublicMagicMetadata([
  286. enteFile,
  287. ], {
  288. heightKey: h,
  289. widthKey: w,
  290. });
  291. }
  292. }
  293. bool _isGIF() => _photo.displayName.toLowerCase().endsWith(".gif");
  294. }