Improve UX of FullScreenMemory widget

This commit is contained in:
ashilkn
2025-06-25 09:19:56 +05:30
parent a3c011070a
commit bf93e28a3d
4 changed files with 228 additions and 16 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

@@ -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,6 +6,8 @@ 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";
@@ -13,6 +15,7 @@ import "package:photos/services/smart_memories_service.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";
@@ -146,22 +149,52 @@ 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);
@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();
@@ -325,19 +358,17 @@ class _FullScreenMemoryState extends State<FullScreenMemory> {
final isVideo = currentMemory.file.fileType == FileType.video;
final currentFile = currentMemory.file;
return GestureDetector(
onTapUp: (TapUpDetails details) {
return MemoriesPointerGestureListener(
onTap: (PointerEvent event) {
final screenWidth = MediaQuery.sizeOf(context).width;
final edgeWidth = screenWidth * 0.20;
if (details.localPosition.dx < edgeWidth) {
final goToPreviousTapAreaWidth = screenWidth * 0.20;
if (event.localPosition.dx < goToPreviousTapAreaWidth) {
_goToPrevious(inheritedData);
} else if (details.localPosition.dx >
screenWidth - edgeWidth) {
} else {
_goToNext(inheritedData);
}
},
onLongPress: () => isVideo ? null : _toggleAnimation(true),
onLongPressUp: () => isVideo ? null : _toggleAnimation(false),
hasPointerNotifier: hasPointerOnScreenNotifier,
child: MemoriesZoomWidget(
scaleController: (controller) {
_zoomAnimationController = controller;
@@ -352,7 +383,7 @@ class _FullScreenMemoryState extends State<FullScreenMemory> {
const BoxDecoration(color: Colors.transparent),
isFromMemories: true,
playbackCallback: (isPlaying) {
_toggleAnimation(!isPlaying);
_toggleAnimation(pause: !isPlaying);
},
onFinalFileLoad: ({required int memoryDuration}) {
onFinalFileLoad(memoryDuration);
@@ -424,9 +455,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);
},
),
];
@@ -442,7 +473,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
@@ -455,7 +486,7 @@ class BottomIcons extends StatelessWidget {
},
},
);
fullScreenState?._toggleAnimation(false);
fullScreenState?._toggleAnimation(pause: false);
},
),
SizedBox(
@@ -471,9 +502,9 @@ 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);
},
),
);

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();
}