zoomable_image.dart 9.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310
  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:photo_view/photo_view_gallery.dart";
  9. import 'package:photos/core/cache/thumbnail_in_memory_cache.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/actions/file/file_actions.dart";
  20. import 'package:photos/ui/common/loading_widget.dart';
  21. import 'package:photos/utils/file_util.dart';
  22. import 'package:photos/utils/image_util.dart';
  23. import 'package:photos/utils/thumbnail_util.dart';
  24. class ZoomableImage extends StatefulWidget {
  25. final EnteFile photo;
  26. final String? tagPrefix;
  27. final Decoration? backgroundDecoration;
  28. final bool shouldCover;
  29. const ZoomableImage(
  30. this.photo, {
  31. Key? key,
  32. required this.tagPrefix,
  33. this.backgroundDecoration,
  34. this.shouldCover = false,
  35. }) : super(key: key);
  36. @override
  37. State<ZoomableImage> createState() => _ZoomableImageState();
  38. }
  39. class _ZoomableImageState extends State<ZoomableImage>
  40. with SingleTickerProviderStateMixin {
  41. late Logger _logger;
  42. late EnteFile _photo;
  43. ImageProvider? _imageProvider;
  44. bool _loadedSmallThumbnail = false;
  45. bool _loadingLargeThumbnail = false;
  46. bool _loadedLargeThumbnail = false;
  47. bool _loadingFinalImage = false;
  48. bool _loadedFinalImage = false;
  49. PhotoViewController _photoViewController = PhotoViewController();
  50. bool _isZooming = false;
  51. ValueChanged<PhotoViewScaleState>? _scaleStateChangedCallback;
  52. @override
  53. void initState() {
  54. _photo = widget.photo;
  55. _logger = Logger("ZoomableImage");
  56. _logger.info('initState for ${_photo.generatedID} with tag ${_photo.tag}');
  57. _scaleStateChangedCallback = (value) {
  58. _isZooming = value != PhotoViewScaleState.initial;
  59. debugPrint("isZooming = $_isZooming, currentState $value");
  60. };
  61. super.initState();
  62. }
  63. @override
  64. void dispose() {
  65. _photoViewController.dispose();
  66. super.dispose();
  67. }
  68. @override
  69. Widget build(BuildContext context) {
  70. if (_photo.isRemoteFile) {
  71. _loadNetworkImage();
  72. } else {
  73. _loadLocalImage(context);
  74. }
  75. Widget content;
  76. if (_imageProvider != null) {
  77. content = PhotoViewGallery.builder(
  78. gaplessPlayback: true,
  79. scaleStateChangedCallback: _scaleStateChangedCallback,
  80. backgroundDecoration: widget.backgroundDecoration as BoxDecoration?,
  81. builder: (context, index) {
  82. return PhotoViewGalleryPageOptions(
  83. imageProvider: _imageProvider!,
  84. minScale: widget.shouldCover
  85. ? PhotoViewComputedScale.covered
  86. : PhotoViewComputedScale.contained,
  87. heroAttributes: PhotoViewHeroAttributes(
  88. tag: widget.tagPrefix! + _photo.tag,
  89. ),
  90. controller: _photoViewController,
  91. );
  92. },
  93. itemCount: 1,
  94. );
  95. } else {
  96. content = const EnteLoadingWidget();
  97. }
  98. verticalDragCallback(d) => {
  99. if (!_isZooming)
  100. {
  101. if (d.delta.dy > dragSensitivity)
  102. {
  103. {Navigator.of(context).pop()},
  104. }
  105. else if (d.delta.dy < (dragSensitivity * -1))
  106. {
  107. showDetailsSheet(context, widget.photo),
  108. },
  109. },
  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. if (file != null) {
  145. _onFinalImageLoaded(
  146. Image.file(
  147. file,
  148. gaplessPlayback: true,
  149. ).image,
  150. );
  151. } else {
  152. _loadingFinalImage = false;
  153. }
  154. });
  155. }
  156. }
  157. void _loadLocalImage(BuildContext context) {
  158. if (!_loadedSmallThumbnail &&
  159. !_loadedLargeThumbnail &&
  160. !_loadedFinalImage) {
  161. final cachedThumbnail =
  162. ThumbnailInMemoryLruCache.get(_photo, thumbnailSmallSize);
  163. if (cachedThumbnail != null) {
  164. _imageProvider = Image.memory(cachedThumbnail).image;
  165. _loadedSmallThumbnail = true;
  166. }
  167. }
  168. if (!_loadingLargeThumbnail &&
  169. !_loadedLargeThumbnail &&
  170. !_loadedFinalImage) {
  171. _loadingLargeThumbnail = true;
  172. getThumbnailFromLocal(_photo, size: thumbnailLargeSize, quality: 100)
  173. .then((cachedThumbnail) {
  174. if (cachedThumbnail != null) {
  175. _onLargeThumbnailLoaded(Image.memory(cachedThumbnail).image, context);
  176. }
  177. });
  178. }
  179. if (!_loadingFinalImage && !_loadedFinalImage) {
  180. _loadingFinalImage = true;
  181. getFile(
  182. _photo,
  183. isOrigin: Platform.isIOS &&
  184. _isGIF(), // since on iOS GIFs playback only when origin-files are loaded
  185. ).then((file) {
  186. if (file != null && file.existsSync()) {
  187. _onFinalImageLoaded(Image.file(file).image);
  188. } else {
  189. _logger.info("File was deleted " + _photo.toString());
  190. if (_photo.uploadedFileID != null) {
  191. _photo.localID = null;
  192. FilesDB.instance.update(_photo);
  193. _loadNetworkImage();
  194. } else {
  195. FilesDB.instance.deleteLocalFile(_photo);
  196. Bus.instance.fire(
  197. LocalPhotosUpdatedEvent(
  198. [_photo],
  199. type: EventType.deletedFromDevice,
  200. source: "zoomPreview",
  201. ),
  202. );
  203. }
  204. }
  205. });
  206. }
  207. }
  208. void _onLargeThumbnailLoaded(
  209. ImageProvider imageProvider,
  210. BuildContext context,
  211. ) {
  212. if (mounted && !_loadedFinalImage) {
  213. precacheImage(imageProvider, context).then((value) {
  214. if (mounted && !_loadedFinalImage) {
  215. setState(() {
  216. _imageProvider = imageProvider;
  217. _loadedLargeThumbnail = true;
  218. });
  219. }
  220. });
  221. }
  222. }
  223. void _onFinalImageLoaded(ImageProvider imageProvider) {
  224. if (mounted) {
  225. precacheImage(imageProvider, context).then((value) async {
  226. if (mounted) {
  227. await _updatePhotoViewController(
  228. previewImageProvider: _imageProvider,
  229. finalImageProvider: imageProvider,
  230. );
  231. setState(() {
  232. _imageProvider = imageProvider;
  233. _loadedFinalImage = true;
  234. _logger.info("Final image loaded");
  235. });
  236. }
  237. });
  238. }
  239. }
  240. Future<void> _updatePhotoViewController({
  241. required ImageProvider? previewImageProvider,
  242. required ImageProvider finalImageProvider,
  243. }) async {
  244. final bool shouldFixPosition = previewImageProvider != null && _isZooming;
  245. ImageInfo? finalImageInfo;
  246. if (shouldFixPosition) {
  247. final prevImageInfo = await getImageInfo(previewImageProvider);
  248. finalImageInfo = await getImageInfo(finalImageProvider);
  249. final scale = _photoViewController.scale! /
  250. (finalImageInfo.image.width / prevImageInfo.image.width);
  251. final currentPosition = _photoViewController.value.position;
  252. final positionScaleFactor = 1 / scale;
  253. final newPosition = currentPosition.scale(
  254. positionScaleFactor,
  255. positionScaleFactor,
  256. );
  257. _photoViewController = PhotoViewController(
  258. initialPosition: newPosition,
  259. initialScale: scale,
  260. );
  261. }
  262. final bool canUpdateMetadata = _photo.canEditMetaInfo;
  263. // forcefully get finalImageInfo is dimensions are not available in metadata
  264. if (finalImageInfo == null && canUpdateMetadata && !_photo.hasDimensions) {
  265. finalImageInfo = await getImageInfo(finalImageProvider);
  266. }
  267. if (finalImageInfo != null && canUpdateMetadata) {
  268. _updateAspectRatioIfNeeded(_photo, finalImageInfo).ignore();
  269. }
  270. }
  271. // Fallback logic to finish back fill and update aspect
  272. // ratio if needed.
  273. Future<void> _updateAspectRatioIfNeeded(
  274. EnteFile enteFile,
  275. ImageInfo imageInfo,
  276. ) async {
  277. final int h = imageInfo.image.height, w = imageInfo.image.width;
  278. if (h != enteFile.height || w != enteFile.width) {
  279. final logMessage =
  280. 'Updating aspect ratio for from ${enteFile.height}x${enteFile.width} to ${h}x$w';
  281. _logger.info(logMessage);
  282. await FileMagicService.instance.updatePublicMagicMetadata([
  283. enteFile,
  284. ], {
  285. heightKey: h,
  286. widthKey: w,
  287. });
  288. }
  289. }
  290. bool _isGIF() => _photo.displayName.toLowerCase().endsWith(".gif");
  291. }