Compare commits

...

15 Commits

Author SHA1 Message Date
ashilkn
e0ff71828a [mob][photos] Add comments 2024-07-04 17:08:13 +05:30
ashilkn
28d9775203 [mob][photos] Fix unexpected selections on vertical drag gestures + remove bugs
Now the feature works without any unexpected behaviour
2024-07-04 16:55:38 +05:30
ashilkn
c70c6ac617 [mob][photos] Handle onTap and onLongPress gestures up the widget tree and not in GalleryFileWidget 2024-07-04 15:07:37 +05:30
ashilkn
28107cc7ea [mob][photos] Swipe to select: Fix bug where on any update to a group (a day), swipe to select stops working 2024-07-03 11:33:37 +05:30
ashilkn
8711753d6f [mob][photos] Swipe to select: Reduce errors by returning from initState if renderBox is null 2024-07-01 16:59:53 +05:30
ashilkn
b68d11ffbc [mob][photos] Swipe to select: Remove flutter errors 2024-07-01 16:57:06 +05:30
ashilkn
ac0235a6be [mob][photos] Swipe to select: remove unnecessary delay 2024-07-01 15:09:45 +05:30
ashilkn
8116b05a9d [mob][photos]Swipe to select clean up + reduce flutter errors 2024-07-01 14:30:14 +05:30
ashilkn
3f49395ee2 [mob][photos] Disable swipe to select if selection is limited to one 2024-07-01 10:06:07 +05:30
ashilkn
2d80ed7332 [mob][photos] Use better names 2024-06-30 14:25:46 +05:30
ashilkn
5ca0be9f2b [mob][photos] Fix issues with single tap and some other issues when dragging and selecting + use better names 2024-06-29 19:46:04 +05:30
ashilkn
e0b2fa5a1b [mob][photos] Improve swipe to select accuracy 2024-06-29 16:45:47 +05:30
ashilkn
a58e9030a0 [mob][photos] Resolve merge conflicts and merge main 2024-06-29 15:36:54 +05:30
ashilkn
1c7fe80663 [mob][photos] Half-working swipe to select 2024-06-29 14:31:05 +05:30
ashilkn
ce701099f0 [mob][photos] Create provider that provides a stream of screen pointer position 2024-06-29 12:11:04 +05:30
6 changed files with 394 additions and 85 deletions

View File

@@ -92,8 +92,12 @@ Future<void> _runInForeground(AdaptiveThemeMode? savedThemeMode) async {
runApp(
AppLock(
builder: (args) =>
EnteApp(_runBackgroundTask, _killBGTask, locale, savedThemeMode),
builder: (args) => EnteApp(
_runBackgroundTask,
_killBGTask,
locale,
savedThemeMode,
),
lockScreen: const LockScreen(),
enabled: Configuration.instance.shouldShowLockScreen(),
locale: locale,

View File

@@ -0,0 +1,138 @@
import "dart:async";
import "package:flutter/widgets.dart";
class PointerProvider extends StatefulWidget {
final Widget child;
const PointerProvider({
super.key,
required this.child,
});
@override
State<PointerProvider> createState() => _PointerProviderState();
}
class _PointerProviderState extends State<PointerProvider> {
late Pointer pointer;
bool _isFingerOnScreenSinceLongPress = false;
@override
void dispose() {
pointer.closeMoveOffsetController();
pointer.closeUpOffsetStreamController();
pointer.closeOnTapStreamController();
pointer.closeOnLongPressStreamController();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Pointer(
child: Builder(
builder: (context) {
pointer = Pointer.of(context);
return GestureDetector(
onTap: () {
pointer.onTapStreamController.add(pointer.pointerPosition);
},
onLongPress: () {
_isFingerOnScreenSinceLongPress = true;
pointer.onLongPressStreamController.add(pointer.pointerPosition);
},
onHorizontalDragUpdate: (details) {
pointer.moveOffsetStreamController.add(details.localPosition);
},
child: Listener(
onPointerMove: (event) {
pointer.pointerPosition = event.localPosition;
//onHorizontalDragUpdate is not called when dragging after
//long press without lifting finger. This is for handling only
//this case.
if (_isFingerOnScreenSinceLongPress &&
(event.localDelta.dx.abs() > 0 &&
event.localDelta.dy.abs() > 0)) {
pointer.moveOffsetStreamController.add(event.localPosition);
}
},
onPointerDown: (event) {
pointer.pointerPosition = event.localPosition;
},
onPointerUp: (event) {
_isFingerOnScreenSinceLongPress = false;
pointer.upOffsetStreamController.add(event.localPosition);
},
child: widget.child,
),
);
},
),
);
}
}
class Pointer extends InheritedWidget {
Pointer({super.key, required super.child});
//This is a List<Offset> instead of just and Offset is so that it can be final
//and still be mutable. Need to have this as final to keep Pointer immutable
//which is recommended for inherited widgets.
final _pointerPosition =
List.generate(1, (_) => Offset.zero, growable: false);
Offset get pointerPosition => _pointerPosition[0];
set pointerPosition(Offset offset) {
_pointerPosition[0] = offset;
}
final StreamController<Offset> onTapStreamController =
StreamController.broadcast();
final StreamController<Offset> onLongPressStreamController =
StreamController.broadcast();
final StreamController<Offset> moveOffsetStreamController =
StreamController.broadcast();
final StreamController<Offset> upOffsetStreamController =
StreamController.broadcast();
Future<dynamic> closeOnTapStreamController() {
debugPrint("dragToSelect: Closing onTapStreamController");
return onTapStreamController.close();
}
Future<dynamic> closeOnLongPressStreamController() {
debugPrint("dragToSelect: Closing onLongPressStreamController");
return onLongPressStreamController.close();
}
Future<dynamic> closeMoveOffsetController() {
debugPrint("dragToSelect: Closing moveOffsetStreamController");
return moveOffsetStreamController.close();
}
Future<dynamic> closeUpOffsetStreamController() {
debugPrint("dragToSelect: Closing upOffsetStreamController");
return upOffsetStreamController.close();
}
static Pointer? maybeOf(BuildContext context) {
return context.dependOnInheritedWidgetOfExactType<Pointer>();
}
static Pointer of(BuildContext context) {
final Pointer? result = maybeOf(context);
assert(result != null, 'No Pointer found in context');
return result!;
}
@override
bool updateShouldNotify(Pointer oldWidget) =>
moveOffsetStreamController != oldWidget.moveOffsetStreamController ||
upOffsetStreamController != oldWidget.upOffsetStreamController ||
onTapStreamController != oldWidget.onTapStreamController ||
onLongPressStreamController != oldWidget.onLongPressStreamController;
}

View File

@@ -1,20 +1,25 @@
import "dart:async";
import "package:flutter/material.dart";
import "package:flutter/services.dart";
import "package:logging/logging.dart";
import "package:media_extension/media_extension.dart";
import "package:media_extension/media_extension_action_types.dart";
import "package:photos/core/constants.dart";
import 'package:photos/models/file/file.dart';
import "package:photos/models/selected_files.dart";
import "package:photos/services/app_lifecycle_service.dart";
import "package:photos/states/pointer_provider.dart";
import "package:photos/theme/ente_theme.dart";
import "package:photos/ui/viewer/file/detail_page.dart";
import "package:photos/ui/viewer/file/thumbnail_widget.dart";
import "package:photos/ui/viewer/gallery/component/group/lazy_group_gallery.dart";
import "package:photos/ui/viewer/gallery/gallery.dart";
import "package:photos/ui/viewer/gallery/state/gallery_context_state.dart";
import "package:photos/utils/file_util.dart";
import "package:photos/utils/navigation_util.dart";
class GalleryFileWidget extends StatelessWidget {
class GalleryFileWidget extends StatefulWidget {
final EnteFile file;
final SelectedFiles? selectedFiles;
final bool limitSelectionToOne;
@@ -35,98 +40,225 @@ class GalleryFileWidget extends StatelessWidget {
super.key,
});
@override
State<GalleryFileWidget> createState() => _GalleryFileWidgetState();
}
class _GalleryFileWidgetState extends State<GalleryFileWidget> {
final _globalKey = GlobalKey();
/// This does not always hold the correct value. This is used to unselect/select
/// photos in swipe selection. It hold the right values during swipe selection
/// so, it works fine for what it is used for.
/// This can hold incorrect values when during onTap and certain cases of onLongPress.
/// Too get a better idea, make this a ValueNotfier and update the UI when this changes.
bool _pointerInsideBbox = false;
bool _pointerInsideBboxPrevValue = false;
late StreamSubscription<Offset> _pointerPositionStreamSubscription;
late StreamSubscription<Offset> _pointerUpEventStreamSubscription;
late StreamSubscription<Offset> _onTapEventStreamSubscription;
late StreamSubscription<Offset> _onLongPressEventStreamSubscription;
final _logger = Logger("GalleryFileWidget");
@override
void didChangeDependencies() {
super.didChangeDependencies();
if (!widget.limitSelectionToOne) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
try {
final RenderBox? renderBox =
_globalKey.currentContext?.findRenderObject() as RenderBox?;
if (renderBox == null) {
_logger.info("RenderBox is null. Returning.");
return;
}
final groupGalleryGlobalKey =
GroupGalleryGlobalKey.of(context).globalKey;
final RenderBox? groupGalleryRenderBox =
groupGalleryGlobalKey.currentContext?.findRenderObject()
as RenderBox?;
if (groupGalleryRenderBox == null) {
_logger.info("GroupGalleryRenderBox is null. Returning.");
return;
}
final position = renderBox.localToGlobal(
Offset.zero,
ancestor: groupGalleryRenderBox,
);
final size = renderBox.size;
final bbox = Rect.fromLTWH(
position.dx,
position.dy,
size.width,
size.height,
);
_onTapEventStreamSubscription = Pointer.of(context)
.onTapStreamController
.stream
.listen((offset) {
if (bbox.contains(offset)) {
_pointerInsideBbox = true;
widget.limitSelectionToOne
? _onTapWithSelectionLimit(widget.file)
: _onTapNoSelectionLimit(context, widget.file);
}
});
_onLongPressEventStreamSubscription = Pointer.of(context)
.onLongPressStreamController
.stream
.listen((offset) {
if (bbox.contains(offset)) {
_pointerInsideBbox = true;
widget.limitSelectionToOne
? _onLongPressWithSelectionLimit(context, widget.file)
: _onLongPressNoSelectionLimit(context, widget.file);
}
});
_pointerUpEventStreamSubscription = Pointer.of(context)
.upOffsetStreamController
.stream
.listen((event) {
if (bbox.contains(event)) {
if (_pointerInsideBbox) _pointerInsideBbox = false;
}
});
_pointerPositionStreamSubscription =
Pointer.of(context).moveOffsetStreamController.stream.listen(
(event) {
if (widget.selectedFiles?.files.isEmpty ?? true) return;
_pointerInsideBboxPrevValue = _pointerInsideBbox;
if (bbox.contains(event)) {
_pointerInsideBbox = true;
} else {
_pointerInsideBbox = false;
}
if (_pointerInsideBbox == true &&
_pointerInsideBboxPrevValue == false) {
widget.selectedFiles!.toggleSelection(widget.file);
}
},
onError: (e) {
_logger.warning("Error in pointer position subscription", e);
},
onDone: () {
_logger.info("Pointer position subscription done");
},
);
} catch (e) {
_logger.warning("Error in pointer subscription", e);
}
}
});
}
}
@override
void dispose() {
_onTapEventStreamSubscription.cancel();
_pointerPositionStreamSubscription.cancel();
_pointerUpEventStreamSubscription.cancel();
_onLongPressEventStreamSubscription.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
final isFileSelected = selectedFiles?.isFileSelected(file) ?? false;
final isFileSelected =
widget.selectedFiles?.isFileSelected(widget.file) ?? false;
Color selectionColor = Colors.white;
if (isFileSelected && file.isUploaded && file.ownerID != currentUserID) {
if (isFileSelected &&
widget.file.isUploaded &&
widget.file.ownerID != widget.currentUserID) {
final avatarColors = getEnteColorScheme(context).avatarColors;
selectionColor =
avatarColors[(file.ownerID!).remainder(avatarColors.length)];
avatarColors[(widget.file.ownerID!).remainder(avatarColors.length)];
}
final String heroTag = tag + file.tag;
final String heroTag = widget.tag + widget.file.tag;
final Widget thumbnailWidget = ThumbnailWidget(
file,
widget.file,
diskLoadDeferDuration: thumbnailDiskLoadDeferDuration,
serverLoadDeferDuration: thumbnailServerLoadDeferDuration,
shouldShowLivePhotoOverlay: true,
key: Key(heroTag),
thumbnailSize: photoGridSize < photoGridSizeDefault
thumbnailSize: widget.photoGridSize < photoGridSizeDefault
? thumbnailLargeSize
: thumbnailSmallSize,
shouldShowOwnerAvatar: !isFileSelected,
shouldShowVideoDuration: true,
);
return GestureDetector(
onTap: () {
limitSelectionToOne
? _onTapWithSelectionLimit(file)
: _onTapNoSelectionLimit(context, file);
},
onLongPress: () {
limitSelectionToOne
? _onLongPressWithSelectionLimit(context, file)
: _onLongPressNoSelectionLimit(context, file);
},
child: Stack(
clipBehavior: Clip.none,
children: [
ClipRRect(
borderRadius: BorderRadius.circular(1),
child: Hero(
tag: heroTag,
flightShuttleBuilder: (
flightContext,
animation,
flightDirection,
fromHeroContext,
toHeroContext,
) =>
thumbnailWidget,
transitionOnUserGestures: true,
child: isFileSelected
? ColorFiltered(
colorFilter: ColorFilter.mode(
Colors.black.withOpacity(
0.4,
),
BlendMode.darken,
return Stack(
clipBehavior: Clip.none,
children: [
ClipRRect(
key: _globalKey,
borderRadius: BorderRadius.circular(1),
child: Hero(
tag: heroTag,
flightShuttleBuilder: (
flightContext,
animation,
flightDirection,
fromHeroContext,
toHeroContext,
) =>
thumbnailWidget,
transitionOnUserGestures: true,
child: isFileSelected
? ColorFiltered(
colorFilter: ColorFilter.mode(
Colors.black.withOpacity(
0.4,
),
child: thumbnailWidget,
)
: thumbnailWidget,
),
BlendMode.darken,
),
child: thumbnailWidget,
)
: thumbnailWidget,
),
isFileSelected
? Positioned(
right: 4,
top: 4,
child: Icon(
Icons.check_circle_rounded,
size: 20,
color: selectionColor, //same for both themes
),
)
: const SizedBox.shrink(),
],
),
),
isFileSelected
? Positioned(
right: 4,
top: 4,
child: Icon(
Icons.check_circle_rounded,
size: 20,
color: selectionColor, //same for both themes
),
)
: const SizedBox.shrink(),
],
);
}
void _toggleFileSelection(EnteFile file) {
selectedFiles!.toggleSelection(file);
widget.selectedFiles!.toggleSelection(file);
}
void _onTapWithSelectionLimit(EnteFile file) {
if (selectedFiles!.files.isNotEmpty && selectedFiles!.files.first != file) {
selectedFiles!.clearAll();
if (widget.selectedFiles!.files.isNotEmpty &&
widget.selectedFiles!.files.first != file) {
widget.selectedFiles!.clearAll();
}
_toggleFileSelection(file);
}
void _onTapNoSelectionLimit(BuildContext context, EnteFile file) async {
final bool shouldToggleSelection =
(selectedFiles?.files.isNotEmpty ?? false) ||
(widget.selectedFiles?.files.isNotEmpty ?? false) ||
GalleryContextState.of(context)!.inSelectionMode;
if (shouldToggleSelection) {
_toggleFileSelection(file);
@@ -142,7 +274,7 @@ class GalleryFileWidget extends StatelessWidget {
}
void _onLongPressNoSelectionLimit(BuildContext context, EnteFile file) {
if (selectedFiles!.files.isNotEmpty) {
if (widget.selectedFiles!.files.isNotEmpty) {
_routeToDetailPage(file, context);
} else if (AppLifecycleService.instance.mediaExtensionAction.action ==
IntentAction.main) {
@@ -167,10 +299,10 @@ class GalleryFileWidget extends StatelessWidget {
void _routeToDetailPage(EnteFile file, BuildContext context) {
final page = DetailPage(
DetailPageConfiguration(
List.unmodifiable(filesInGroup),
asyncLoader,
filesInGroup.indexOf(file),
tag,
List.unmodifiable(widget.filesInGroup),
widget.asyncLoader,
widget.filesInGroup.indexOf(file),
widget.tag,
sortOrderAsc: GalleryContextState.of(context)!.sortOrderAsc,
),
);

View File

@@ -44,6 +44,7 @@ class _LazyGridViewState extends State<LazyGridView> {
@override
void initState() {
super.initState();
_shouldRender = widget.shouldRender;
_currentUserID = Configuration.instance.getUserID();
widget.selectedFiles?.addListener(_selectedFilesListener);
@@ -53,7 +54,6 @@ class _LazyGridViewState extends State<LazyGridView> {
setState(() {});
}
});
super.initState();
}
@override

View File

@@ -7,6 +7,7 @@ import 'package:photos/core/constants.dart';
import 'package:photos/events/files_updated_event.dart';
import 'package:photos/models/file/file.dart';
import 'package:photos/models/selected_files.dart';
import "package:photos/states/pointer_provider.dart";
import 'package:photos/theme/ente_theme.dart';
import "package:photos/ui/viewer/gallery/component/grid/place_holder_grid_view_widget.dart";
import "package:photos/ui/viewer/gallery/component/group/group_gallery.dart";
@@ -61,6 +62,7 @@ class _LazyGroupGalleryState extends State<LazyGroupGallery> {
late StreamSubscription<FilesUpdatedEvent>? _reloadEventSubscription;
late StreamSubscription<int> _currentIndexSubscription;
bool? _shouldRender;
final _groupGalleryGlobalKey = GlobalKey();
@override
void initState() {
@@ -233,21 +235,29 @@ class _LazyGroupGalleryState extends State<LazyGroupGallery> {
),
],
),
_shouldRender!
? GroupGallery(
photoGridSize: widget.photoGridSize,
files: _filesInGroup,
tag: widget.tag,
asyncLoader: widget.asyncLoader,
selectedFiles: widget.selectedFiles,
limitSelectionToOne: widget.limitSelectionToOne,
)
// todo: perf eval should we have separate PlaceHolder for Groups
// instead of creating a large cached view
: PlaceHolderGridViewWidget(
_filesInGroup.length,
widget.photoGridSize,
),
PointerProvider(
child: GroupGalleryGlobalKey(
globalKey: _groupGalleryGlobalKey,
child: SizedBox(
key: _groupGalleryGlobalKey,
child: _shouldRender!
? GroupGallery(
photoGridSize: widget.photoGridSize,
files: _filesInGroup,
tag: widget.tag,
asyncLoader: widget.asyncLoader,
selectedFiles: widget.selectedFiles,
limitSelectionToOne: widget.limitSelectionToOne,
)
// todo: perf eval should we have separate PlaceHolder for Groups
// instead of creating a large cached view
: PlaceHolderGridViewWidget(
_filesInGroup.length,
widget.photoGridSize,
),
),
),
),
],
);
}
@@ -265,3 +275,27 @@ class _LazyGroupGalleryState extends State<LazyGroupGallery> {
}
}
}
class GroupGalleryGlobalKey extends InheritedWidget {
const GroupGalleryGlobalKey({
super.key,
required this.globalKey,
required super.child,
});
final GlobalKey globalKey;
static GroupGalleryGlobalKey? maybeOf(BuildContext context) {
return context.dependOnInheritedWidgetOfExactType<GroupGalleryGlobalKey>();
}
static GroupGalleryGlobalKey of(BuildContext context) {
final GroupGalleryGlobalKey? result = maybeOf(context);
assert(result != null, 'No GroupGalleryGlobalKey found in context');
return result!;
}
@override
bool updateShouldNotify(GroupGalleryGlobalKey oldWidget) =>
globalKey != oldWidget.globalKey;
}

View File

@@ -15,6 +15,7 @@ class GalleryContextState extends InheritedWidget {
Key? key,
}) : super(key: key, child: child);
//TODO: throw error with message if no GalleryContextState found
static GalleryContextState? of(BuildContext context) {
return context.dependOnInheritedWidgetOfExactType<GalleryContextState>();
}