Resolve merge conflicts and merge UI/UX improvements to memory_improvement branch

This commit is contained in:
ashilkn
2025-06-25 16:27:40 +05:30
5 changed files with 443 additions and 200 deletions

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,5 +1,6 @@
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";
class AllMemoriesPage extends StatefulWidget {
@@ -50,27 +51,26 @@ class _AllMemoriesPageState extends State<AllMemoriesPage>
return FullScreenMemoryDataUpdater(
initialIndex: initialMemoryIndex,
memories: widget.allMemories[index],
child: ClipRRect(
child: FullScreenMemory(
widget.allTitles[index],
initialMemoryIndex,
onNextMemory: index < widget.allMemories.length - 1
? () => pageController.nextPage(
duration: const Duration(milliseconds: 250),
curve: Curves.ease,
)
: null,
onPreviousMemory: index > 0
? () => pageController.previousPage(
duration: const Duration(milliseconds: 250),
curve: Curves.ease,
)
: null,
),
child: FullScreenMemory(
widget.allTitles[index],
initialMemoryIndex,
onNextMemory: index < widget.allMemories.length - 1
? () => pageController.nextPage(
duration: const Duration(milliseconds: 250),
curve: Curves.ease,
)
: null,
onPreviousMemory: index > 0
? () => pageController.previousPage(
duration: const Duration(milliseconds: 250),
curve: Curves.ease,
)
: null,
),
);
},
),
backgroundColor: backgroundBaseDark,
);
}

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

@@ -6,13 +6,17 @@ 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/colors.dart";
import "package:photos/theme/ente_theme.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";
@@ -37,6 +41,8 @@ import "package:photos/utils/share_util.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;
@@ -145,28 +151,61 @@ class _FullScreenMemoryState extends State<FullScreenMemory> {
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) _showTitle.value = false;
});
hasPointerOnScreenNotifier.addListener(
_hasPointerListener,
);
}
@override
void dispose() {
_showTitle.dispose();
durationNotifier.dispose();
hasPointerOnScreenNotifier.removeListener(_hasPointerListener);
super.dispose();
}
void _toggleAnimation(bool pause) {
/// 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 {
_progressAnimationController?.forward();
_zoomAnimationController?.forward();
if (hasFinalFileLoaded) {
_progressAnimationController?.forward();
_zoomAnimationController?.forward();
}
}
}
@@ -180,6 +219,7 @@ class _FullScreenMemoryState extends State<FullScreenMemory> {
}
void onFinalFileLoad(int duration) {
hasFinalFileLoaded = true;
if (_progressAnimationController?.isAnimating == true) {
_progressAnimationController!.stop();
}
@@ -196,6 +236,7 @@ class _FullScreenMemoryState extends State<FullScreenMemory> {
}
void _goToNext(FullScreenMemoryData inheritedData) {
hasFinalFileLoaded = false;
final currentIndex = inheritedData.indexNotifier.value;
if (currentIndex < inheritedData.memories.length - 1) {
inheritedData.indexNotifier.value += 1;
@@ -206,6 +247,7 @@ class _FullScreenMemoryState extends State<FullScreenMemory> {
}
void _goToPrevious(FullScreenMemoryData inheritedData) {
hasFinalFileLoaded = false;
final currentIndex = inheritedData.indexNotifier.value;
if (currentIndex > 0) {
inheritedData.indexNotifier.value -= 1;
@@ -228,180 +270,203 @@ class _FullScreenMemoryState extends State<FullScreenMemory> {
@override
Widget build(BuildContext context) {
final screenPadding = MediaQuery.paddingOf(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),
),
),
builder: (context, value, child) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
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: 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,
),
),
],
),
],
);
},
),
flexibleSpace: Container(
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Colors.black54,
Colors.black45,
Colors.transparent,
],
stops: [0, 0.6, 1],
),
),
),
backgroundColor: Colors.transparent,
elevation: 0,
return Padding(
padding: EdgeInsets.fromLTRB(
4,
screenPadding.top + 8,
4,
screenPadding.bottom + 8,
),
body: Stack(
alignment: Alignment.bottomCenter,
children: [
const MemoryBackDrop(),
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 GestureDetector(
onTapUp: (TapUpDetails details) {
final screenWidth = MediaQuery.sizeOf(context).width;
final edgeWidth = screenWidth * 0.20;
if (details.localPosition.dx < edgeWidth) {
_goToPrevious(inheritedData);
} else if (details.localPosition.dx >
screenWidth - edgeWidth) {
_goToNext(inheritedData);
}
},
onLongPress: () => isVideo ? null : _toggleAnimation(true),
onLongPressUp: () => isVideo ? null : _toggleAnimation(false),
child: MemoriesZoomWidget(
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(!isPlaying);
},
onFinalFileLoad: ({required int memoryDuration}) {
onFinalFileLoad(memoryDuration);
},
child: Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: strokeFainterDark,
width: 1,
),
),
child: ClipRRect(
borderRadius: BorderRadius.circular(16),
child: Scaffold(
backgroundColor: Colors.black,
extendBodyBehindAppBar: true,
appBar: AppBar(
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),
),
),
);
},
),
SafeArea(
top: false,
child: Align(
alignment: Alignment.bottomLeft,
child: Padding(
padding: const EdgeInsets.only(bottom: 72),
child: 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(16, 4, 16, 12),
child: Hero(
tag: widget.title,
child: Text(
widget.title,
style: getEnteTextTheme(context)
.largeBold
.copyWith(
fontSize: 40,
color: Colors.white,
),
),
),
builder: (context, value, child) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
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);
},
);
},
)
: showStepProgressIndicator
? const SizedBox.shrink()
: const MemoryCounter(),
: 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,
),
),
],
),
],
);
},
),
flexibleSpace: Container(
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Colors.black54,
Colors.black45,
Colors.transparent,
],
stops: [0, 0.6, 1],
),
),
),
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);
},
),
),
);
},
),
),
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(16, 4, 16, 12),
child: Hero(
tag: widget.title,
child: Text(
widget.title,
style: getEnteTextTheme(context)
.largeBold
.copyWith(
color: Colors.white,
),
),
),
)
: showStepProgressIndicator
? const SizedBox.shrink()
: const MemoryCounter(),
);
},
),
const BottomIcons(),
],
),
const BottomGradient(),
],
),
),
const BottomGradient(),
const BottomIcons(),
],
),
),
);
}
@@ -427,9 +492,9 @@ class BottomIcons extends StatelessWidget {
color: Colors.white, //same for both themes
),
onPressed: () async {
fullScreenState?._toggleAnimation(true);
fullScreenState?._toggleAnimation(pause: true);
await showDetailsSheet(context, currentFile);
fullScreenState?._toggleAnimation(false);
fullScreenState?._toggleAnimation(pause: false);
},
),
];
@@ -445,7 +510,7 @@ class BottomIcons extends StatelessWidget {
color: Colors.white, //same for both themes
),
onPressed: () async {
fullScreenState?._toggleAnimation(true);
fullScreenState?._toggleAnimation(pause: true);
await showSingleFileDeleteSheet(
context,
inheritedData
@@ -458,7 +523,7 @@ class BottomIcons extends StatelessWidget {
},
},
);
fullScreenState?._toggleAnimation(false);
fullScreenState?._toggleAnimation(pause: false);
},
),
SizedBox(
@@ -474,22 +539,19 @@ class BottomIcons extends StatelessWidget {
color: Colors.white, //same for both themes
),
onPressed: () async {
fullScreenState?._toggleAnimation(true);
fullScreenState?._toggleAnimation(pause: true);
await share(context, [currentFile]);
fullScreenState?._toggleAnimation(false);
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,
),
);
},
@@ -522,7 +584,7 @@ class BottomGradient extends StatelessWidget {
Widget build(BuildContext context) {
return IgnorePointer(
child: Container(
height: 172,
height: 96,
width: double.infinity,
decoration: const BoxDecoration(
gradient: LinearGradient(
@@ -541,8 +603,8 @@ class BottomGradient extends StatelessWidget {
}
}
class MemoryBackDrop extends StatelessWidget {
const MemoryBackDrop({super.key});
class _MemoryBlur extends StatelessWidget {
const _MemoryBlur();
@override
Widget build(BuildContext context) {

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";
@@ -64,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
@@ -92,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
@@ -99,6 +111,7 @@ class _ZoomableImageState extends State<ZoomableImage> {
_photoViewController.dispose();
_scaleStateController.dispose();
_captionUpdatedSubscription.cancel();
_resetZoomSubscription.cancel();
super.dispose();
}