[mob][photos] Memories improvement (#6152)

## Description
1. A subtle zoom-in/out effect for photos and replaced the black area
around landscape photos with a blurred background.
2. Auto play each image for 5 second and video for its duration with a
step progress animation.
3. Long-press to pause animation. Releasing will resume the playback.
This commit is contained in:
Ashil
2025-07-01 15:40:03 +05:30
committed by GitHub
16 changed files with 1368 additions and 536 deletions

View File

@@ -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

View File

@@ -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;
}
}

View File

@@ -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,
);

View File

@@ -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<List<Memory>> allMemories;
final List<String> 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<AllMemoriesPage> createState() => _AllMemoriesPageState();
}
class _AllMemoriesPageState extends State<AllMemoriesPage>
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;
}
}

View File

@@ -0,0 +1,150 @@
import 'dart:async';
import 'package:flutter/widgets.dart';
class ActivePointers with ChangeNotifier {
final Set<int> _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 longpress 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<bool>? 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<MemoriesPointerGestureListener> {
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 longpress
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;
}
}

View File

@@ -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<Memory> 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<FullScreenMemory> {
PageController? _pageController;
final _showTitle = ValueNotifier<bool>(true);
AnimationController? _progressAnimationController;
AnimationController? _zoomAnimationController;
final ValueNotifier<Duration> durationNotifier =
ValueNotifier(const Duration(seconds: 5));
/// Used to check if any pointer is on the screen.
final hasPointerOnScreenNotifier = ValueNotifier<bool>(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<Duration>(
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<int>(
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<bool> 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<MemoriesZoomWidget> createState() => _MemoriesZoomWidgetState();
}
class _MemoriesZoomWidgetState extends State<MemoriesZoomWidget>
with TickerProviderStateMixin {
late AnimationController _controller;
late Animation<double> _scaleAnimation;
late Animation<Offset> _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<double>(
begin: startScale,
end: endScale,
).animate(
CurvedAnimation(
parent: _controller,
curve: Curves.easeInOut,
),
);
_panAnimation = Tween<Offset>(
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,
),
);
},
),
);
}
}

View File

@@ -20,12 +20,11 @@ class MemoriesWidget extends StatefulWidget {
}
class _MemoriesWidgetState extends State<MemoriesWidget> {
late ScrollController _controller;
late StreamSubscription<MemoriesSettingChanged> _memoriesSettingSubscription;
late StreamSubscription<MemoriesChangedEvent> _memoriesChangedSubscription;
late StreamSubscription<MemorySeenEvent> _memorySeenSubscription;
late double _maxHeight;
late double _maxWidth;
late double _memoryheight;
late double _memoryWidth;
@override
void initState() {
@@ -48,7 +47,6 @@ class _MemoriesWidgetState extends State<MemoriesWidget> {
setState(() {});
}
});
_controller = ScrollController();
}
@override
@@ -57,8 +55,8 @@ class _MemoriesWidgetState extends State<MemoriesWidget> {
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<MemoriesWidget> {
_memoriesSettingSubscription.cancel();
_memoriesChangedSubscription.cancel();
_memorySeenSubscription.cancel();
_controller.dispose();
super.dispose();
}
@@ -84,7 +81,7 @@ class _MemoriesWidgetState extends State<MemoriesWidget> {
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<MemoriesWidget> {
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,
);
},
),

View File

@@ -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<Memory> memories;
final ScrollController controller;
final double offsetOfItem;
final double maxHeight;
final double maxWidth;
final List<List<Memory>> 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<String> 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<MemoryCoverWidget> {
@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<MemoryCoverWidget> {
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() {

View File

@@ -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<MemoryProgressIndicator> createState() =>
_MemoryProgressIndicatorState();
}
class _MemoryProgressIndicatorState extends State<MemoryProgressIndicator>
with SingleTickerProviderStateMixin {
late AnimationController _animationController;
late Animation<double> _animation;
@override
void initState() {
super.initState();
_animationController = AnimationController(
vsync: this,
duration: widget.duration,
);
_animation =
Tween<double>(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<Color>(
widget.selectedColor,
),
minHeight: widget.height,
borderRadius: BorderRadius.circular(12),
);
},
)
: Container(
height: widget.height,
decoration: BoxDecoration(
color: widget.unselectedColor,
borderRadius: BorderRadius.circular(12),
),
),
),
);
}),
);
}
}

View File

@@ -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 {

View File

@@ -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<VideoWidget> {
playbackCallback: widget.playbackCallback,
playlistData: playlistData,
selectedPreview: playPreview,
isFromMemories: widget.isFromMemories,
onStreamChange: () {
setState(() {
selectPreviewForPlay = !selectPreviewForPlay;
@@ -162,6 +168,7 @@ class _VideoWidgetState extends State<VideoWidget> {
);
});
},
onFinalFileLoad: widget.onFinalFileLoad,
);
}
return VideoWidgetMediaKit(
@@ -171,6 +178,7 @@ class _VideoWidgetState extends State<VideoWidget> {
playbackCallback: widget.playbackCallback,
preview: playlistData?.preview,
selectedPreview: playPreview,
isFromMemories: widget.isFromMemories,
onStreamChange: () {
setState(() {
selectPreviewForPlay = !selectPreviewForPlay;
@@ -184,6 +192,7 @@ class _VideoWidgetState extends State<VideoWidget> {
);
});
},
onFinalFileLoad: widget.onFinalFileLoad,
);
}
}

View File

@@ -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<VideoWidgetMediaKit>
}
player.open(Media(url), play: _isAppInFG);
});
int duration = controller!.player.state.duration.inSeconds;
if (duration == 0) {
duration = 10;
}
widget.onFinalFileLoad?.call(
memoryDuration: duration,
);
}
}
}

View File

@@ -99,56 +99,79 @@ class _VideoWidgetState extends State<VideoWidget> {
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,
),
],
),
),
),
),
),
),
],
),
);

View File

@@ -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<VideoWidgetNative>
widget.file.uploadedFileID!,
)
.listen((event) {
if (mounted) {
setState(() {
_progressNotifier.value = event.progress;
});
}
_progressNotifier.value = event.progress;
});
}
@@ -303,12 +301,27 @@ class _VideoWidgetNativeState extends State<VideoWidgetNative>
),
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<VideoWidgetNative>
),
)
: 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<VideoWidgetNative>
}
void _seekListener() {
if (widget.isFromMemories) return;
if (!_isSeeking.value &&
_controller?.playbackStatus == PlaybackStatus.playing) {
_debouncer.run(() async {
@@ -483,6 +512,7 @@ class _VideoWidgetNativeState extends State<VideoWidgetNative>
}
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<VideoWidgetNative>
Future<void> _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,

View File

@@ -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<ZoomableImage> {
final _scaleStateController = PhotoViewScaleStateController();
late final StreamSubscription<FileCaptionUpdatedEvent>
_captionUpdatedSubscription;
late final StreamSubscription<ResetZoomOfPhotoView> _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<ZoomableImage> {
}
}
});
_resetZoomSubscription =
Bus.instance.on<ResetZoomOfPhotoView>().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<ZoomableImage> {
_photoViewController.dispose();
_scaleStateController.dispose();
_captionUpdatedSubscription.cancel();
_resetZoomSubscription.cancel();
super.dispose();
}
@@ -426,6 +441,7 @@ class _ZoomableImageState extends State<ZoomableImage> {
_loadedFinalImage = true;
_logger.info("Final image loaded");
});
widget.onFinalFileLoad?.call(memoryDuration: 5);
}
Future<void> _updatePhotoViewController({

View File

@@ -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<ZoomableLiveImageNew>
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