Improve UX of FullScreenMemory widget
This commit is contained in:
18
mobile/lib/events/reset_zoom_of_photo_view_event.dart
Normal file
18
mobile/lib/events/reset_zoom_of_photo_view_event.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
150
mobile/lib/ui/home/memories/custom_listener.dart
Normal file
150
mobile/lib/ui/home/memories/custom_listener.dart
Normal 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 long‐press fires.
|
||||
final Duration longPressDuration;
|
||||
|
||||
/// Maximum movement (in logical pixels) before we consider it a drag.
|
||||
final double touchSlop;
|
||||
|
||||
/// Notifier that indicates whether there are active pointers.
|
||||
final ValueNotifier<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 long‐press
|
||||
hasPointerMoved = true;
|
||||
_longPressTimer!.cancel();
|
||||
_longPressTimer = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _handlePointerUp(PointerUpEvent event) {
|
||||
_longPressTimer?.cancel();
|
||||
_longPressTimer = null;
|
||||
|
||||
if (_longPressFired) {
|
||||
// widget.onLongPressUp?.call();
|
||||
} else {
|
||||
if (!_activePointers.activePointerWasPartOfMultitouch &&
|
||||
!hasPointerMoved) {
|
||||
widget.onTap?.call(event);
|
||||
}
|
||||
}
|
||||
_removePointer(event.pointer);
|
||||
_reset();
|
||||
}
|
||||
|
||||
void _handlePointerCancel(PointerCancelEvent event) {
|
||||
_longPressTimer?.cancel();
|
||||
_longPressTimer = null;
|
||||
_longPressFired = false;
|
||||
_removePointer(event.pointer);
|
||||
_reset();
|
||||
}
|
||||
|
||||
void _removePointer(int pointer) {
|
||||
_activePointers.remove(pointer);
|
||||
}
|
||||
|
||||
void _addPointer(int pointer) {
|
||||
_activePointers.add(pointer);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Listener(
|
||||
onPointerDown: _handlePointerDown,
|
||||
onPointerMove: _handlePointerMove,
|
||||
onPointerUp: _handlePointerUp,
|
||||
onPointerCancel: _handlePointerCancel,
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: widget.child,
|
||||
);
|
||||
}
|
||||
|
||||
void _reset() {
|
||||
hasPointerMoved = false;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user