diff --git a/mobile/ios/Podfile.lock b/mobile/ios/Podfile.lock index c3340cff58..cd23df9b83 100644 --- a/mobile/ios/Podfile.lock +++ b/mobile/ios/Podfile.lock @@ -75,6 +75,8 @@ PODS: - Flutter - flutter_timezone (0.0.1): - Flutter + - flutter_timezone (0.0.1): + - Flutter - fluttertoast (0.0.2): - Flutter - GoogleDataTransport (10.1.0): @@ -260,6 +262,7 @@ DEPENDENCIES: - flutter_secure_storage (from `.symlinks/plugins/flutter_secure_storage/ios`) - flutter_sodium (from `.symlinks/plugins/flutter_sodium/ios`) - flutter_timezone (from `.symlinks/plugins/flutter_timezone/ios`) + - flutter_timezone (from `.symlinks/plugins/flutter_timezone/ios`) - fluttertoast (from `.symlinks/plugins/fluttertoast/ios`) - home_widget (from `.symlinks/plugins/home_widget/ios`) - image_editor_common (from `.symlinks/plugins/image_editor_common/ios`) @@ -300,7 +303,7 @@ DEPENDENCIES: - workmanager (from `.symlinks/plugins/workmanager/ios`) SPEC REPOS: - https://github.com/ente-io/ffmpeg-kit-custom-repo-ios: + https://github.com/ente-io/ffmpeg-kit-custom-repo-ios.git: - ffmpeg_kit_custom trunk: - Firebase @@ -361,6 +364,8 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/flutter_sodium/ios" flutter_timezone: :path: ".symlinks/plugins/flutter_timezone/ios" + flutter_timezone: + :path: ".symlinks/plugins/flutter_timezone/ios" fluttertoast: :path: ".symlinks/plugins/fluttertoast/ios" home_widget: @@ -445,12 +450,23 @@ SPEC CHECKSUMS: cupertino_http: 94ac07f5ff090b8effa6c5e2c47871d48ab7c86c dart_ui_isolate: 46f6714abe6891313267153ef6f9748d8ecfcab1 device_info_plus: 335f3ce08d2e174b9fdc3db3db0f4e3b1f66bd89 + app_links: 76b66b60cc809390ca1ad69bfd66b998d2387ac7 + background_fetch: 94b36ee293e82972852dba8ede1fbcd3bd3d9d57 + battery_info: 83f3aae7be2fccefab1d2bf06b8aa96f11c8bcdd + connectivity_plus: cb623214f4e1f6ef8fe7403d580fdad517d2f7dd + cupertino_http: 94ac07f5ff090b8effa6c5e2c47871d48ab7c86c + dart_ui_isolate: 46f6714abe6891313267153ef6f9748d8ecfcab1 + device_info_plus: 335f3ce08d2e174b9fdc3db3db0f4e3b1f66bd89 ffmpeg_kit_custom: 682b4f2f1ff1f8abae5a92f6c3540f2441d5be99 ffmpeg_kit_flutter: 915b345acc97d4142e8a9a8549d177ff10f043f5 file_saver: 6cdbcddd690cb02b0c1a0c225b37cd805c2bf8b6 + ffmpeg_kit_flutter: 915b345acc97d4142e8a9a8549d177ff10f043f5 + file_saver: 6cdbcddd690cb02b0c1a0c225b37cd805c2bf8b6 Firebase: d80354ed7f6df5f9aca55e9eb47cc4b634735eaf firebase_core: 6cbed78b4f298ed103a9fd034e6dbc846320480f firebase_messaging: 5e0adf2eb18b0ee59aa0c109314c091a0497ecac + firebase_core: 6cbed78b4f298ed103a9fd034e6dbc846320480f + firebase_messaging: 5e0adf2eb18b0ee59aa0c109314c091a0497ecac FirebaseCore: 99fe0c4b44a39f37d99e6404e02009d2db5d718d FirebaseCoreInternal: df24ce5af28864660ecbd13596fc8dd3a8c34629 FirebaseInstallations: 6c963bd2a86aca0481eef4f48f5a4df783ae5917 @@ -459,7 +475,7 @@ SPEC CHECKSUMS: flutter_email_sender: aa1e9772696691d02cd91fea829856c11efb8e58 flutter_image_compress_common: 1697a328fd72bfb335507c6bca1a65fa5ad87df1 flutter_inappwebview_ios: b89ba3482b96fb25e00c967aae065701b66e9b99 - flutter_local_notifications: a5a732f069baa862e728d839dd2ebb904737effb + flutter_local_notifications: ad39620c743ea4c15127860f4b5641649a988100 flutter_native_splash: 6cad9122ea0fad137d23137dd14b937f3e90b145 flutter_secure_storage: 2c2ff13db9e0a5647389bff88b0ecac56e3f3418 flutter_sodium: 7e4621538491834eba53bd524547854bcbbd6987 @@ -472,7 +488,13 @@ SPEC CHECKSUMS: in_app_purchase_storekit: d1a48cb0f8b29dbf5f85f782f5dd79b21b90a5e6 integration_test: 4a889634ef21a45d28d50d622cf412dc6d9f586e launcher_icon_switcher: 84c218d233505aa7d8655d8fa61a3ba802c022da + home_widget: f169fc41fd807b4d46ab6615dc44d62adbf9f64f + image_editor_common: 3de87e7c4804f4ae24c8f8a998362b98c105cac1 + in_app_purchase_storekit: d1a48cb0f8b29dbf5f85f782f5dd79b21b90a5e6 + integration_test: 4a889634ef21a45d28d50d622cf412dc6d9f586e + launcher_icon_switcher: 84c218d233505aa7d8655d8fa61a3ba802c022da libwebp: 02b23773aedb6ff1fd38cec7a77b81414c6842a8 + local_auth_darwin: 553ce4f9b16d3fdfeafce9cf042e7c9f77c1c391 local_auth_ios: f7a1841beef3151d140a967c2e46f30637cdf451 Mantle: c5aa8794a29a022dfbbfc9799af95f477a69b62d maps_launcher: edf829809ba9e894d70e569bab11c16352dedb45 @@ -482,19 +504,35 @@ SPEC CHECKSUMS: motion_sensors: 741e702c17467b9569a92165dda8d4d88c6167f1 motionphoto: 23e2aeb5c6380112f69468d71f970fa7438e5ed1 move_to_background: 7e3467dd2a1d1013e98c9c1cb93fd53cd7ef9d84 + maps_launcher: edf829809ba9e894d70e569bab11c16352dedb45 + media_extension: 671e2567880d96c95c65c9a82ccceed8f2e309fd + media_kit_libs_ios_video: 5a18affdb97d1f5d466dc79988b13eff6c5e2854 + media_kit_video: 1746e198cb697d1ffb734b1d05ec429d1fcd1474 + motion_sensors: 741e702c17467b9569a92165dda8d4d88c6167f1 + motionphoto: 23e2aeb5c6380112f69468d71f970fa7438e5ed1 + move_to_background: 7e3467dd2a1d1013e98c9c1cb93fd53cd7ef9d84 nanopb: fad817b59e0457d11a5dfbde799381cd727c1275 native_video_player: 6809dec117e8997161dbfb42a6f90d6df71a504d objective_c: 89e720c30d716b036faf9c9684022048eee1eee2 onnxruntime: f9b296392c96c42882be020a59dbeac6310d81b2 + native_video_player: e363dd14f6a498ad8a8f7e6486a0db046ad19f13 + objective_c: 89e720c30d716b036faf9c9684022048eee1eee2 + onnxruntime: f9b296392c96c42882be020a59dbeac6310d81b2 onnxruntime-c: a909204639a1f035f575127ac406f781ac797c9c onnxruntime-objc: b6fab0f1787aa6f7190c2013f03037df4718bd8b open_mail_app: 7314a609e88eed22d53671279e189af7a0ab0f11 + open_mail_app: 7314a609e88eed22d53671279e189af7a0ab0f11 OrderedSet: e539b66b644ff081c73a262d24ad552a69be3a94 package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499 path_provider_foundation: 080d55be775b7414fd5a5ef3ac137b97b097e564 permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d photo_manager: d2fbcc0f2d82458700ee6256a15018210a81d413 privacy_screen: 3159a541f5d3a31bea916cfd4e58f9dc722b3fd4 + package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499 + path_provider_foundation: 080d55be775b7414fd5a5ef3ac137b97b097e564 + permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d + photo_manager: d2fbcc0f2d82458700ee6256a15018210a81d413 + privacy_screen: 3159a541f5d3a31bea916cfd4e58f9dc722b3fd4 PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47 receive_sharing_intent: 222384f00ffe7e952bbfabaa9e3967cb87e5fe00 SDWebImage: f29024626962457f3470184232766516dee8dfea @@ -507,7 +545,6 @@ SPEC CHECKSUMS: sqlite3: 3c950dc86011117c307eb0b28c4a7bb449dce9f1 sqlite3_flutter_libs: 3c323550ef3b928bc0aa9513c841e45a7d242832 system_info_plus: 555ce7047fbbf29154726db942ae785c29211740 - thermal: d4c48be750d1ddbab36b0e2dcb2471531bc8df41 ua_client_hints: 92fe0d139619b73ec9fcb46cc7e079a26178f586 url_launcher_ios: 694010445543906933d732453a59da0a173ae33d video_player_avfoundation: 2cef49524dd1f16c5300b9cd6efd9611ce03639b diff --git a/mobile/lib/events/reset_zoom_of_photo_view_event.dart b/mobile/lib/events/reset_zoom_of_photo_view_event.dart new file mode 100644 index 0000000000..fd25ad6917 --- /dev/null +++ b/mobile/lib/events/reset_zoom_of_photo_view_event.dart @@ -0,0 +1,18 @@ +import "package:photos/events/event.dart"; + +class ResetZoomOfPhotoView extends Event { + final int? uploadedFileID; + final String? localID; + + ResetZoomOfPhotoView({ + required this.localID, + required this.uploadedFileID, + }); + + bool isSamePhoto({required int? uploadedFileID, required String? localID}) { + if (this.uploadedFileID == uploadedFileID && this.localID == localID) { + return true; + } + return false; + } +} diff --git a/mobile/lib/services/memories_cache_service.dart b/mobile/lib/services/memories_cache_service.dart index a7cb0eea26..46fd63058f 100644 --- a/mobile/lib/services/memories_cache_service.dart +++ b/mobile/lib/services/memories_cache_service.dart @@ -1,6 +1,7 @@ import "dart:async"; import "dart:io" show File; +import "package:flutter/cupertino.dart"; import "package:flutter/foundation.dart" show kDebugMode; import "package:flutter/material.dart" show BuildContext; import "package:logging/logging.dart"; @@ -24,6 +25,8 @@ import "package:photos/services/language_service.dart"; import "package:photos/services/machine_learning/face_ml/person/person_service.dart"; import "package:photos/services/notification_service.dart"; import "package:photos/services/search_service.dart"; +import "package:photos/theme/colors.dart"; +import "package:photos/ui/home/memories/all_memories_page.dart"; import "package:photos/ui/home/memories/full_screen_memory.dart"; import "package:photos/ui/viewer/people/people_page.dart"; import "package:photos/utils/cache_util.dart"; @@ -485,10 +488,12 @@ class MemoriesCacheService { } await routeToPage( context, - FullScreenMemoryDataUpdater( - initialIndex: fileIdx, - memories: allMemories[memoryIdx].memories, - child: FullScreenMemory(allMemories[memoryIdx].title, fileIdx), + AllMemoriesPage( + allMemories: _cachedMemories!.map((e) => e.memories).toList(), + allTitles: _cachedMemories!.map((e) => e.title).toList(), + initialPageIndex: memoryIdx, + inititalFileIndex: fileIdx, + isFromWidgetOrNotifications: true, ), forceCustomPageRoute: true, ); @@ -515,10 +520,12 @@ class MemoriesCacheService { } await routeToPage( context, - FullScreenMemoryDataUpdater( - initialIndex: 0, - memories: allMemories[memoryIdx].memories, - child: FullScreenMemory(allMemories[memoryIdx].title, 0), + AllMemoriesPage( + allMemories: allMemories.map((e) => e.memories).toList(), + allTitles: allMemories.map((e) => e.title).toList(), + initialPageIndex: memoryIdx, + inititalFileIndex: 0, + isFromWidgetOrNotifications: true, ), forceCustomPageRoute: true, ); @@ -582,7 +589,12 @@ class MemoriesCacheService { FullScreenMemoryDataUpdater( initialIndex: 0, memories: personMemory!.memories, - child: FullScreenMemory(personMemory.title, 0), + child: Container( + color: backgroundBaseDark, + width: double.infinity, + height: double.infinity, + child: FullScreenMemory(personMemory.title, 0), + ), ), forceCustomPageRoute: true, ); diff --git a/mobile/lib/ui/home/memories/all_memories_page.dart b/mobile/lib/ui/home/memories/all_memories_page.dart new file mode 100644 index 0000000000..955fe7a833 --- /dev/null +++ b/mobile/lib/ui/home/memories/all_memories_page.dart @@ -0,0 +1,106 @@ +import 'package:flutter/material.dart'; +import "package:photos/models/memories/memory.dart"; +import "package:photos/theme/colors.dart"; +import "package:photos/ui/home/memories/full_screen_memory.dart"; + +// TODO: Use a single instance variable for `allMemories` and `allTitles` +class AllMemoriesPage extends StatefulWidget { + final int initialPageIndex; + final int inititalFileIndex; + final List> allMemories; + final List allTitles; + final bool isFromWidgetOrNotifications; + + const AllMemoriesPage({ + super.key, + required this.allMemories, + required this.allTitles, + required this.initialPageIndex, + this.inititalFileIndex = 0, + this.isFromWidgetOrNotifications = false, + }); + + @override + State createState() => _AllMemoriesPageState(); +} + +class _AllMemoriesPageState extends State + with SingleTickerProviderStateMixin { + late PageController pageController; + bool isFirstLoad = true; + + @override + void initState() { + super.initState(); + pageController = PageController(initialPage: widget.initialPageIndex); + } + + @override + void dispose() { + pageController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + height: double.infinity, + color: backgroundBaseDark, + child: PageView.builder( + controller: pageController, + physics: const BouncingScrollPhysics(), + hitTestBehavior: HitTestBehavior.translucent, + itemCount: widget.allMemories.length, + itemBuilder: (context, index) { + final initialMemoryIndex = + widget.isFromWidgetOrNotifications && isFirstLoad + ? widget.inititalFileIndex + : _getNextMemoryIndex(index); + isFirstLoad = false; + return FullScreenMemoryDataUpdater( + initialIndex: initialMemoryIndex, + memories: widget.allMemories[index], + child: FullScreenMemory( + widget.allTitles[index], + initialMemoryIndex, + onNextMemory: index < widget.allMemories.length - 1 + ? () => pageController.nextPage( + duration: const Duration(milliseconds: 675), + curve: Curves.easeOutQuart, + ) + : null, + onPreviousMemory: index > 0 + ? () => pageController.previousPage( + duration: const Duration(milliseconds: 675), + curve: Curves.easeOutQuart, + ) + : null, + ), + ); + }, + ), + ); + } + + int _getNextMemoryIndex(int currentIndex) { + int lastSeenIndex = 0; + int lastSeenTimestamp = 0; + final allMemoriesLength = widget.allMemories[currentIndex].length; + for (var index = 0; index < allMemoriesLength; index++) { + final memory = widget.allMemories[currentIndex][index]; + if (!memory.isSeen()) { + return index; + } else { + if (memory.seenTime() > lastSeenTimestamp) { + lastSeenIndex = index; + lastSeenTimestamp = memory.seenTime(); + } + } + } + if (lastSeenIndex == widget.allMemories[currentIndex].length - 1) { + return 0; + } + return lastSeenIndex + 1; + } +} diff --git a/mobile/lib/ui/home/memories/custom_listener.dart b/mobile/lib/ui/home/memories/custom_listener.dart new file mode 100644 index 0000000000..981f9919cc --- /dev/null +++ b/mobile/lib/ui/home/memories/custom_listener.dart @@ -0,0 +1,150 @@ +import 'dart:async'; +import 'package:flutter/widgets.dart'; + +class ActivePointers with ChangeNotifier { + final Set _activePointers = {}; + bool get hasActivePointers => _activePointers.isNotEmpty; + bool activePointerWasPartOfMultitouch = false; + + void add(int pointer) { + if (_activePointers.isNotEmpty && !_activePointers.contains(pointer)) { + activePointerWasPartOfMultitouch = true; + } + _activePointers.add(pointer); + notifyListeners(); + } + + void remove(int pointer) { + _activePointers.remove(pointer); + if (_activePointers.isEmpty) { + activePointerWasPartOfMultitouch = false; + } + notifyListeners(); + } +} + +/// `onLongPress` and `onLongPressUp` have not been tested enough to make sure +/// it works as expected, so they are commented out for now. +class MemoriesPointerGestureListener extends StatefulWidget { + final Widget child; + final Function(PointerEvent)? onTap; + // final VoidCallback? onLongPress; + // final VoidCallback? onLongPressUp; + + /// How long the pointer must stay down before a long‐press fires. + final Duration longPressDuration; + + /// Maximum movement (in logical pixels) before we consider it a drag. + final double touchSlop; + + /// Notifier that indicates whether there are active pointers. + final ValueNotifier? hasPointerNotifier; + static const double kTouchSlop = 18.0; // Default touch slop value + + const MemoriesPointerGestureListener({ + super.key, + required this.child, + this.onTap, + // this.onLongPress, + // this.onLongPressUp, + this.hasPointerNotifier, + this.longPressDuration = const Duration(milliseconds: 500), + this.touchSlop = kTouchSlop, // from flutter/gestures/constants.dart + }); + + @override + MemoriesPointerGestureListenerState createState() => + MemoriesPointerGestureListenerState(); +} + +class MemoriesPointerGestureListenerState + extends State { + Timer? _longPressTimer; + bool _longPressFired = false; + Offset? _downPosition; + bool hasPointerMoved = false; + final _activePointers = ActivePointers(); + + @override + void initState() { + super.initState(); + _activePointers.addListener(_activatePointerListener); + } + + void _activatePointerListener() { + if (widget.hasPointerNotifier != null) { + widget.hasPointerNotifier!.value = _activePointers.hasActivePointers; + } + } + + void _handlePointerDown(PointerDownEvent event) { + _addPointer(event.pointer); + _downPosition = event.localPosition; + _longPressFired = false; + _longPressTimer?.cancel(); + _longPressTimer = Timer(widget.longPressDuration, () { + _longPressFired = true; + // widget.onLongPress?.call(); + }); + } + + void _handlePointerMove(PointerMoveEvent event) { + if (_longPressTimer != null && _downPosition != null) { + final distance = (event.localPosition - _downPosition!).distance; + if (distance > widget.touchSlop) { + // user started dragging – cancel long‐press + hasPointerMoved = true; + _longPressTimer!.cancel(); + _longPressTimer = null; + } + } + } + + void _handlePointerUp(PointerUpEvent event) { + _longPressTimer?.cancel(); + _longPressTimer = null; + + if (_longPressFired) { + // widget.onLongPressUp?.call(); + } else { + if (!_activePointers.activePointerWasPartOfMultitouch && + !hasPointerMoved) { + widget.onTap?.call(event); + } + } + _removePointer(event.pointer); + _reset(); + } + + void _handlePointerCancel(PointerCancelEvent event) { + _longPressTimer?.cancel(); + _longPressTimer = null; + _longPressFired = false; + _removePointer(event.pointer); + _reset(); + } + + void _removePointer(int pointer) { + _activePointers.remove(pointer); + } + + void _addPointer(int pointer) { + _activePointers.add(pointer); + } + + @override + Widget build(BuildContext context) { + return Listener( + onPointerDown: _handlePointerDown, + onPointerMove: _handlePointerMove, + onPointerUp: _handlePointerUp, + onPointerCancel: _handlePointerCancel, + behavior: HitTestBehavior.opaque, + child: widget.child, + ); + } + + void _reset() { + hasPointerMoved = false; + } +} diff --git a/mobile/lib/ui/home/memories/full_screen_memory.dart b/mobile/lib/ui/home/memories/full_screen_memory.dart index 7a09c80f2a..b382be1369 100644 --- a/mobile/lib/ui/home/memories/full_screen_memory.dart +++ b/mobile/lib/ui/home/memories/full_screen_memory.dart @@ -1,20 +1,27 @@ import "dart:async"; import "dart:io"; +import "dart:math"; +import "dart:ui"; import "package:flutter/cupertino.dart"; import "package:flutter/material.dart"; import "package:photos/core/configuration.dart"; +import "package:photos/core/event_bus.dart"; +import "package:photos/events/reset_zoom_of_photo_view_event.dart"; +import "package:photos/models/file/file_type.dart"; import "package:photos/models/memories/memory.dart"; import "package:photos/service_locator.dart"; import "package:photos/services/smart_memories_service.dart"; -import "package:photos/theme/ente_theme.dart"; +import "package:photos/theme/colors.dart"; import "package:photos/theme/text_style.dart"; import "package:photos/ui/actions/file/file_actions.dart"; +import "package:photos/ui/home/memories/custom_listener.dart"; +import "package:photos/ui/home/memories/memory_progress_indicator.dart"; import "package:photos/ui/viewer/file/file_widget.dart"; +import "package:photos/ui/viewer/file/thumbnail_widget.dart"; import "package:photos/ui/viewer/file_details/favorite_widget.dart"; import "package:photos/utils/file_util.dart"; import "package:photos/utils/share_util.dart"; -import "package:step_progress_indicator/step_progress_indicator.dart"; //There are two states of variables that FullScreenMemory depends on: //1. The list of memories @@ -32,6 +39,8 @@ import "package:step_progress_indicator/step_progress_indicator.dart"; //ValueNotifier inside the InheritedWidget and the widgets that need to change //are wrapped in a ValueListenableBuilder. +//TODO: Use better naming convention. "Memory" should be a whole memory and +//parts of the memory should be called "items". class FullScreenMemoryDataUpdater extends StatefulWidget { final List memories; final int initialIndex; @@ -118,9 +127,14 @@ class FullScreenMemoryData extends InheritedWidget { class FullScreenMemory extends StatefulWidget { final String title; final int initialIndex; + final VoidCallback? onNextMemory; + final VoidCallback? onPreviousMemory; + const FullScreenMemory( this.title, this.initialIndex, { + this.onNextMemory, + this.onPreviousMemory, super.key, }); @@ -129,198 +143,336 @@ class FullScreenMemory extends StatefulWidget { } class _FullScreenMemoryState extends State { - PageController? _pageController; final _showTitle = ValueNotifier(true); + AnimationController? _progressAnimationController; + AnimationController? _zoomAnimationController; + final ValueNotifier durationNotifier = + ValueNotifier(const Duration(seconds: 5)); + + /// Used to check if any pointer is on the screen. + final hasPointerOnScreenNotifier = ValueNotifier(false); + bool hasFinalFileLoaded = false; @override void initState() { super.initState(); Future.delayed(const Duration(seconds: 3), () { - if (mounted) { - setState(() { - _showTitle.value = false; - }); - } + if (mounted) _showTitle.value = false; }); + hasPointerOnScreenNotifier.addListener( + _hasPointerListener, + ); } @override void dispose() { - _pageController?.dispose(); _showTitle.dispose(); + durationNotifier.dispose(); + hasPointerOnScreenNotifier.removeListener(_hasPointerListener); + super.dispose(); } + /// Used to check if user has touched the screen and then to pause animation + /// and once the pointer is removed from the screen, it resumes the animation + /// It also resets the zoom of the photo view to default for better user + /// experience after finger(s) is removed from the screen after zooming in by + /// pinching. + void _hasPointerListener() { + if (hasPointerOnScreenNotifier.value) { + _toggleAnimation(pause: true); + } else { + _toggleAnimation(pause: false); + final inheritedData = FullScreenMemoryData.of(context)!; + final currentFile = + inheritedData.memories[inheritedData.indexNotifier.value].file; + Bus.instance.fire( + ResetZoomOfPhotoView( + localID: currentFile.localID, + uploadedFileID: currentFile.uploadedFileID, + ), + ); + } + } + + void _toggleAnimation({required bool pause}) { + if (pause) { + _progressAnimationController?.stop(); + _zoomAnimationController?.stop(); + } else { + if (hasFinalFileLoaded) { + _progressAnimationController?.forward(); + _zoomAnimationController?.forward(); + } + } + } + + void _resetAnimation() { + _progressAnimationController + ?..stop() + ..reset(); + _zoomAnimationController + ?..stop() + ..reset(); + } + + void onFinalFileLoad(int duration) { + hasFinalFileLoaded = true; + if (_progressAnimationController?.isAnimating == true) { + _progressAnimationController!.stop(); + } + durationNotifier.value = Duration(seconds: duration); + _progressAnimationController + ?..stop() + ..reset() + ..duration = durationNotifier.value + ..forward(); + _zoomAnimationController + ?..stop() + ..reset() + ..forward(); + } + + void _goToNext(FullScreenMemoryData inheritedData) { + hasFinalFileLoaded = false; + final currentIndex = inheritedData.indexNotifier.value; + if (currentIndex < inheritedData.memories.length - 1) { + inheritedData.indexNotifier.value += 1; + _onPageChange(inheritedData, currentIndex + 1); + } else if (widget.onNextMemory != null) { + widget.onNextMemory!(); + } + } + + void _goToPrevious(FullScreenMemoryData inheritedData) { + hasFinalFileLoaded = false; + final currentIndex = inheritedData.indexNotifier.value; + if (currentIndex > 0) { + inheritedData.indexNotifier.value -= 1; + _onPageChange(inheritedData, currentIndex - 1); + } else if (widget.onPreviousMemory != null) { + widget.onPreviousMemory!(); + } + } + + void _onPageChange(FullScreenMemoryData inheritedData, int index) { + unawaited( + memoriesCacheService.markMemoryAsSeen( + inheritedData.memories[index], + false, + ), + ); + inheritedData.indexNotifier.value = index; + _resetAnimation(); + } + @override Widget build(BuildContext context) { final inheritedData = FullScreenMemoryData.of(context)!; final showStepProgressIndicator = inheritedData.memories.length < 60; - return Scaffold( - backgroundColor: Colors.black, - extendBodyBehindAppBar: true, - appBar: AppBar( - toolbarHeight: 84, - automaticallyImplyLeading: false, - title: ValueListenableBuilder( - valueListenable: inheritedData.indexNotifier, - child: InkWell( - onTap: () { - Navigator.pop(context); - }, - child: const Padding( - padding: EdgeInsets.fromLTRB(4, 8, 8, 8), - child: Icon( - Icons.close, - color: Colors.white, //same for both themes - ), - ), - ), - builder: (context, value, child) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - showStepProgressIndicator - ? StepProgressIndicator( - totalSteps: inheritedData.memories.length, - currentStep: value + 1, - size: 2, - selectedColor: Colors.white, //same for both themes - unselectedColor: Colors.white.withOpacity(0.4), - ) - : const SizedBox.shrink(), - const SizedBox( - height: 10, - ), - Row( - children: [ - child!, - Text( - SmartMemoriesService.getDateFormatted( - creationTime: - inheritedData.memories[value].file.creationTime!, - context: context, - ), - style: Theme.of(context).textTheme.titleMedium!.copyWith( - fontSize: 14, - color: Colors.white, - ), //same for both themes - ), - ], - ), - ], - ); - }, - ), - flexibleSpace: Container( - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: [ - Colors.black.withOpacity(0.6), - Colors.black.withOpacity(0.5), - Colors.transparent, - ], - stops: const [0, 0.6, 1], - ), - ), - ), - backgroundColor: const Color(0x00000000), - elevation: 0, + + return Padding( + padding: const EdgeInsets.symmetric( + horizontal: 4.0, ), - body: Stack( - alignment: Alignment.bottomCenter, - children: [ - PageView.builder( - controller: _pageController ??= PageController( - initialPage: widget.initialIndex, + child: SafeArea( + child: Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: strokeFainterDark, + width: 1, ), - itemBuilder: (context, index) { - if (index < inheritedData.memories.length - 1) { - final nextFile = inheritedData.memories[index + 1].file; - preloadThumbnail(nextFile); - preloadFile(nextFile); - } - return GestureDetector( - onTapDown: (TapDownDetails details) { - final screenWidth = MediaQuery.of(context).size.width; - final edgeWidth = screenWidth * 0.20; - if (details.localPosition.dx < edgeWidth) { - if (index > 0) { - _pageController!.previousPage( - duration: const Duration(milliseconds: 250), - curve: Curves.ease, - ); - } - } else if (details.localPosition.dx > - screenWidth - edgeWidth) { - if (index < (inheritedData.memories.length - 1)) { - _pageController!.nextPage( - duration: const Duration(milliseconds: 250), - curve: Curves.ease, - ); - } - } - }, - child: FileWidget( - inheritedData.memories[index].file, - autoPlay: false, - tagPrefix: "memories", - backgroundDecoration: const BoxDecoration( - color: Colors.transparent, + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(16), + child: Scaffold( + backgroundColor: Colors.black, + extendBodyBehindAppBar: true, + appBar: AppBar( + toolbarHeight: 64, + primary: false, + automaticallyImplyLeading: false, + title: ValueListenableBuilder( + valueListenable: inheritedData.indexNotifier, + child: GestureDetector( + onTap: () => Navigator.pop(context), + child: const Padding( + padding: EdgeInsets.fromLTRB(4, 8, 8, 8), + child: Icon(Icons.close, color: Colors.white), + ), + ), + builder: (context, value, child) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 32), + showStepProgressIndicator + ? ValueListenableBuilder( + valueListenable: durationNotifier, + builder: (context, duration, _) { + return MemoryProgressIndicator( + totalSteps: inheritedData.memories.length, + currentIndex: value, + selectedColor: Colors.white, + unselectedColor: + Colors.white.withOpacity(0.4), + duration: duration, + animationController: (controller) { + _progressAnimationController = controller; + }, + onComplete: () { + _goToNext(inheritedData); + }, + ); + }, + ) + : const SizedBox.shrink(), + const SizedBox(height: 6), + Row( + children: [ + child!, + Text( + SmartMemoriesService.getDateFormatted( + creationTime: inheritedData + .memories[value].file.creationTime!, + context: context, + ), + style: Theme.of(context) + .textTheme + .titleMedium! + .copyWith( + fontSize: 14, + color: Colors.white, + ), + ), + ], + ), + ], + ); + }, + ), + flexibleSpace: Container( + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + Color.fromARGB(75, 0, 0, 0), + Color.fromARGB(37, 0, 0, 0), + Colors.transparent, + Colors.transparent, + ], + stops: [0, 0.45, 0.8, 1], + ), ), ), - ); - }, - onPageChanged: (index) { - unawaited( - memoriesCacheService.markMemoryAsSeen( - inheritedData.memories[index], - inheritedData.memories.length == index + 1, - ), - ); - inheritedData.indexNotifier.value = index; - }, - itemCount: inheritedData.memories.length, - ), - SafeArea( - top: false, - child: Padding( - padding: const EdgeInsets.only(bottom: 72), - child: ValueListenableBuilder( - builder: (context, value, _) { - return AnimatedSwitcher( - duration: const Duration(milliseconds: 250), - switchInCurve: Curves.easeOut, - switchOutCurve: Curves.easeIn, - child: value - ? Padding( - padding: const EdgeInsets.fromLTRB(16, 4, 16, 12), - child: Hero( - tag: widget.title, - child: Text( - widget.title, - style: getEnteTextTheme(context) - .largeBold - .copyWith( - color: - Colors.white, //same for both themes + backgroundColor: Colors.transparent, + elevation: 0, + ), + body: Stack( + alignment: Alignment.bottomCenter, + children: [ + const _MemoryBlur(), + ValueListenableBuilder( + valueListenable: inheritedData.indexNotifier, + builder: (context, index, _) { + if (index < inheritedData.memories.length - 1) { + final nextFile = inheritedData.memories[index + 1].file; + preloadThumbnail(nextFile); + preloadFile(nextFile); + } + final currentMemory = inheritedData.memories[index]; + final isVideo = + currentMemory.file.fileType == FileType.video; + final currentFile = currentMemory.file; + + return MemoriesPointerGestureListener( + onTap: (PointerEvent event) { + final screenWidth = MediaQuery.sizeOf(context).width; + final goToPreviousTapAreaWidth = screenWidth * 0.20; + if (event.localPosition.dx < + goToPreviousTapAreaWidth) { + _goToPrevious(inheritedData); + } else { + _goToNext(inheritedData); + } + }, + hasPointerNotifier: hasPointerOnScreenNotifier, + child: MemoriesZoomWidget( + key: ValueKey( + currentFile.uploadedFileID ?? currentFile.localID, + ), + scaleController: (controller) { + _zoomAnimationController = controller; + }, + zoomIn: index % 2 == 0, + isVideo: isVideo, + child: FileWidget( + currentFile, + autoPlay: false, + tagPrefix: "memories", + backgroundDecoration: + const BoxDecoration(color: Colors.transparent), + isFromMemories: true, + playbackCallback: (isPlaying) { + _toggleAnimation(pause: !isPlaying); + }, + onFinalFileLoad: ({required int memoryDuration}) { + onFinalFileLoad(memoryDuration); + }, + ), + ), + ); + }, + ), + BottomGradient(showTitle: _showTitle), + Column( + mainAxisAlignment: MainAxisAlignment.end, + mainAxisSize: MainAxisSize.min, + children: [ + ValueListenableBuilder( + valueListenable: _showTitle, + builder: (context, value, _) { + return AnimatedSwitcher( + duration: const Duration(milliseconds: 250), + switchInCurve: Curves.easeOut, + switchOutCurve: Curves.easeIn, + child: value + ? Padding( + padding: const EdgeInsets.fromLTRB( + 32, + 4, + 32, + 12, ), - ), - ), - ) - : showStepProgressIndicator - ? const SizedBox.shrink() - : const MemoryCounter(), - ); - }, - valueListenable: _showTitle, + child: Hero( + tag: widget.title, + child: Text( + widget.title, + style: const TextStyle( + color: Colors.white, + fontSize: 30, + fontFamily: "Montserrat", + ), + textAlign: TextAlign.left, + ), + ), + ) + : showStepProgressIndicator + ? const SizedBox.shrink() + : const MemoryCounter(), + ); + }, + ), + const BottomIcons(), + ], + ), + ], ), ), ), - const BottomGradient(), - const BottomIcons(), - ], + ), ), ); } @@ -332,6 +484,8 @@ class BottomIcons extends StatelessWidget { @override Widget build(BuildContext context) { final inheritedData = FullScreenMemoryData.of(context)!; + final fullScreenState = + context.findAncestorStateOfType<_FullScreenMemoryState>(); return ValueListenableBuilder( valueListenable: inheritedData.indexNotifier, @@ -343,8 +497,10 @@ class BottomIcons extends StatelessWidget { Platform.isAndroid ? Icons.info_outline : CupertinoIcons.info, color: Colors.white, //same for both themes ), - onPressed: () { - showDetailsSheet(context, currentFile); + onPressed: () async { + fullScreenState?._toggleAnimation(pause: true); + await showDetailsSheet(context, currentFile); + fullScreenState?._toggleAnimation(pause: false); }, ), ]; @@ -360,6 +516,7 @@ class BottomIcons extends StatelessWidget { color: Colors.white, //same for both themes ), onPressed: () async { + fullScreenState?._toggleAnimation(pause: true); await showSingleFileDeleteSheet( context, inheritedData @@ -372,6 +529,7 @@ class BottomIcons extends StatelessWidget { }, }, ); + fullScreenState?._toggleAnimation(pause: false); }, ), SizedBox( @@ -386,21 +544,20 @@ class BottomIcons extends StatelessWidget { Icons.adaptive.share, color: Colors.white, //same for both themes ), - onPressed: () { - share(context, [currentFile]); + onPressed: () async { + fullScreenState?._toggleAnimation(pause: true); + await share(context, [currentFile]); + fullScreenState?._toggleAnimation(pause: false); }, ), ); - return SafeArea( - top: false, - child: Container( - alignment: Alignment.bottomCenter, - padding: const EdgeInsets.fromLTRB(12, 0, 12, 20), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceAround, - children: rowChildren, - ), + return Container( + alignment: Alignment.bottomCenter, + padding: const EdgeInsets.fromLTRB(12, 0, 12, 16), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: rowChildren, ), ); }, @@ -427,26 +584,174 @@ class MemoryCounter extends StatelessWidget { } class BottomGradient extends StatelessWidget { - const BottomGradient({super.key}); + final ValueNotifier showTitle; + const BottomGradient({super.key, required this.showTitle}); @override Widget build(BuildContext context) { return IgnorePointer( - child: Container( - height: 124, - width: double.infinity, - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.bottomCenter, - end: Alignment.topCenter, - colors: [ - Colors.black.withOpacity(0.5), //same for both themes - Colors.transparent, - ], - stops: const [0, 0.8], - ), - ), + child: ValueListenableBuilder( + valueListenable: showTitle, + builder: (context, value, _) { + return AnimatedContainer( + duration: const Duration(milliseconds: 875), + curve: Curves.easeOutQuart, + height: value ? 240 : 120, + width: double.infinity, + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.bottomCenter, + end: Alignment.topCenter, + colors: [ + Color.fromARGB(97, 0, 0, 0), + Color.fromARGB(42, 0, 0, 0), + Colors.transparent, + ], + stops: [0, 0.5, 1.0], + ), + ), + ); + }, ), ); } } + +class _MemoryBlur extends StatelessWidget { + const _MemoryBlur(); + + @override + Widget build(BuildContext context) { + final inheritedData = FullScreenMemoryData.of(context)!; + return ValueListenableBuilder( + valueListenable: inheritedData.indexNotifier, + builder: (context, value, _) { + final currentFile = inheritedData.memories[value].file; + if (currentFile.fileType == FileType.video) { + return const SizedBox.shrink(); + } + return AnimatedSwitcher( + duration: const Duration(milliseconds: 750), + switchInCurve: Curves.easeOutExpo, + switchOutCurve: Curves.easeInExpo, + child: ImageFiltered( + key: ValueKey(inheritedData.indexNotifier.value), + imageFilter: ImageFilter.blur( + sigmaX: 100, + sigmaY: 100, + ), + child: ThumbnailWidget( + currentFile, + shouldShowSyncStatus: false, + shouldShowFavoriteIcon: false, + shouldShowVideoOverlayIcon: false, + ), + ), + ); + }, + ); + } +} + +class MemoriesZoomWidget extends StatefulWidget { + final Widget child; + final bool isVideo; + final void Function(AnimationController)? scaleController; + final bool zoomIn; + + const MemoriesZoomWidget({ + super.key, + required this.child, + required this.isVideo, + required this.zoomIn, + this.scaleController, + }); + + @override + State createState() => _MemoriesZoomWidgetState(); +} + +class _MemoriesZoomWidgetState extends State + with TickerProviderStateMixin { + late AnimationController _controller; + late Animation _scaleAnimation; + late Animation _panAnimation; + Random random = Random(); + + @override + void initState() { + super.initState(); + _initAnimation(); + } + + void _initAnimation() { + _controller = AnimationController( + vsync: this, + duration: const Duration( + seconds: 5, + ), + ); + + final startScale = widget.zoomIn ? 1.05 : 1.15; + final endScale = widget.zoomIn ? 1.15 : 1.05; + + final startX = (random.nextDouble() - 0.5) * 0.1; + final startY = (random.nextDouble() - 0.5) * 0.1; + final endX = (random.nextDouble() - 0.5) * 0.1; + final endY = (random.nextDouble() - 0.5) * 0.1; + + _scaleAnimation = Tween( + begin: startScale, + end: endScale, + ).animate( + CurvedAnimation( + parent: _controller, + curve: Curves.easeInOut, + ), + ); + + _panAnimation = Tween( + begin: Offset(startX, startY), + end: Offset(endX, endY), + ).animate( + CurvedAnimation( + parent: _controller, + curve: Curves.easeInOut, + ), + ); + + if (widget.scaleController != null) { + widget.scaleController!(_controller); + } + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return widget.isVideo + ? widget.child + : ClipRect( + child: AnimatedBuilder( + animation: _controller, + child: widget.child, + builder: (context, child) { + return Transform.scale( + scale: _scaleAnimation.value, + child: Transform.translate( + offset: Offset( + _panAnimation.value.dx * 100, + _panAnimation.value.dy * 100, + ), + child: child, + ), + ); + }, + ), + ); + } +} diff --git a/mobile/lib/ui/home/memories/memories_widget.dart b/mobile/lib/ui/home/memories/memories_widget.dart index 03625648b7..52e9dd8a36 100644 --- a/mobile/lib/ui/home/memories/memories_widget.dart +++ b/mobile/lib/ui/home/memories/memories_widget.dart @@ -20,12 +20,11 @@ class MemoriesWidget extends StatefulWidget { } class _MemoriesWidgetState extends State { - late ScrollController _controller; late StreamSubscription _memoriesSettingSubscription; late StreamSubscription _memoriesChangedSubscription; late StreamSubscription _memorySeenSubscription; - late double _maxHeight; - late double _maxWidth; + late double _memoryheight; + late double _memoryWidth; @override void initState() { @@ -48,7 +47,6 @@ class _MemoriesWidgetState extends State { setState(() {}); } }); - _controller = ScrollController(); } @override @@ -57,8 +55,8 @@ class _MemoriesWidgetState extends State { final screenWidth = MediaQuery.sizeOf(context).width; //factor will be 2 for most phones in portrait mode final factor = (screenWidth / 220).ceil(); - _maxWidth = screenWidth / (factor * 2); - _maxHeight = _maxWidth / MemoryCoverWidget.aspectRatio; + _memoryWidth = screenWidth / (factor * 2); + _memoryheight = _memoryWidth / MemoryCoverWidget.aspectRatio; } @override @@ -66,7 +64,6 @@ class _MemoriesWidgetState extends State { _memoriesSettingSubscription.cancel(); _memoriesChangedSubscription.cancel(); _memorySeenSubscription.cancel(); - _controller.dispose(); super.dispose(); } @@ -84,7 +81,7 @@ class _MemoriesWidgetState extends State { builder: (context, snapshot) { if (snapshot.hasError || !snapshot.hasData) { return SizedBox( - height: _maxHeight + 12 + 10, + height: _memoryheight + 12 + 10, child: const EnteLoadingWidget(), ); } else { @@ -121,27 +118,22 @@ class _MemoriesWidgetState extends State { collatedMemories.addAll(seenMemories.map((e) => (e.memories, e.title))); return SizedBox( - height: _maxHeight + MemoryCoverWidget.outerStrokeWidth * 2, + height: _memoryheight + MemoryCoverWidget.outerStrokeWidth * 2, child: ListView.builder( physics: const AlwaysScrollableScrollPhysics( parent: BouncingScrollPhysics(), ), scrollDirection: Axis.horizontal, - controller: _controller, itemCount: collatedMemories.length, itemBuilder: (context, itemIndex) { - final maxScaleOffsetX = - _maxWidth + MemoryCoverWidget.horizontalPadding * 2; - final offsetOfItem = - (_maxWidth + MemoryCoverWidget.horizontalPadding * 2) * itemIndex; return MemoryCoverWidget( memories: collatedMemories[itemIndex].$1, - controller: _controller, - offsetOfItem: offsetOfItem, - maxHeight: _maxHeight, - maxWidth: _maxWidth, - maxScaleOffsetX: maxScaleOffsetX, + allMemories: collatedMemories.map((e) => e.$1).toList(), + height: _memoryheight, + width: _memoryWidth, title: collatedMemories[itemIndex].$2, + allTitle: collatedMemories.map((e) => e.$2).toList(), + currentMemoryIndex: itemIndex, ); }, ), diff --git a/mobile/lib/ui/home/memories/memory_cover_widget.dart b/mobile/lib/ui/home/memories/memory_cover_widget.dart index 55f8db652c..16665f6b2d 100644 --- a/mobile/lib/ui/home/memories/memory_cover_widget.dart +++ b/mobile/lib/ui/home/memories/memory_cover_widget.dart @@ -1,33 +1,34 @@ import "package:flutter/material.dart"; -import "package:flutter/scheduler.dart"; import "package:photos/models/memories/memory.dart"; import "package:photos/theme/colors.dart"; import "package:photos/theme/effects.dart"; import "package:photos/theme/ente_theme.dart"; -import "package:photos/ui/home/memories/full_screen_memory.dart"; +import "package:photos/ui/home/memories/all_memories_page.dart"; import "package:photos/ui/viewer/file/thumbnail_widget.dart"; +import "package:photos/utils/file_util.dart"; import "package:photos/utils/navigation_util.dart"; +// TODO: Use a single instance variable for `allMemories` and `allTitles` class MemoryCoverWidget extends StatefulWidget { final List memories; - final ScrollController controller; - final double offsetOfItem; - final double maxHeight; - final double maxWidth; + final List> allMemories; + final double height; + final double width; static const outerStrokeWidth = 1.0; static const aspectRatio = 0.68; static const horizontalPadding = 2.5; - final double maxScaleOffsetX; final String title; + final List allTitle; + final int currentMemoryIndex; const MemoryCoverWidget({ required this.memories, - required this.controller, - required this.offsetOfItem, - required this.maxHeight, - required this.maxWidth, - required this.maxScaleOffsetX, + required this.allMemories, + required this.height, + required this.width, required this.title, + required this.allTitle, + required this.currentMemoryIndex, super.key, }); @@ -36,6 +37,12 @@ class MemoryCoverWidget extends StatefulWidget { } class _MemoryCoverWidgetState extends State { + @override + void initState() { + super.initState(); + _preloadFirstUnseenMemory(); + } + @override Widget build(BuildContext context) { //memories will be empty if all memories are deleted and setState is called @@ -44,183 +51,180 @@ class _MemoryCoverWidgetState extends State { return const SizedBox.shrink(); } - final widthOfScreen = MediaQuery.sizeOf(context).width; final index = _getNextMemoryIndex(); final title = widget.title; final memory = widget.memories[index]; final isSeen = memory.isSeen(); - final brightness = - SchedulerBinding.instance.platformDispatcher.platformBrightness; + final brightness = Theme.of(context).brightness; - return AnimatedBuilder( - animation: widget.controller, - builder: (context, child) { - final diff = (widget.controller.offset - widget.offsetOfItem) + - widget.maxScaleOffsetX; - final scale = 1 - (diff / widthOfScreen).abs() / 3.7; - return Padding( - padding: const EdgeInsets.symmetric( - horizontal: MemoryCoverWidget.horizontalPadding, - ), - child: GestureDetector( - onTap: () async { - await routeToPage( - context, - FullScreenMemoryDataUpdater( - initialIndex: index, - memories: widget.memories, - child: FullScreenMemory(title, index), - ), - forceCustomPageRoute: true, - ); - setState(() {}); - }, //Adding this row is a workaround for making height of memory cover - //render as [MemoryCoverWidgetNew.height] * scale. Without this, height of rendered memory - //cover will be [MemoryCoverWidgetNew.height]. - child: Row( - children: [ - Container( - height: widget.maxHeight * scale, - width: widget.maxWidth * scale, - decoration: BoxDecoration( - boxShadow: brightness == Brightness.dark - ? [ - const BoxShadow( - color: strokeFainterDark, - spreadRadius: MemoryCoverWidget.outerStrokeWidth, - blurRadius: 0, - ), - ] - : [...shadowFloatFaintestLight], - borderRadius: BorderRadius.circular(5), - ), - child: ClipRRect( - borderRadius: BorderRadius.circular(5), - child: isSeen - ? ColorFiltered( - colorFilter: const ColorFilter.mode( - Color(0xFFBFBFBF), - BlendMode.hue, - ), - child: Stack( - fit: StackFit.expand, - alignment: Alignment.bottomCenter, - children: [ - child!, - Container( - decoration: BoxDecoration( - gradient: LinearGradient( - colors: [ - Colors.black.withOpacity(0.5), - Colors.transparent, - ], - stops: const [0, 1], - begin: Alignment.bottomCenter, - end: Alignment.topCenter, - ), - ), - ), - Positioned( - bottom: 8 * scale, - child: Transform.scale( - scale: scale, - child: SizedBox( - width: widget.maxWidth, - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: 8.0, - ), - child: Hero( - tag: title, - child: Center( - child: Text( - title, - style: getEnteTextTheme(context) - .miniBold - .copyWith( - color: isSeen - ? textFaintDark - : Colors.white, - ), - textAlign: TextAlign.left, - ), - ), - ), - ), - ), - ), - ), - ], - ), - ) - : Stack( - fit: StackFit.expand, - alignment: Alignment.bottomCenter, - children: [ - child!, - Container( - decoration: BoxDecoration( - gradient: LinearGradient( - colors: [ - Colors.black.withOpacity(0.5), - Colors.transparent, - ], - stops: const [0, 1], - begin: Alignment.bottomCenter, - end: Alignment.topCenter, - ), - ), - ), - Positioned( - bottom: 8 * scale, - child: Transform.scale( - scale: scale, - child: SizedBox( - width: widget.maxWidth, - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: 8.0, - ), - child: Hero( - tag: title, - child: Center( - child: Text( - title, - style: getEnteTextTheme(context) - .miniBold - .copyWith( - color: Colors.white, - ), - textAlign: TextAlign.left, - ), - ), - ), - ), - ), - ), - ), - ], - ), - ), - ), - ], + return Padding( + padding: const EdgeInsets.symmetric( + horizontal: MemoryCoverWidget.horizontalPadding, + ), + child: GestureDetector( + onTap: () async { + await routeToPage( + context, + forceCustomPageRoute: true, + AllMemoriesPage( + initialPageIndex: widget.currentMemoryIndex, + allMemories: widget.allMemories, + allTitles: widget.allTitle, ), + ); + setState(() {}); + }, + child: Container( + height: widget.height, + width: widget.width, + decoration: BoxDecoration( + boxShadow: brightness == Brightness.dark + ? [ + const BoxShadow( + color: strokeFainterDark, + spreadRadius: MemoryCoverWidget.outerStrokeWidth, + blurRadius: 0, + ), + ] + : [...shadowFloatFaintestLight], + borderRadius: BorderRadius.circular(5), + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(5), + child: isSeen + ? ColorFiltered( + colorFilter: const ColorFilter.mode( + Color(0xFFBFBFBF), + BlendMode.hue, + ), + child: Stack( + fit: StackFit.expand, + alignment: Alignment.bottomCenter, + children: [ + Hero( + tag: "memories" + memory.file.tag, + child: ThumbnailWidget( + memory.file, + shouldShowArchiveStatus: false, + shouldShowSyncStatus: false, + key: Key("memories" + memory.file.tag), + ), + ), + Container( + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [ + Colors.black.withOpacity(0.5), + Colors.transparent, + ], + stops: const [0, 1], + begin: Alignment.bottomCenter, + end: Alignment.topCenter, + ), + ), + ), + Positioned( + bottom: 8, + child: SizedBox( + width: widget.width, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 8.0, + ), + child: Hero( + tag: title, + child: Center( + child: Text( + title, + style: getEnteTextTheme(context) + .miniBold + .copyWith( + color: isSeen + ? textFaintDark + : Colors.white, + ), + textAlign: TextAlign.left, + ), + ), + ), + ), + ), + ), + ], + ), + ) + : Stack( + fit: StackFit.expand, + alignment: Alignment.bottomCenter, + children: [ + Hero( + tag: "memories" + memory.file.tag, + child: ThumbnailWidget( + memory.file, + shouldShowArchiveStatus: false, + shouldShowSyncStatus: false, + key: Key("memories" + memory.file.tag), + ), + ), + Container( + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [ + Colors.black.withOpacity(0.5), + Colors.transparent, + ], + stops: const [0, 1], + begin: Alignment.bottomCenter, + end: Alignment.topCenter, + ), + ), + ), + Positioned( + bottom: 8, + child: SizedBox( + width: widget.width, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 8.0, + ), + child: Hero( + tag: title, + child: Center( + child: Text( + title, + style: getEnteTextTheme(context) + .miniBold + .copyWith( + color: Colors.white, + ), + textAlign: TextAlign.left, + ), + ), + ), + ), + ), + ), + ], + ), ), - ); - }, - child: Hero( - tag: "memories" + memory.file.tag, - child: ThumbnailWidget( - memory.file, - shouldShowArchiveStatus: false, - shouldShowSyncStatus: false, - key: Key("memories" + memory.file.tag), ), ), ); } + void _preloadFirstUnseenMemory() { + Future.delayed(const Duration(seconds: 5), () { + if (mounted) { + if (widget.memories.isEmpty) return; + + final index = _getNextMemoryIndex(); + preloadThumbnail(widget.memories[index].file); + preloadFile(widget.memories[index].file); + } + }); + } + // Returns either the first unseen memory or the memory that succeeds the // last seen memory int _getNextMemoryIndex() { diff --git a/mobile/lib/ui/home/memories/memory_progress_indicator.dart b/mobile/lib/ui/home/memories/memory_progress_indicator.dart new file mode 100644 index 0000000000..0028215197 --- /dev/null +++ b/mobile/lib/ui/home/memories/memory_progress_indicator.dart @@ -0,0 +1,107 @@ +import "package:flutter/material.dart"; + +class MemoryProgressIndicator extends StatefulWidget { + final int totalSteps; + final int currentIndex; + final Duration duration; + final Color selectedColor; + final Color unselectedColor; + final double height; + final double gap; + final void Function(AnimationController)? animationController; + final VoidCallback? onComplete; + + const MemoryProgressIndicator({ + super.key, + required this.totalSteps, + required this.currentIndex, + this.duration = const Duration(seconds: 5), + this.selectedColor = Colors.white, + this.unselectedColor = Colors.white54, + this.height = 2.0, + this.gap = 4.0, + this.animationController, + this.onComplete, + }); + + @override + State createState() => + _MemoryProgressIndicatorState(); +} + +class _MemoryProgressIndicatorState extends State + with SingleTickerProviderStateMixin { + late AnimationController _animationController; + late Animation _animation; + + @override + void initState() { + super.initState(); + _animationController = AnimationController( + vsync: this, + duration: widget.duration, + ); + + _animation = + Tween(begin: 0.0, end: 1.0).animate(_animationController); + + if (widget.animationController != null) { + widget.animationController!(_animationController); + } + + _animationController.addStatusListener((status) { + if (status == AnimationStatus.completed && widget.onComplete != null) { + widget.onComplete!(); + } + }); + } + + @override + void dispose() { + _animationController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Row( + children: List.generate(widget.totalSteps, (index) { + return Expanded( + child: Padding( + padding: EdgeInsets.only(right: widget.gap), + child: index < widget.currentIndex + ? Container( + height: widget.height, + decoration: BoxDecoration( + color: widget.selectedColor, + borderRadius: BorderRadius.circular(12), + ), + ) + : index == widget.currentIndex + ? AnimatedBuilder( + animation: _animation, + builder: (context, child) { + return LinearProgressIndicator( + value: _animation.value, + backgroundColor: widget.unselectedColor, + valueColor: AlwaysStoppedAnimation( + widget.selectedColor, + ), + minHeight: widget.height, + borderRadius: BorderRadius.circular(12), + ); + }, + ) + : Container( + height: widget.height, + decoration: BoxDecoration( + color: widget.unselectedColor, + borderRadius: BorderRadius.circular(12), + ), + ), + ), + ); + }), + ); + } +} diff --git a/mobile/lib/ui/viewer/file/file_widget.dart b/mobile/lib/ui/viewer/file/file_widget.dart index a38f576c37..8d5e029720 100644 --- a/mobile/lib/ui/viewer/file/file_widget.dart +++ b/mobile/lib/ui/viewer/file/file_widget.dart @@ -12,6 +12,8 @@ class FileWidget extends StatelessWidget { final Function(bool)? playbackCallback; final BoxDecoration? backgroundDecoration; final bool? autoPlay; + final bool? isFromMemories; + final Function({required int memoryDuration})? onFinalFileLoad; const FileWidget( this.file, { @@ -20,6 +22,8 @@ class FileWidget extends StatelessWidget { this.playbackCallback, required this.tagPrefix, this.backgroundDecoration, + this.isFromMemories = false, + this.onFinalFileLoad, super.key, }); @@ -37,7 +41,9 @@ class FileWidget extends StatelessWidget { shouldDisableScroll: shouldDisableScroll, tagPrefix: tagPrefix, backgroundDecoration: backgroundDecoration, + isFromMemories: isFromMemories ?? false, key: key ?? ValueKey(fileKey), + onFinalFileLoad: onFinalFileLoad, ); } else if (file.fileType == FileType.video) { // use old video widget on iOS simulator as the new one crashes while @@ -54,6 +60,8 @@ class FileWidget extends StatelessWidget { file, tagPrefix: tagPrefix, playbackCallback: playbackCallback, + onFinalFileLoad: onFinalFileLoad, + isFromMemories: isFromMemories ?? false, key: key ?? ValueKey(fileKey), ); } else { diff --git a/mobile/lib/ui/viewer/file/video_widget.dart b/mobile/lib/ui/viewer/file/video_widget.dart index 60fc59ee52..2f7ef84d00 100644 --- a/mobile/lib/ui/viewer/file/video_widget.dart +++ b/mobile/lib/ui/viewer/file/video_widget.dart @@ -23,10 +23,15 @@ class VideoWidget extends StatefulWidget { final EnteFile file; final String? tagPrefix; final Function(bool)? playbackCallback; + final Function({required int memoryDuration})? onFinalFileLoad; + final bool isFromMemories; + const VideoWidget( this.file, { this.tagPrefix, this.playbackCallback, + this.onFinalFileLoad, + this.isFromMemories = false, super.key, }); @@ -149,6 +154,7 @@ class _VideoWidgetState extends State { playbackCallback: widget.playbackCallback, playlistData: playlistData, selectedPreview: playPreview, + isFromMemories: widget.isFromMemories, onStreamChange: () { setState(() { selectPreviewForPlay = !selectPreviewForPlay; @@ -162,6 +168,7 @@ class _VideoWidgetState extends State { ); }); }, + onFinalFileLoad: widget.onFinalFileLoad, ); } return VideoWidgetMediaKit( @@ -171,6 +178,7 @@ class _VideoWidgetState extends State { playbackCallback: widget.playbackCallback, preview: playlistData?.preview, selectedPreview: playPreview, + isFromMemories: widget.isFromMemories, onStreamChange: () { setState(() { selectPreviewForPlay = !selectPreviewForPlay; @@ -184,6 +192,7 @@ class _VideoWidgetState extends State { ); }); }, + onFinalFileLoad: widget.onFinalFileLoad, ); } } diff --git a/mobile/lib/ui/viewer/file/video_widget_media_kit.dart b/mobile/lib/ui/viewer/file/video_widget_media_kit.dart index 26c0f9054d..80b9f438ca 100644 --- a/mobile/lib/ui/viewer/file/video_widget_media_kit.dart +++ b/mobile/lib/ui/viewer/file/video_widget_media_kit.dart @@ -36,6 +36,7 @@ class VideoWidgetMediaKit extends StatefulWidget { final void Function() onStreamChange; final File? preview; final bool selectedPreview; + final Function({required int memoryDuration})? onFinalFileLoad; const VideoWidgetMediaKit( this.file, { @@ -45,6 +46,7 @@ class VideoWidgetMediaKit extends StatefulWidget { required this.onStreamChange, this.preview, required this.selectedPreview, + this.onFinalFileLoad, super.key, }); @@ -313,6 +315,13 @@ class _VideoWidgetMediaKitState extends State } player.open(Media(url), play: _isAppInFG); }); + int duration = controller!.player.state.duration.inSeconds; + if (duration == 0) { + duration = 10; + } + widget.onFinalFileLoad?.call( + memoryDuration: duration, + ); } } } diff --git a/mobile/lib/ui/viewer/file/video_widget_media_kit_common.dart b/mobile/lib/ui/viewer/file/video_widget_media_kit_common.dart index af7f048562..d5d5d3aa87 100644 --- a/mobile/lib/ui/viewer/file/video_widget_media_kit_common.dart +++ b/mobile/lib/ui/viewer/file/video_widget_media_kit_common.dart @@ -99,56 +99,79 @@ class _VideoWidgetState extends State { children: [ GestureDetector( behavior: HitTestBehavior.opaque, - onTap: () { - showControlsNotifier.value = !showControlsNotifier.value; - if (widget.playbackCallback != null) { - widget.playbackCallback!( - !showControlsNotifier.value, - ); + onTap: widget.isFromMemories + ? null + : () { + showControlsNotifier.value = + !showControlsNotifier.value; + if (widget.playbackCallback != null) { + widget.playbackCallback!( + !showControlsNotifier.value, + ); + } + }, + onLongPress: () { + if (widget.isFromMemories) { + widget.playbackCallback?.call(false); + if (widget.controller.player.state.playing) { + widget.controller.player.pause(); + } + } + }, + onLongPressUp: () { + if (widget.isFromMemories) { + widget.playbackCallback?.call(true); + if (!widget.controller.player.state.playing) { + widget.controller.player.play(); + } } }, child: Container( constraints: const BoxConstraints.expand(), ), ), - IgnorePointer( - ignoring: !value, - child: PlayPauseButtonMediaKit(widget.controller), - ), - Positioned( - bottom: verticalMargin, - right: 0, - left: 0, - child: IgnorePointer( - ignoring: !value, - child: SafeArea( - top: false, - left: false, - right: false, - child: Padding( - padding: EdgeInsets.only( - bottom: widget.isFromMemories ? 32 : 0, - ), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - VideoStreamChangeWidget( - showControls: value, - file: widget.file, - isPreviewPlayer: widget.isPreviewPlayer, - onStreamChange: widget.onStreamChange, + widget.isFromMemories + ? const SizedBox.shrink() + : IgnorePointer( + ignoring: !value, + child: PlayPauseButtonMediaKit(widget.controller), + ), + widget.isFromMemories + ? const SizedBox.shrink() + : Positioned( + bottom: verticalMargin, + right: 0, + left: 0, + child: IgnorePointer( + ignoring: !value, + child: SafeArea( + top: false, + left: false, + right: false, + child: Padding( + padding: EdgeInsets.only( + bottom: widget.isFromMemories ? 32 : 0, + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + VideoStreamChangeWidget( + showControls: value, + file: widget.file, + isPreviewPlayer: widget.isPreviewPlayer, + onStreamChange: widget.onStreamChange, + ), + SeekBarAndDuration( + controller: widget.controller, + isSeekingNotifier: _isSeekingNotifier, + file: widget.file, + ), + ], + ), ), - SeekBarAndDuration( - controller: widget.controller, - isSeekingNotifier: _isSeekingNotifier, - file: widget.file, - ), - ], + ), ), ), - ), - ), - ), ], ), ); diff --git a/mobile/lib/ui/viewer/file/video_widget_native.dart b/mobile/lib/ui/viewer/file/video_widget_native.dart index 90265e7aee..3b658e79ce 100644 --- a/mobile/lib/ui/viewer/file/video_widget_native.dart +++ b/mobile/lib/ui/viewer/file/video_widget_native.dart @@ -46,6 +46,7 @@ class VideoWidgetNative extends StatefulWidget { final void Function()? onStreamChange; final PlaylistData? playlistData; final bool selectedPreview; + final Function({required int memoryDuration})? onFinalFileLoad; const VideoWidgetNative( this.file, { @@ -55,6 +56,7 @@ class VideoWidgetNative extends StatefulWidget { required this.onStreamChange, super.key, this.playlistData, + this.onFinalFileLoad, required this.selectedPreview, }); @@ -138,11 +140,7 @@ class _VideoWidgetNativeState extends State widget.file.uploadedFileID!, ) .listen((event) { - if (mounted) { - setState(() { - _progressNotifier.value = event.progress; - }); - } + _progressNotifier.value = event.progress; }); } @@ -303,12 +301,27 @@ class _VideoWidgetNativeState extends State ), GestureDetector( behavior: HitTestBehavior.opaque, - onTap: () { - _showControls.value = !_showControls.value; - if (widget.playbackCallback != null) { - widget.playbackCallback!(!_showControls.value); + onTap: widget.isFromMemories + ? null + : () { + _showControls.value = !_showControls.value; + if (widget.playbackCallback != null) { + widget + .playbackCallback!(!_showControls.value); + } + _elTooltipController.hide(); + }, + onLongPress: () { + if (widget.isFromMemories) { + widget.playbackCallback?.call(false); + _controller?.pause(); + } + }, + onLongPressUp: () { + if (widget.isFromMemories) { + widget.playbackCallback?.call(true); + _controller?.play(); } - _elTooltipController.hide(); }, child: Container( constraints: const BoxConstraints.expand(), @@ -333,86 +346,101 @@ class _VideoWidgetNativeState extends State ), ) : const SizedBox.shrink(), - Positioned.fill( - child: Center( - child: ValueListenableBuilder( - builder: (BuildContext context, bool value, _) { - return value - ? ValueListenableBuilder( - builder: (context, bool value, _) { - return AnimatedOpacity( - duration: - const Duration(milliseconds: 200), - opacity: value ? 1 : 0, - curve: Curves.easeInOutQuad, - child: IgnorePointer( - ignoring: !value, - child: PlayPauseButton(_controller), - ), - ); - }, - valueListenable: _showControls, - ) - : const SizedBox(); - }, - valueListenable: _isPlaybackReady, - ), - ), - ), - Positioned( - bottom: verticalMargin, - right: 0, - left: 0, - child: SafeArea( - top: false, - left: false, - right: false, - child: Padding( - padding: EdgeInsets.only( - bottom: widget.isFromMemories ? 32 : 0, - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - _VideoDescriptionAndSwitchToMediaKitButton( - file: widget.file, - showControls: _showControls, - elTooltipController: _elTooltipController, - controller: _controller, - selectedPreview: widget.selectedPreview, - ), - ValueListenableBuilder( - valueListenable: _showControls, - builder: (context, value, _) { - return VideoStreamChangeWidget( - showControls: value, - file: widget.file, - isPreviewPlayer: widget.selectedPreview, - onStreamChange: widget.onStreamChange, - ); - }, - ), - ValueListenableBuilder( - valueListenable: _isPlaybackReady, + widget.isFromMemories + ? const SizedBox.shrink() + : Positioned.fill( + child: Center( + child: ValueListenableBuilder( builder: (BuildContext context, bool value, _) { return value - ? _SeekBarAndDuration( - controller: _controller, - duration: duration, - showControls: _showControls, - isSeeking: _isSeeking, - position: position, - file: widget.file, + ? ValueListenableBuilder( + builder: (context, bool value, _) { + return AnimatedOpacity( + duration: const Duration( + milliseconds: 200, + ), + opacity: value ? 1 : 0, + curve: Curves.easeInOutQuad, + child: IgnorePointer( + ignoring: !value, + child: PlayPauseButton( + _controller, + ), + ), + ); + }, + valueListenable: _showControls, ) : const SizedBox(); }, + valueListenable: _isPlaybackReady, ), - ], + ), + ), + widget.isFromMemories + ? const SizedBox.shrink() + : Positioned( + bottom: verticalMargin, + right: 0, + left: 0, + child: SafeArea( + top: false, + left: false, + right: false, + child: Padding( + padding: EdgeInsets.only( + bottom: widget.isFromMemories ? 32 : 0, + ), + child: Column( + crossAxisAlignment: + CrossAxisAlignment.center, + children: [ + _VideoDescriptionAndSwitchToMediaKitButton( + file: widget.file, + showControls: _showControls, + elTooltipController: + _elTooltipController, + controller: _controller, + selectedPreview: widget.selectedPreview, + ), + ValueListenableBuilder( + valueListenable: _showControls, + builder: (context, value, _) { + return VideoStreamChangeWidget( + showControls: value, + file: widget.file, + isPreviewPlayer: + widget.selectedPreview, + onStreamChange: + widget.onStreamChange, + ); + }, + ), + ValueListenableBuilder( + valueListenable: _isPlaybackReady, + builder: ( + BuildContext context, + bool value, + _, + ) { + return value + ? _SeekBarAndDuration( + controller: _controller, + duration: duration, + showControls: _showControls, + isSeeking: _isSeeking, + position: position, + file: widget.file, + ) + : const SizedBox(); + }, + ), + ], + ), + ), + ), ), - ), - ), - ), ], ), ), @@ -465,6 +493,7 @@ class _VideoWidgetNativeState extends State } void _seekListener() { + if (widget.isFromMemories) return; if (!_isSeeking.value && _controller?.playbackStatus == PlaybackStatus.playing) { _debouncer.run(() async { @@ -483,6 +512,7 @@ class _VideoWidgetNativeState extends State } void _onPlaybackStatusChanged() { + if (widget.isFromMemories) return; final duration = widget.file.duration != null ? widget.file.duration! * 1000 : _controller?.videoInfo?.durationInMilliseconds; @@ -527,6 +557,8 @@ class _VideoWidgetNativeState extends State Future _onPlaybackReady() async { if (_isPlaybackReady.value) return; await _controller!.play(); + final durationInSeconds = durationToSeconds(duration) ?? 10; + widget.onFinalFileLoad?.call(memoryDuration: durationInSeconds); unawaited(_controller!.setVolume(1)); _isPlaybackReady.value = true; } @@ -740,11 +772,7 @@ class _SeekBarAndDuration extends StatelessWidget { Widget build(BuildContext context) { return ValueListenableBuilder( valueListenable: showControls, - builder: ( - BuildContext context, - bool value, - _, - ) { + builder: (BuildContext context, bool value, _) { return AnimatedOpacity( duration: const Duration( milliseconds: 200, diff --git a/mobile/lib/ui/viewer/file/zoomable_image.dart b/mobile/lib/ui/viewer/file/zoomable_image.dart index 9300b8cacc..45f4f2b016 100644 --- a/mobile/lib/ui/viewer/file/zoomable_image.dart +++ b/mobile/lib/ui/viewer/file/zoomable_image.dart @@ -13,6 +13,7 @@ import 'package:photos/db/files_db.dart'; import "package:photos/events/file_caption_updated_event.dart"; import "package:photos/events/files_updated_event.dart"; import 'package:photos/events/local_photos_updated_event.dart'; +import "package:photos/events/reset_zoom_of_photo_view_event.dart"; import "package:photos/models/file/extensions/file_props.dart"; import 'package:photos/models/file/file.dart'; import "package:photos/states/detail_page_state.dart"; @@ -31,6 +32,7 @@ class ZoomableImage extends StatefulWidget { final Decoration? backgroundDecoration; final bool shouldCover; final bool isGuestView; + final Function({required int memoryDuration})? onFinalFileLoad; const ZoomableImage( this.photo, { @@ -40,6 +42,7 @@ class ZoomableImage extends StatefulWidget { this.backgroundDecoration, this.shouldCover = false, this.isGuestView = false, + this.onFinalFileLoad, }); @override @@ -62,6 +65,7 @@ class _ZoomableImageState extends State { final _scaleStateController = PhotoViewScaleStateController(); late final StreamSubscription _captionUpdatedSubscription; + late final StreamSubscription _resetZoomSubscription; // This is to prevent the app from crashing when loading 200MP images // https://github.com/flutter/flutter/issues/110331 @@ -90,6 +94,16 @@ class _ZoomableImageState extends State { } } }); + + _resetZoomSubscription = + Bus.instance.on().listen((event) { + if (event.isSamePhoto( + uploadedFileID: widget.photo.uploadedFileID, + localID: widget.photo.localID, + )) { + _scaleStateController.scaleState = PhotoViewScaleState.initial; + } + }); } @override @@ -97,6 +111,7 @@ class _ZoomableImageState extends State { _photoViewController.dispose(); _scaleStateController.dispose(); _captionUpdatedSubscription.cancel(); + _resetZoomSubscription.cancel(); super.dispose(); } @@ -426,6 +441,7 @@ class _ZoomableImageState extends State { _loadedFinalImage = true; _logger.info("Final image loaded"); }); + widget.onFinalFileLoad?.call(memoryDuration: 5); } Future _updatePhotoViewController({ diff --git a/mobile/lib/ui/viewer/file/zoomable_live_image_new.dart b/mobile/lib/ui/viewer/file/zoomable_live_image_new.dart index 4483c2ff40..a3757ac2a3 100644 --- a/mobile/lib/ui/viewer/file/zoomable_live_image_new.dart +++ b/mobile/lib/ui/viewer/file/zoomable_live_image_new.dart @@ -23,13 +23,17 @@ class ZoomableLiveImageNew extends StatefulWidget { final Function(bool)? shouldDisableScroll; final String? tagPrefix; final Decoration? backgroundDecoration; - + final bool isFromMemories; + final Function({required int memoryDuration})? onFinalFileLoad; + const ZoomableLiveImageNew( this.enteFile, { super.key, this.shouldDisableScroll, required this.tagPrefix, this.backgroundDecoration, + this.isFromMemories = false, + this.onFinalFileLoad, }); @override @@ -94,13 +98,17 @@ class _ZoomableLiveImageNewState extends State shouldDisableScroll: widget.shouldDisableScroll, backgroundDecoration: widget.backgroundDecoration, isGuestView: isGuestView, + onFinalFileLoad: widget.onFinalFileLoad, ); } - return GestureDetector( - onLongPressStart: (_) => {_onLongPressEvent(true)}, - onLongPressEnd: (_) => {_onLongPressEvent(false)}, - child: content, - ); + if (!widget.isFromMemories) { + return GestureDetector( + onLongPressStart: (_) => _onLongPressEvent(true), + onLongPressEnd: (_) => _onLongPressEvent(false), + child: content, + ); + } + return content; } @override