Compare commits
21 Commits
testing-fe
...
swipe_to_s
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
933a56ab8f | ||
|
|
ffaae58abd | ||
|
|
cf56b7e057 | ||
|
|
570871a1f0 | ||
|
|
e71f40098f | ||
|
|
c45bbdda14 | ||
|
|
e0ff71828a | ||
|
|
28d9775203 | ||
|
|
c70c6ac617 | ||
|
|
28107cc7ea | ||
|
|
8711753d6f | ||
|
|
b68d11ffbc | ||
|
|
ac0235a6be | ||
|
|
8116b05a9d | ||
|
|
3f49395ee2 | ||
|
|
2d80ed7332 | ||
|
|
5ca0be9f2b | ||
|
|
e0b2fa5a1b | ||
|
|
a58e9030a0 | ||
|
|
1c7fe80663 | ||
|
|
ce701099f0 |
@@ -93,8 +93,12 @@ Future<void> _runInForeground(AdaptiveThemeMode? savedThemeMode) async {
|
|||||||
|
|
||||||
runApp(
|
runApp(
|
||||||
AppLock(
|
AppLock(
|
||||||
builder: (args) =>
|
builder: (args) => EnteApp(
|
||||||
EnteApp(_runBackgroundTask, _killBGTask, locale, savedThemeMode),
|
_runBackgroundTask,
|
||||||
|
_killBGTask,
|
||||||
|
locale,
|
||||||
|
savedThemeMode,
|
||||||
|
),
|
||||||
lockScreen: const LockScreen(),
|
lockScreen: const LockScreen(),
|
||||||
enabled: await Configuration.instance.shouldShowLockScreen(),
|
enabled: await Configuration.instance.shouldShowLockScreen(),
|
||||||
locale: locale,
|
locale: locale,
|
||||||
|
|||||||
@@ -30,6 +30,18 @@ class SelectedFiles extends ChangeNotifier {
|
|||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void toggleFilesSelection(Set<EnteFile> filesToToggle) {
|
||||||
|
final filesToUnselect = files.intersection(filesToToggle);
|
||||||
|
final filesToSelect = filesToToggle.difference(filesToUnselect);
|
||||||
|
|
||||||
|
//remove the files that are already selected
|
||||||
|
files.removeAll(filesToToggle);
|
||||||
|
files.addAll(filesToSelect);
|
||||||
|
lastSelectionOperationFiles.clear();
|
||||||
|
lastSelectionOperationFiles.addAll(filesToSelect..addAll(filesToUnselect));
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
void toggleGroupSelection(Set<EnteFile> filesToToggle) {
|
void toggleGroupSelection(Set<EnteFile> filesToToggle) {
|
||||||
if (files.containsAll(filesToToggle)) {
|
if (files.containsAll(filesToToggle)) {
|
||||||
unSelectAll(filesToToggle);
|
unSelectAll(filesToToggle);
|
||||||
@@ -38,11 +50,13 @@ class SelectedFiles extends ChangeNotifier {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void selectAll(Set<EnteFile> filesToSelect) {
|
void selectAll(Set<EnteFile> filesToSelect, {bool skipNotify = false}) {
|
||||||
files.addAll(filesToSelect);
|
files.addAll(filesToSelect);
|
||||||
lastSelectionOperationFiles.clear();
|
lastSelectionOperationFiles.clear();
|
||||||
lastSelectionOperationFiles.addAll(filesToSelect);
|
lastSelectionOperationFiles.addAll(filesToSelect);
|
||||||
notifyListeners();
|
if (!skipNotify) {
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void unSelectAll(Set<EnteFile> filesToUnselect, {bool skipNotify = false}) {
|
void unSelectAll(Set<EnteFile> filesToUnselect, {bool skipNotify = false}) {
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
|
import "dart:async";
|
||||||
import 'dart:math' show max;
|
import 'dart:math' show max;
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:photos/ui/huge_listview/draggable_scrollbar.dart';
|
import 'package:photos/ui/huge_listview/draggable_scrollbar.dart';
|
||||||
|
import "package:photos/ui/viewer/gallery/component/multiple_groups_gallery_view.dart";
|
||||||
|
import "package:photos/utils/debouncer.dart";
|
||||||
import 'package:scrollable_positioned_list/scrollable_positioned_list.dart';
|
import 'package:scrollable_positioned_list/scrollable_positioned_list.dart';
|
||||||
|
|
||||||
typedef HugeListViewItemBuilder<T> = Widget Function(
|
typedef HugeListViewItemBuilder<T> = Widget Function(
|
||||||
@@ -94,6 +97,14 @@ class HugeListViewState<T> extends State<HugeListView<T>> {
|
|||||||
final listener = ItemPositionsListener.create();
|
final listener = ItemPositionsListener.create();
|
||||||
int lastIndexJump = -1;
|
int lastIndexJump = -1;
|
||||||
dynamic error;
|
dynamic error;
|
||||||
|
final scrollOffsetController = ScrollOffsetController();
|
||||||
|
final scrollController = ScrollController();
|
||||||
|
double currentScrollOffset = 0.0;
|
||||||
|
StreamSubscription<double>? shouldScrollGalleryEventSubscription;
|
||||||
|
final debouncer = Debouncer(
|
||||||
|
const Duration(milliseconds: 100),
|
||||||
|
executionInterval: const Duration(milliseconds: 100),
|
||||||
|
);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -104,9 +115,32 @@ class HugeListViewState<T> extends State<HugeListView<T>> {
|
|||||||
: null;
|
: null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didChangeDependencies() {
|
||||||
|
super.didChangeDependencies();
|
||||||
|
if (shouldScrollGalleryEventSubscription != null) {
|
||||||
|
shouldScrollGalleryEventSubscription!.cancel();
|
||||||
|
}
|
||||||
|
shouldScrollGalleryEventSubscription =
|
||||||
|
SwipeToSelectGalleryScroll.of(context)
|
||||||
|
.streamController
|
||||||
|
.stream
|
||||||
|
.listen((factor) {
|
||||||
|
debouncer.run(() async {
|
||||||
|
final double newOffset = (factor * 70);
|
||||||
|
await scrollOffsetController.animateScroll(
|
||||||
|
offset: newOffset,
|
||||||
|
duration: const Duration(milliseconds: 100),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
listener.itemPositions.removeListener(_sendScroll);
|
listener.itemPositions.removeListener(_sendScroll);
|
||||||
|
shouldScrollGalleryEventSubscription?.cancel();
|
||||||
|
SwipeToSelectGalleryScroll.of(context).streamController.close();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -136,61 +170,69 @@ class HugeListViewState<T> extends State<HugeListView<T>> {
|
|||||||
return widget.emptyResultBuilder!(context);
|
return widget.emptyResultBuilder!(context);
|
||||||
}
|
}
|
||||||
|
|
||||||
return widget.isScrollablePositionedList
|
return NotificationListener<ScrollNotification>(
|
||||||
? DraggableScrollbar(
|
onNotification: (notification) {
|
||||||
key: scrollKey,
|
currentScrollOffset = notification.metrics.pixels;
|
||||||
totalCount: widget.totalCount,
|
return false;
|
||||||
initialScrollIndex: widget.startIndex,
|
},
|
||||||
onChange: (position) {
|
child: widget.isScrollablePositionedList
|
||||||
final int currentIndex = _currentFirst();
|
? DraggableScrollbar(
|
||||||
final int floorIndex = (position * widget.totalCount).floor();
|
key: scrollKey,
|
||||||
final int cielIndex = (position * widget.totalCount).ceil();
|
totalCount: widget.totalCount,
|
||||||
int nextIndexToJump;
|
|
||||||
if (floorIndex != currentIndex && floorIndex > currentIndex) {
|
|
||||||
nextIndexToJump = floorIndex;
|
|
||||||
} else if (cielIndex != currentIndex &&
|
|
||||||
cielIndex < currentIndex) {
|
|
||||||
nextIndexToJump = floorIndex;
|
|
||||||
} else {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (lastIndexJump != nextIndexToJump) {
|
|
||||||
lastIndexJump = nextIndexToJump;
|
|
||||||
widget.controller?.jumpTo(index: nextIndexToJump);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
labelTextBuilder: widget.labelTextBuilder,
|
|
||||||
backgroundColor: widget.thumbBackgroundColor,
|
|
||||||
drawColor: widget.thumbDrawColor,
|
|
||||||
heightScrollThumb: widget.thumbHeight,
|
|
||||||
bottomSafeArea: widget.bottomSafeArea,
|
|
||||||
currentFirstIndex: _currentFirst(),
|
|
||||||
isEnabled: widget.isDraggableScrollbarEnabled,
|
|
||||||
padding: widget.thumbPadding,
|
|
||||||
child: ScrollablePositionedList.builder(
|
|
||||||
physics: widget.disableScroll
|
|
||||||
? const NeverScrollableScrollPhysics()
|
|
||||||
: const BouncingScrollPhysics(),
|
|
||||||
itemScrollController: widget.controller,
|
|
||||||
itemPositionsListener: listener,
|
|
||||||
initialScrollIndex: widget.startIndex,
|
initialScrollIndex: widget.startIndex,
|
||||||
|
onChange: (position) {
|
||||||
|
final int currentIndex = _currentFirst();
|
||||||
|
final int floorIndex = (position * widget.totalCount).floor();
|
||||||
|
final int cielIndex = (position * widget.totalCount).ceil();
|
||||||
|
int nextIndexToJump;
|
||||||
|
if (floorIndex != currentIndex && floorIndex > currentIndex) {
|
||||||
|
nextIndexToJump = floorIndex;
|
||||||
|
} else if (cielIndex != currentIndex &&
|
||||||
|
cielIndex < currentIndex) {
|
||||||
|
nextIndexToJump = floorIndex;
|
||||||
|
} else {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (lastIndexJump != nextIndexToJump) {
|
||||||
|
lastIndexJump = nextIndexToJump;
|
||||||
|
widget.controller?.jumpTo(index: nextIndexToJump);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
labelTextBuilder: widget.labelTextBuilder,
|
||||||
|
backgroundColor: widget.thumbBackgroundColor,
|
||||||
|
drawColor: widget.thumbDrawColor,
|
||||||
|
heightScrollThumb: widget.thumbHeight,
|
||||||
|
bottomSafeArea: widget.bottomSafeArea,
|
||||||
|
currentFirstIndex: _currentFirst(),
|
||||||
|
isEnabled: widget.isDraggableScrollbarEnabled,
|
||||||
|
padding: widget.thumbPadding,
|
||||||
|
child: ScrollablePositionedList.builder(
|
||||||
|
physics: widget.disableScroll
|
||||||
|
? const NeverScrollableScrollPhysics()
|
||||||
|
: const BouncingScrollPhysics(),
|
||||||
|
scrollOffsetController: scrollOffsetController,
|
||||||
|
itemScrollController: widget.controller,
|
||||||
|
itemPositionsListener: listener,
|
||||||
|
initialScrollIndex: widget.startIndex,
|
||||||
|
itemCount: max(widget.totalCount, 0),
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
return ExcludeSemantics(
|
||||||
|
child: widget.itemBuilder(context, index),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: ListView.builder(
|
||||||
|
physics: const BouncingScrollPhysics(),
|
||||||
itemCount: max(widget.totalCount, 0),
|
itemCount: max(widget.totalCount, 0),
|
||||||
|
controller: scrollController,
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
return ExcludeSemantics(
|
return ExcludeSemantics(
|
||||||
child: widget.itemBuilder(context, index),
|
child: widget.itemBuilder(context, index),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
)
|
);
|
||||||
: ListView.builder(
|
|
||||||
physics: const BouncingScrollPhysics(),
|
|
||||||
itemCount: max(widget.totalCount, 0),
|
|
||||||
itemBuilder: (context, index) {
|
|
||||||
return ExcludeSemantics(
|
|
||||||
child: widget.itemBuilder(context, index),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Jump to the [position] in the list. [position] is between 0.0 (first item) and 1.0 (last item), practically currentIndex / totalCount.
|
/// Jump to the [position] in the list. [position] is between 0.0 (first item) and 1.0 (last item), practically currentIndex / totalCount.
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
|
import "dart:async";
|
||||||
|
|
||||||
import "package:flutter/material.dart";
|
import "package:flutter/material.dart";
|
||||||
import "package:flutter/services.dart";
|
import "package:flutter/services.dart";
|
||||||
|
import "package:logging/logging.dart";
|
||||||
import "package:media_extension/media_extension.dart";
|
import "package:media_extension/media_extension.dart";
|
||||||
import "package:media_extension/media_extension_action_types.dart";
|
import "package:media_extension/media_extension_action_types.dart";
|
||||||
import "package:photos/core/constants.dart";
|
import "package:photos/core/constants.dart";
|
||||||
@@ -9,12 +12,14 @@ import "package:photos/services/app_lifecycle_service.dart";
|
|||||||
import "package:photos/theme/ente_theme.dart";
|
import "package:photos/theme/ente_theme.dart";
|
||||||
import "package:photos/ui/viewer/file/detail_page.dart";
|
import "package:photos/ui/viewer/file/detail_page.dart";
|
||||||
import "package:photos/ui/viewer/file/thumbnail_widget.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/gallery.dart";
|
||||||
import "package:photos/ui/viewer/gallery/state/gallery_context_state.dart";
|
import "package:photos/ui/viewer/gallery/state/gallery_context_state.dart";
|
||||||
|
import "package:photos/ui/viewer/gallery/swipe_to_select_helper.dart";
|
||||||
import "package:photos/utils/file_util.dart";
|
import "package:photos/utils/file_util.dart";
|
||||||
import "package:photos/utils/navigation_util.dart";
|
import "package:photos/utils/navigation_util.dart";
|
||||||
|
|
||||||
class GalleryFileWidget extends StatelessWidget {
|
class GalleryFileWidget extends StatefulWidget {
|
||||||
final EnteFile file;
|
final EnteFile file;
|
||||||
final SelectedFiles? selectedFiles;
|
final SelectedFiles? selectedFiles;
|
||||||
final bool limitSelectionToOne;
|
final bool limitSelectionToOne;
|
||||||
@@ -35,98 +40,232 @@ class GalleryFileWidget extends StatelessWidget {
|
|||||||
super.key,
|
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 = SelectionGesturesEvent.of(context)
|
||||||
|
.onTapStreamController
|
||||||
|
.stream
|
||||||
|
.listen((offset) {
|
||||||
|
if (bbox.contains(offset)) {
|
||||||
|
_pointerInsideBbox = true;
|
||||||
|
widget.limitSelectionToOne
|
||||||
|
? _onTapWithSelectionLimit(widget.file)
|
||||||
|
: _onTapNoSelectionLimit(context, widget.file);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
_onLongPressEventStreamSubscription =
|
||||||
|
SelectionGesturesEvent.of(context)
|
||||||
|
.onLongPressStreamController
|
||||||
|
.stream
|
||||||
|
.listen((offset) {
|
||||||
|
if (bbox.contains(offset)) {
|
||||||
|
_pointerInsideBbox = true;
|
||||||
|
widget.limitSelectionToOne
|
||||||
|
? _onLongPressWithSelectionLimit(context, widget.file)
|
||||||
|
: _onLongPressNoSelectionLimit(context, widget.file);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
_pointerUpEventStreamSubscription =
|
||||||
|
SelectionGesturesEvent.of(context)
|
||||||
|
.upOffsetStreamController
|
||||||
|
.stream
|
||||||
|
.listen((event) {
|
||||||
|
if (bbox.contains(event)) {
|
||||||
|
if (_pointerInsideBbox) _pointerInsideBbox = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
_pointerPositionStreamSubscription =
|
||||||
|
SelectionGesturesEvent.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);
|
||||||
|
LastSelectedFileByDragging.of(context)
|
||||||
|
.updateLastSelectedFile(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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final isFileSelected = selectedFiles?.isFileSelected(file) ?? false;
|
final isFileSelected =
|
||||||
|
widget.selectedFiles?.isFileSelected(widget.file) ?? false;
|
||||||
Color selectionColor = Colors.white;
|
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;
|
final avatarColors = getEnteColorScheme(context).avatarColors;
|
||||||
selectionColor =
|
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(
|
final Widget thumbnailWidget = ThumbnailWidget(
|
||||||
file,
|
widget.file,
|
||||||
diskLoadDeferDuration: thumbnailDiskLoadDeferDuration,
|
diskLoadDeferDuration: thumbnailDiskLoadDeferDuration,
|
||||||
serverLoadDeferDuration: thumbnailServerLoadDeferDuration,
|
serverLoadDeferDuration: thumbnailServerLoadDeferDuration,
|
||||||
shouldShowLivePhotoOverlay: true,
|
shouldShowLivePhotoOverlay: true,
|
||||||
key: Key(heroTag),
|
key: Key(heroTag),
|
||||||
thumbnailSize: photoGridSize < photoGridSizeDefault
|
thumbnailSize: widget.photoGridSize < photoGridSizeDefault
|
||||||
? thumbnailLargeSize
|
? thumbnailLargeSize
|
||||||
: thumbnailSmallSize,
|
: thumbnailSmallSize,
|
||||||
shouldShowOwnerAvatar: !isFileSelected,
|
shouldShowOwnerAvatar: !isFileSelected,
|
||||||
shouldShowVideoDuration: true,
|
shouldShowVideoDuration: true,
|
||||||
);
|
);
|
||||||
return GestureDetector(
|
return Stack(
|
||||||
onTap: () {
|
clipBehavior: Clip.none,
|
||||||
limitSelectionToOne
|
children: [
|
||||||
? _onTapWithSelectionLimit(file)
|
ClipRRect(
|
||||||
: _onTapNoSelectionLimit(context, file);
|
key: _globalKey,
|
||||||
},
|
borderRadius: BorderRadius.circular(1),
|
||||||
onLongPress: () {
|
child: Hero(
|
||||||
limitSelectionToOne
|
tag: heroTag,
|
||||||
? _onLongPressWithSelectionLimit(context, file)
|
flightShuttleBuilder: (
|
||||||
: _onLongPressNoSelectionLimit(context, file);
|
flightContext,
|
||||||
},
|
animation,
|
||||||
child: Stack(
|
flightDirection,
|
||||||
clipBehavior: Clip.none,
|
fromHeroContext,
|
||||||
children: [
|
toHeroContext,
|
||||||
ClipRRect(
|
) =>
|
||||||
borderRadius: BorderRadius.circular(1),
|
thumbnailWidget,
|
||||||
child: Hero(
|
transitionOnUserGestures: true,
|
||||||
tag: heroTag,
|
child: isFileSelected
|
||||||
flightShuttleBuilder: (
|
? ColorFiltered(
|
||||||
flightContext,
|
colorFilter: ColorFilter.mode(
|
||||||
animation,
|
Colors.black.withOpacity(
|
||||||
flightDirection,
|
0.4,
|
||||||
fromHeroContext,
|
|
||||||
toHeroContext,
|
|
||||||
) =>
|
|
||||||
thumbnailWidget,
|
|
||||||
transitionOnUserGestures: true,
|
|
||||||
child: isFileSelected
|
|
||||||
? ColorFiltered(
|
|
||||||
colorFilter: ColorFilter.mode(
|
|
||||||
Colors.black.withOpacity(
|
|
||||||
0.4,
|
|
||||||
),
|
|
||||||
BlendMode.darken,
|
|
||||||
),
|
),
|
||||||
child: thumbnailWidget,
|
BlendMode.darken,
|
||||||
)
|
),
|
||||||
: thumbnailWidget,
|
child: thumbnailWidget,
|
||||||
),
|
)
|
||||||
|
: thumbnailWidget,
|
||||||
),
|
),
|
||||||
isFileSelected
|
),
|
||||||
? Positioned(
|
isFileSelected
|
||||||
right: 4,
|
? Positioned(
|
||||||
top: 4,
|
right: 4,
|
||||||
child: Icon(
|
top: 4,
|
||||||
Icons.check_circle_rounded,
|
child: Icon(
|
||||||
size: 20,
|
Icons.check_circle_rounded,
|
||||||
color: selectionColor, //same for both themes
|
size: 20,
|
||||||
),
|
color: selectionColor, //same for both themes
|
||||||
)
|
),
|
||||||
: const SizedBox.shrink(),
|
)
|
||||||
],
|
: const SizedBox.shrink(),
|
||||||
),
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _toggleFileSelection(EnteFile file) {
|
void _toggleFileSelection(EnteFile file) {
|
||||||
selectedFiles!.toggleSelection(file);
|
widget.selectedFiles!.toggleSelection(file);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _onTapWithSelectionLimit(EnteFile file) {
|
void _onTapWithSelectionLimit(EnteFile file) {
|
||||||
if (selectedFiles!.files.isNotEmpty && selectedFiles!.files.first != file) {
|
if (widget.selectedFiles!.files.isNotEmpty &&
|
||||||
selectedFiles!.clearAll();
|
widget.selectedFiles!.files.first != file) {
|
||||||
|
widget.selectedFiles!.clearAll();
|
||||||
}
|
}
|
||||||
_toggleFileSelection(file);
|
_toggleFileSelection(file);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _onTapNoSelectionLimit(BuildContext context, EnteFile file) async {
|
void _onTapNoSelectionLimit(BuildContext context, EnteFile file) async {
|
||||||
final bool shouldToggleSelection =
|
final bool shouldToggleSelection =
|
||||||
(selectedFiles?.files.isNotEmpty ?? false) ||
|
(widget.selectedFiles?.files.isNotEmpty ?? false) ||
|
||||||
GalleryContextState.of(context)!.inSelectionMode;
|
GalleryContextState.of(context)!.inSelectionMode;
|
||||||
if (shouldToggleSelection) {
|
if (shouldToggleSelection) {
|
||||||
_toggleFileSelection(file);
|
_toggleFileSelection(file);
|
||||||
@@ -142,11 +281,14 @@ class GalleryFileWidget extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _onLongPressNoSelectionLimit(BuildContext context, EnteFile file) {
|
void _onLongPressNoSelectionLimit(BuildContext context, EnteFile file) {
|
||||||
if (selectedFiles!.files.isNotEmpty) {
|
if (widget.selectedFiles!.files.isNotEmpty) {
|
||||||
_routeToDetailPage(file, context);
|
_routeToDetailPage(file, context);
|
||||||
} else if (AppLifecycleService.instance.mediaExtensionAction.action ==
|
} else if (AppLifecycleService.instance.mediaExtensionAction.action ==
|
||||||
IntentAction.main) {
|
IntentAction.main) {
|
||||||
HapticFeedback.lightImpact();
|
HapticFeedback.lightImpact();
|
||||||
|
LastSelectedFileByDragging.of(context).updateLastSelectedFile(
|
||||||
|
widget.file,
|
||||||
|
); // This is used to show the selection box
|
||||||
_toggleFileSelection(file);
|
_toggleFileSelection(file);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -167,10 +309,10 @@ class GalleryFileWidget extends StatelessWidget {
|
|||||||
void _routeToDetailPage(EnteFile file, BuildContext context) {
|
void _routeToDetailPage(EnteFile file, BuildContext context) {
|
||||||
final page = DetailPage(
|
final page = DetailPage(
|
||||||
DetailPageConfiguration(
|
DetailPageConfiguration(
|
||||||
List.unmodifiable(filesInGroup),
|
List.unmodifiable(widget.filesInGroup),
|
||||||
asyncLoader,
|
widget.asyncLoader,
|
||||||
filesInGroup.indexOf(file),
|
widget.filesInGroup.indexOf(file),
|
||||||
tag,
|
widget.tag,
|
||||||
sortOrderAsc: GalleryContextState.of(context)!.sortOrderAsc,
|
sortOrderAsc: GalleryContextState.of(context)!.sortOrderAsc,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ class _LazyGridViewState extends State<LazyGridView> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
|
super.initState();
|
||||||
_shouldRender = widget.shouldRender;
|
_shouldRender = widget.shouldRender;
|
||||||
_currentUserID = Configuration.instance.getUserID();
|
_currentUserID = Configuration.instance.getUserID();
|
||||||
widget.selectedFiles?.addListener(_selectedFilesListener);
|
widget.selectedFiles?.addListener(_selectedFilesListener);
|
||||||
@@ -53,7 +54,6 @@ class _LazyGridViewState extends State<LazyGridView> {
|
|||||||
setState(() {});
|
setState(() {});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
super.initState();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import "package:photos/ui/viewer/gallery/component/group/group_header_widget.dar
|
|||||||
import "package:photos/ui/viewer/gallery/component/group/type.dart";
|
import "package:photos/ui/viewer/gallery/component/group/type.dart";
|
||||||
import 'package:photos/ui/viewer/gallery/gallery.dart';
|
import 'package:photos/ui/viewer/gallery/gallery.dart';
|
||||||
import "package:photos/ui/viewer/gallery/state/gallery_context_state.dart";
|
import "package:photos/ui/viewer/gallery/state/gallery_context_state.dart";
|
||||||
|
import "package:photos/ui/viewer/gallery/swipe_to_select_helper.dart";
|
||||||
|
|
||||||
class LazyGroupGallery extends StatefulWidget {
|
class LazyGroupGallery extends StatefulWidget {
|
||||||
final List<EnteFile> files;
|
final List<EnteFile> files;
|
||||||
@@ -233,21 +234,41 @@ class _LazyGroupGalleryState extends State<LazyGroupGallery> {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
_shouldRender!
|
widget.selectedFiles != null
|
||||||
? GroupGallery(
|
? SwipeToSelectHelper(
|
||||||
photoGridSize: widget.photoGridSize,
|
|
||||||
files: _filesInGroup,
|
files: _filesInGroup,
|
||||||
tag: widget.tag,
|
selectedFiles: widget.selectedFiles!,
|
||||||
asyncLoader: widget.asyncLoader,
|
child: _shouldRender!
|
||||||
selectedFiles: widget.selectedFiles,
|
? GroupGallery(
|
||||||
limitSelectionToOne: widget.limitSelectionToOne,
|
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,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
// todo: perf eval should we have separate PlaceHolder for Groups
|
: _shouldRender!
|
||||||
// instead of creating a large cached view
|
? GroupGallery(
|
||||||
: PlaceHolderGridViewWidget(
|
photoGridSize: widget.photoGridSize,
|
||||||
_filesInGroup.length,
|
files: _filesInGroup,
|
||||||
widget.photoGridSize,
|
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 +286,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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import "dart:async";
|
||||||
|
|
||||||
import "package:flutter/material.dart";
|
import "package:flutter/material.dart";
|
||||||
import "package:intl/intl.dart";
|
import "package:intl/intl.dart";
|
||||||
import "package:logging/logging.dart";
|
import "package:logging/logging.dart";
|
||||||
@@ -69,92 +71,119 @@ class MultipleGroupsGalleryView extends StatelessWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final gType = GalleryContextState.of(context)!.type;
|
final gType = GalleryContextState.of(context)!.type;
|
||||||
return HugeListView<List<EnteFile>>(
|
return SwipeToSelectGalleryScroll(
|
||||||
controller: itemScroller,
|
child: HugeListView<List<EnteFile>>(
|
||||||
startIndex: 0,
|
controller: itemScroller,
|
||||||
totalCount: groupedFiles.length,
|
startIndex: 0,
|
||||||
isDraggableScrollbarEnabled: groupedFiles.length > 10,
|
totalCount: groupedFiles.length,
|
||||||
disableScroll: disableScroll,
|
isDraggableScrollbarEnabled: groupedFiles.length > 10,
|
||||||
isScrollablePositionedList: isScrollablePositionedList,
|
disableScroll: disableScroll,
|
||||||
waitBuilder: (_) {
|
isScrollablePositionedList: isScrollablePositionedList,
|
||||||
return const EnteLoadingWidget();
|
waitBuilder: (_) {
|
||||||
},
|
return const EnteLoadingWidget();
|
||||||
emptyResultBuilder: (_) {
|
},
|
||||||
final List<Widget> children = [];
|
emptyResultBuilder: (_) {
|
||||||
if (header != null) {
|
final List<Widget> children = [];
|
||||||
children.add(header!);
|
if (header != null) {
|
||||||
}
|
children.add(header!);
|
||||||
children.add(
|
|
||||||
Expanded(
|
|
||||||
child: emptyState,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
if (footer != null) {
|
|
||||||
children.add(footer!);
|
|
||||||
}
|
|
||||||
return Column(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
||||||
children: children,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
itemBuilder: (context, index) {
|
|
||||||
Widget gallery;
|
|
||||||
gallery = LazyGroupGallery(
|
|
||||||
groupedFiles[index],
|
|
||||||
index,
|
|
||||||
reloadEvent,
|
|
||||||
removalEventTypes,
|
|
||||||
asyncLoader,
|
|
||||||
selectedFiles,
|
|
||||||
tagPrefix,
|
|
||||||
Bus.instance
|
|
||||||
.on<GalleryIndexUpdatedEvent>()
|
|
||||||
.where((event) => event.tag == tagPrefix)
|
|
||||||
.map((event) => event.index),
|
|
||||||
enableFileGrouping,
|
|
||||||
showSelectAllByDefault,
|
|
||||||
logTag: logTag,
|
|
||||||
photoGridSize: LocalSettings.instance.getPhotoGridSize(),
|
|
||||||
limitSelectionToOne: limitSelectionToOne,
|
|
||||||
);
|
|
||||||
if (header != null && index == 0) {
|
|
||||||
gallery = Column(children: [header!, gallery]);
|
|
||||||
}
|
|
||||||
if (footer != null && index == groupedFiles.length - 1) {
|
|
||||||
gallery = Column(children: [gallery, footer!]);
|
|
||||||
}
|
|
||||||
return gallery;
|
|
||||||
},
|
|
||||||
labelTextBuilder: (int index) {
|
|
||||||
try {
|
|
||||||
final EnteFile file = groupedFiles[index][0];
|
|
||||||
if (gType == GroupType.size) {
|
|
||||||
return file.fileSize != null
|
|
||||||
? convertBytesToReadableFormat(file.fileSize!)
|
|
||||||
: "";
|
|
||||||
}
|
}
|
||||||
|
children.add(
|
||||||
return DateFormat.yMMM(Localizations.localeOf(context).languageCode)
|
Expanded(
|
||||||
.format(
|
child: emptyState,
|
||||||
DateTime.fromMicrosecondsSinceEpoch(
|
|
||||||
file.creationTime!,
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
} catch (e) {
|
if (footer != null) {
|
||||||
logger.severe("label text builder failed", e);
|
children.add(footer!);
|
||||||
return "";
|
}
|
||||||
}
|
return Column(
|
||||||
},
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
thumbBackgroundColor:
|
children: children,
|
||||||
Theme.of(context).colorScheme.galleryThumbBackgroundColor,
|
);
|
||||||
thumbDrawColor: Theme.of(context).colorScheme.galleryThumbDrawColor,
|
},
|
||||||
thumbPadding: header != null
|
itemBuilder: (context, index) {
|
||||||
? const EdgeInsets.only(top: 60)
|
Widget gallery;
|
||||||
: const EdgeInsets.all(0),
|
gallery = LazyGroupGallery(
|
||||||
bottomSafeArea: scrollBottomSafeArea,
|
groupedFiles[index],
|
||||||
firstShown: (int firstIndex) {
|
index,
|
||||||
Bus.instance.fire(GalleryIndexUpdatedEvent(tagPrefix, firstIndex));
|
reloadEvent,
|
||||||
},
|
removalEventTypes,
|
||||||
|
asyncLoader,
|
||||||
|
selectedFiles,
|
||||||
|
tagPrefix,
|
||||||
|
Bus.instance
|
||||||
|
.on<GalleryIndexUpdatedEvent>()
|
||||||
|
.where((event) => event.tag == tagPrefix)
|
||||||
|
.map((event) => event.index),
|
||||||
|
enableFileGrouping,
|
||||||
|
showSelectAllByDefault,
|
||||||
|
logTag: logTag,
|
||||||
|
photoGridSize: LocalSettings.instance.getPhotoGridSize(),
|
||||||
|
limitSelectionToOne: limitSelectionToOne,
|
||||||
|
);
|
||||||
|
if (header != null && index == 0) {
|
||||||
|
gallery = Column(children: [header!, gallery]);
|
||||||
|
}
|
||||||
|
if (footer != null && index == groupedFiles.length - 1) {
|
||||||
|
gallery = Column(children: [gallery, footer!]);
|
||||||
|
}
|
||||||
|
return gallery;
|
||||||
|
},
|
||||||
|
labelTextBuilder: (int index) {
|
||||||
|
try {
|
||||||
|
final EnteFile file = groupedFiles[index][0];
|
||||||
|
if (gType == GroupType.size) {
|
||||||
|
return file.fileSize != null
|
||||||
|
? convertBytesToReadableFormat(file.fileSize!)
|
||||||
|
: "";
|
||||||
|
}
|
||||||
|
|
||||||
|
return DateFormat.yMMM(Localizations.localeOf(context).languageCode)
|
||||||
|
.format(
|
||||||
|
DateTime.fromMicrosecondsSinceEpoch(
|
||||||
|
file.creationTime!,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
logger.severe("label text builder failed", e);
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
},
|
||||||
|
thumbBackgroundColor:
|
||||||
|
Theme.of(context).colorScheme.galleryThumbBackgroundColor,
|
||||||
|
thumbDrawColor: Theme.of(context).colorScheme.galleryThumbDrawColor,
|
||||||
|
thumbPadding: header != null
|
||||||
|
? const EdgeInsets.only(top: 60)
|
||||||
|
: const EdgeInsets.all(0),
|
||||||
|
bottomSafeArea: scrollBottomSafeArea,
|
||||||
|
firstShown: (int firstIndex) {
|
||||||
|
Bus.instance.fire(GalleryIndexUpdatedEvent(tagPrefix, firstIndex));
|
||||||
|
},
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class SwipeToSelectGalleryScroll extends InheritedWidget {
|
||||||
|
final StreamController<double> streamController = StreamController();
|
||||||
|
|
||||||
|
SwipeToSelectGalleryScroll({
|
||||||
|
required Widget child,
|
||||||
|
Key? key,
|
||||||
|
}) : super(key: key, child: child);
|
||||||
|
|
||||||
|
static SwipeToSelectGalleryScroll? maybeOf(BuildContext context) {
|
||||||
|
return context
|
||||||
|
.dependOnInheritedWidgetOfExactType<SwipeToSelectGalleryScroll>();
|
||||||
|
}
|
||||||
|
|
||||||
|
static SwipeToSelectGalleryScroll of(BuildContext context) {
|
||||||
|
final SwipeToSelectGalleryScroll? result = maybeOf(context);
|
||||||
|
assert(result != null, 'No SwipeToSelectGalleryScroll found in context');
|
||||||
|
return result!;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool updateShouldNotify(SwipeToSelectGalleryScroll oldWidget) {
|
||||||
|
return streamController != oldWidget.streamController;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ class GalleryContextState extends InheritedWidget {
|
|||||||
Key? key,
|
Key? key,
|
||||||
}) : super(key: key, child: child);
|
}) : super(key: key, child: child);
|
||||||
|
|
||||||
|
//TODO: throw error with message if no GalleryContextState found
|
||||||
static GalleryContextState? of(BuildContext context) {
|
static GalleryContextState? of(BuildContext context) {
|
||||||
return context.dependOnInheritedWidgetOfExactType<GalleryContextState>();
|
return context.dependOnInheritedWidgetOfExactType<GalleryContextState>();
|
||||||
}
|
}
|
||||||
|
|||||||
319
mobile/lib/ui/viewer/gallery/swipe_to_select_helper.dart
Normal file
319
mobile/lib/ui/viewer/gallery/swipe_to_select_helper.dart
Normal file
@@ -0,0 +1,319 @@
|
|||||||
|
import "dart:async";
|
||||||
|
|
||||||
|
import "package:flutter/material.dart";
|
||||||
|
import "package:logging/logging.dart";
|
||||||
|
import "package:photos/models/file/file.dart";
|
||||||
|
import "package:photos/models/selected_files.dart";
|
||||||
|
import "package:photos/ui/viewer/gallery/component/group/lazy_group_gallery.dart";
|
||||||
|
import "package:photos/ui/viewer/gallery/component/multiple_groups_gallery_view.dart";
|
||||||
|
|
||||||
|
class SwipeToSelectHelper extends StatefulWidget {
|
||||||
|
final List<EnteFile> files;
|
||||||
|
final SelectedFiles selectedFiles;
|
||||||
|
final Widget child;
|
||||||
|
const SwipeToSelectHelper({
|
||||||
|
required this.files,
|
||||||
|
required this.selectedFiles,
|
||||||
|
required this.child,
|
||||||
|
super.key,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<SwipeToSelectHelper> createState() => _SwipeToSelectHelperState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _SwipeToSelectHelperState extends State<SwipeToSelectHelper> {
|
||||||
|
final _groupGalleryGlobalKey = GlobalKey();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return LastSelectedFileByDragging(
|
||||||
|
filesInGroup: widget.files,
|
||||||
|
child: Builder(
|
||||||
|
builder: (context) {
|
||||||
|
return SelectionGesturesEventProvider(
|
||||||
|
selectedFiles: widget.selectedFiles,
|
||||||
|
files: widget.files,
|
||||||
|
child: GroupGalleryGlobalKey(
|
||||||
|
globalKey: _groupGalleryGlobalKey,
|
||||||
|
child: SizedBox(
|
||||||
|
key: _groupGalleryGlobalKey,
|
||||||
|
child: widget.child,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class LastSelectedFileByDragging extends InheritedWidget {
|
||||||
|
///Check if this should updates on didUpdateWidget. If so, use a state varaible
|
||||||
|
///and update it there on didUpdateWidget.
|
||||||
|
final List<EnteFile> filesInGroup;
|
||||||
|
LastSelectedFileByDragging({
|
||||||
|
super.key,
|
||||||
|
required this.filesInGroup,
|
||||||
|
required super.child,
|
||||||
|
});
|
||||||
|
|
||||||
|
final _indexInGroup = ValueNotifier<int>(-1);
|
||||||
|
|
||||||
|
void updateLastSelectedFile(EnteFile file) {
|
||||||
|
_indexInGroup.value = filesInGroup.indexOf(file);
|
||||||
|
}
|
||||||
|
|
||||||
|
ValueNotifier<int> get index => _indexInGroup;
|
||||||
|
|
||||||
|
static LastSelectedFileByDragging? maybeOf(BuildContext context) {
|
||||||
|
return context
|
||||||
|
.dependOnInheritedWidgetOfExactType<LastSelectedFileByDragging>();
|
||||||
|
}
|
||||||
|
|
||||||
|
static LastSelectedFileByDragging of(BuildContext context) {
|
||||||
|
final LastSelectedFileByDragging? result = maybeOf(context);
|
||||||
|
assert(result != null, 'No LastSelectedFileByDragging found in context');
|
||||||
|
return result!;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool updateShouldNotify(LastSelectedFileByDragging oldWidget) =>
|
||||||
|
_indexInGroup != oldWidget._indexInGroup ||
|
||||||
|
filesInGroup != oldWidget.filesInGroup;
|
||||||
|
}
|
||||||
|
|
||||||
|
class SelectionGesturesEventProvider extends StatefulWidget {
|
||||||
|
final Widget child;
|
||||||
|
final List<EnteFile> files;
|
||||||
|
final SelectedFiles selectedFiles;
|
||||||
|
|
||||||
|
const SelectionGesturesEventProvider({
|
||||||
|
super.key,
|
||||||
|
required this.selectedFiles,
|
||||||
|
required this.files,
|
||||||
|
required this.child,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<SelectionGesturesEventProvider> createState() =>
|
||||||
|
_SelectionGesturesEventProviderState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _SelectionGesturesEventProviderState
|
||||||
|
extends State<SelectionGesturesEventProvider> {
|
||||||
|
late SelectionGesturesEvent selectionGesturesEvent;
|
||||||
|
late SwipeToSelectGalleryScroll swipeToSelectGalleryScroll;
|
||||||
|
bool _isFingerOnScreenSinceLongPress = false;
|
||||||
|
bool _isDragging = false;
|
||||||
|
int prevSelectedFileIndex = -1;
|
||||||
|
int currentSelectedFileIndex = -1;
|
||||||
|
final _logger = Logger("PointerProvider");
|
||||||
|
static const kUpThreshold = 180.0;
|
||||||
|
static const kDownThreshold = 240.0;
|
||||||
|
static const kSelectionSheetBuffer = 120.0;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didChangeDependencies() {
|
||||||
|
super.didChangeDependencies();
|
||||||
|
LastSelectedFileByDragging.of(context)
|
||||||
|
.index
|
||||||
|
.removeListener(swipingToSelectListener);
|
||||||
|
LastSelectedFileByDragging.of(context)._indexInGroup.addListener(
|
||||||
|
swipingToSelectListener,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
selectionGesturesEvent.closeMoveOffsetController();
|
||||||
|
selectionGesturesEvent.closeUpOffsetStreamController();
|
||||||
|
selectionGesturesEvent.closeOnTapStreamController();
|
||||||
|
selectionGesturesEvent.closeOnLongPressStreamController();
|
||||||
|
widget.selectedFiles.removeListener(
|
||||||
|
swipingToSelectListener,
|
||||||
|
);
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void swipingToSelectListener() {
|
||||||
|
prevSelectedFileIndex = currentSelectedFileIndex;
|
||||||
|
currentSelectedFileIndex =
|
||||||
|
LastSelectedFileByDragging.of(context).index.value;
|
||||||
|
if (prevSelectedFileIndex != -1 && currentSelectedFileIndex != -1) {
|
||||||
|
if ((currentSelectedFileIndex - prevSelectedFileIndex).abs() > 1) {
|
||||||
|
late final int startIndex;
|
||||||
|
late final int endIndex;
|
||||||
|
if (currentSelectedFileIndex > prevSelectedFileIndex) {
|
||||||
|
startIndex = prevSelectedFileIndex;
|
||||||
|
endIndex = currentSelectedFileIndex;
|
||||||
|
} else {
|
||||||
|
startIndex = currentSelectedFileIndex;
|
||||||
|
endIndex = prevSelectedFileIndex;
|
||||||
|
}
|
||||||
|
widget.selectedFiles.toggleFilesSelection(
|
||||||
|
widget.files
|
||||||
|
.sublist(
|
||||||
|
startIndex + 1,
|
||||||
|
endIndex,
|
||||||
|
)
|
||||||
|
.toSet(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final heightOfScreen = MediaQuery.sizeOf(context).height;
|
||||||
|
|
||||||
|
return SelectionGesturesEvent(
|
||||||
|
child: Builder(
|
||||||
|
builder: (context) {
|
||||||
|
selectionGesturesEvent = SelectionGesturesEvent.of(context);
|
||||||
|
swipeToSelectGalleryScroll = SwipeToSelectGalleryScroll.of(context);
|
||||||
|
return GestureDetector(
|
||||||
|
onTap: () {
|
||||||
|
selectionGesturesEvent.onTapStreamController
|
||||||
|
.add(selectionGesturesEvent.pointerPosition);
|
||||||
|
},
|
||||||
|
onLongPress: () {
|
||||||
|
_isFingerOnScreenSinceLongPress = true;
|
||||||
|
selectionGesturesEvent.onLongPressStreamController
|
||||||
|
.add(selectionGesturesEvent.pointerPosition);
|
||||||
|
},
|
||||||
|
onHorizontalDragUpdate: (details) {
|
||||||
|
onDragToSelect(details.localPosition);
|
||||||
|
},
|
||||||
|
child: Listener(
|
||||||
|
onPointerMove: (event) {
|
||||||
|
selectionGesturesEvent.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)) {
|
||||||
|
onDragToSelect(event.localPosition);
|
||||||
|
|
||||||
|
sinkScrollEvent(event.position.dy, heightOfScreen);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onPointerDown: (event) {
|
||||||
|
selectionGesturesEvent.pointerPosition = event.localPosition;
|
||||||
|
},
|
||||||
|
onPointerUp: (event) {
|
||||||
|
_isFingerOnScreenSinceLongPress = false;
|
||||||
|
_isDragging = false;
|
||||||
|
selectionGesturesEvent.upOffsetStreamController
|
||||||
|
.add(event.localPosition);
|
||||||
|
|
||||||
|
LastSelectedFileByDragging.of(context).index.value = -1;
|
||||||
|
currentSelectedFileIndex = -1;
|
||||||
|
},
|
||||||
|
child: widget.child,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void onDragToSelect(Offset offset) {
|
||||||
|
selectionGesturesEvent.moveOffsetStreamController.add(offset);
|
||||||
|
_isDragging = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
void sinkScrollEvent(double yGlobalPos, double heightOfScreen) {
|
||||||
|
final pixelsBeyondThresholdDown =
|
||||||
|
yGlobalPos - (heightOfScreen - kDownThreshold);
|
||||||
|
final pixelsBeyondThresholdUp = yGlobalPos - kUpThreshold;
|
||||||
|
|
||||||
|
if (pixelsBeyondThresholdUp < 0) {
|
||||||
|
print(
|
||||||
|
"up with strength: ${pixelsBeyondThresholdUp / kUpThreshold}",
|
||||||
|
);
|
||||||
|
swipeToSelectGalleryScroll.streamController.sink
|
||||||
|
.add(pixelsBeyondThresholdUp / kUpThreshold);
|
||||||
|
}
|
||||||
|
if (pixelsBeyondThresholdDown > 0) {
|
||||||
|
print(
|
||||||
|
"down with strength: ${(pixelsBeyondThresholdDown + kSelectionSheetBuffer) / kDownThreshold}",
|
||||||
|
);
|
||||||
|
swipeToSelectGalleryScroll.streamController.sink.add(
|
||||||
|
(pixelsBeyondThresholdDown + kSelectionSheetBuffer) / kDownThreshold,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class SelectionGesturesEvent extends InheritedWidget {
|
||||||
|
SelectionGesturesEvent({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 SelectionGesturesEvent? maybeOf(BuildContext context) {
|
||||||
|
return context.dependOnInheritedWidgetOfExactType<SelectionGesturesEvent>();
|
||||||
|
}
|
||||||
|
|
||||||
|
static SelectionGesturesEvent of(BuildContext context) {
|
||||||
|
final SelectionGesturesEvent? result = maybeOf(context);
|
||||||
|
assert(result != null, 'No SelectionGesturesEvent found in context');
|
||||||
|
return result!;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool updateShouldNotify(SelectionGesturesEvent oldWidget) =>
|
||||||
|
moveOffsetStreamController != oldWidget.moveOffsetStreamController ||
|
||||||
|
upOffsetStreamController != oldWidget.upOffsetStreamController ||
|
||||||
|
onTapStreamController != oldWidget.onTapStreamController ||
|
||||||
|
onLongPressStreamController != oldWidget.onLongPressStreamController;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user