diff --git a/mobile/lib/modules/home/ui/asset_grid/asset_drag_region.dart b/mobile/lib/modules/home/ui/asset_grid/asset_drag_region.dart new file mode 100644 index 0000000000..cf1de0383b --- /dev/null +++ b/mobile/lib/modules/home/ui/asset_grid/asset_drag_region.dart @@ -0,0 +1,223 @@ +// ignore_for_file: library_private_types_in_public_api +// Based on https://stackoverflow.com/a/52625182 + +import 'dart:async'; + +import 'package:collection/collection.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; + +class AssetDragRegion extends StatefulWidget { + final Widget child; + + final void Function(AssetIndex valueKey)? onStart; + final void Function(AssetIndex valueKey)? onAssetEnter; + final void Function()? onEnd; + final void Function()? onScrollStart; + final void Function(ScrollDirection direction)? onScroll; + + const AssetDragRegion({ + super.key, + required this.child, + this.onStart, + this.onAssetEnter, + this.onEnd, + this.onScrollStart, + this.onScroll, + }); + @override + State createState() => _AssetDragRegionState(); +} + +class _AssetDragRegionState extends State { + late AssetIndex? assetUnderPointer; + late AssetIndex? anchorAsset; + + // Scroll related state + static const double scrollOffset = 0.10; + double? topScrollOffset; + double? bottomScrollOffset; + Timer? scrollTimer; + late bool scrollNotified; + + @override + void initState() { + super.initState(); + assetUnderPointer = null; + anchorAsset = null; + scrollNotified = false; + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + topScrollOffset = null; + bottomScrollOffset = null; + } + + @override + void dispose() { + scrollTimer?.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return RawGestureDetector( + gestures: { + _CustomLongPressGestureRecognizer: GestureRecognizerFactoryWithHandlers< + _CustomLongPressGestureRecognizer>( + () => _CustomLongPressGestureRecognizer(), + _registerCallbacks, + ), + }, + child: widget.child, + ); + } + + void _registerCallbacks(_CustomLongPressGestureRecognizer recognizer) { + recognizer.onLongPressMoveUpdate = (details) => _onLongPressMove(details); + recognizer.onLongPressStart = (details) => _onLongPressStart(details); + recognizer.onLongPressUp = _onLongPressEnd; + recognizer.onLongPressCancel = _onLongPressEnd; + } + + AssetIndex? _getValueKeyAtPositon(Offset position) { + final box = context.findAncestorRenderObjectOfType(); + if (box == null) return null; + + final hitTestResult = BoxHitTestResult(); + final local = box.globalToLocal(position); + if (!box.hitTest(hitTestResult, position: local)) return null; + + return (hitTestResult.path + .firstWhereOrNull((hit) => hit.target is _AssetIndexProxy) + ?.target as _AssetIndexProxy?) + ?.index; + } + + void _onLongPressStart(LongPressStartDetails event) { + /// Calculate widget height and scroll offset when long press starting instead of in [initState] + /// or [didChangeDependencies] as the grid might still be rendering into view to get the actual size + final height = context.size?.height; + if (height != null && + (topScrollOffset == null || bottomScrollOffset == null)) { + topScrollOffset = height * scrollOffset; + bottomScrollOffset = height - topScrollOffset!; + } + + final initialHit = _getValueKeyAtPositon(event.globalPosition); + anchorAsset = initialHit; + if (initialHit == null) return; + + if (anchorAsset != null) { + widget.onStart?.call(anchorAsset!); + } + } + + void _onLongPressEnd() { + scrollNotified = false; + scrollTimer?.cancel(); + widget.onEnd?.call(); + } + + void _onLongPressMove(LongPressMoveUpdateDetails event) { + if (anchorAsset == null) return; + if (topScrollOffset == null || bottomScrollOffset == null) return; + + final currentDy = event.localPosition.dy; + + if (currentDy > bottomScrollOffset!) { + scrollTimer ??= Timer.periodic( + const Duration(milliseconds: 50), + (_) => widget.onScroll?.call(ScrollDirection.forward), + ); + } else if (currentDy < topScrollOffset!) { + scrollTimer ??= Timer.periodic( + const Duration(milliseconds: 50), + (_) => widget.onScroll?.call(ScrollDirection.reverse), + ); + } else { + scrollTimer?.cancel(); + scrollTimer = null; + } + + final currentlyTouchingAsset = _getValueKeyAtPositon(event.globalPosition); + if (currentlyTouchingAsset == null) return; + + if (assetUnderPointer != currentlyTouchingAsset) { + if (!scrollNotified) { + scrollNotified = true; + widget.onScrollStart?.call(); + } + + widget.onAssetEnter?.call(currentlyTouchingAsset); + assetUnderPointer = currentlyTouchingAsset; + } + } +} + +class _CustomLongPressGestureRecognizer extends LongPressGestureRecognizer { + @override + void rejectGesture(int pointer) { + acceptGesture(pointer); + } +} + +// ignore: prefer-single-widget-per-file +class AssetIndexWrapper extends SingleChildRenderObjectWidget { + final int rowIndex; + final int sectionIndex; + + const AssetIndexWrapper({ + required Widget super.child, + required this.rowIndex, + required this.sectionIndex, + super.key, + }); + + @override + _AssetIndexProxy createRenderObject(BuildContext context) { + return _AssetIndexProxy( + index: AssetIndex(rowIndex: rowIndex, sectionIndex: sectionIndex), + ); + } + + @override + void updateRenderObject( + BuildContext context, + _AssetIndexProxy renderObject, + ) { + renderObject.index = + AssetIndex(rowIndex: rowIndex, sectionIndex: sectionIndex); + } +} + +class _AssetIndexProxy extends RenderProxyBox { + AssetIndex index; + + _AssetIndexProxy({ + required this.index, + }); +} + +class AssetIndex { + final int rowIndex; + final int sectionIndex; + + const AssetIndex({ + required this.rowIndex, + required this.sectionIndex, + }); + + @override + bool operator ==(covariant AssetIndex other) { + if (identical(this, other)) return true; + + return other.rowIndex == rowIndex && other.sectionIndex == sectionIndex; + } + + @override + int get hashCode => rowIndex.hashCode ^ sectionIndex.hashCode; +} diff --git a/mobile/lib/modules/home/ui/asset_grid/immich_asset_grid_view.dart b/mobile/lib/modules/home/ui/asset_grid/immich_asset_grid_view.dart index 8a63108167..4c520fe6fc 100644 --- a/mobile/lib/modules/home/ui/asset_grid/immich_asset_grid_view.dart +++ b/mobile/lib/modules/home/ui/asset_grid/immich_asset_grid_view.dart @@ -5,12 +5,15 @@ import 'dart:math'; import 'package:collection/collection.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; import 'package:flutter/services.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/collection_extensions.dart'; import 'package:immich_mobile/modules/asset_viewer/providers/scroll_notifier.provider.dart'; +import 'package:immich_mobile/modules/home/ui/asset_grid/asset_drag_region.dart'; import 'package:immich_mobile/modules/home/ui/asset_grid/thumbnail_image.dart'; import 'package:immich_mobile/modules/home/ui/asset_grid/thumbnail_placeholder.dart'; +import 'package:immich_mobile/modules/home/ui/control_bottom_app_bar.dart'; import 'package:immich_mobile/shared/models/asset.dart'; import 'package:scrollable_positioned_list/scrollable_positioned_list.dart'; @@ -73,6 +76,8 @@ class ImmichAssetGridView extends StatefulWidget { class ImmichAssetGridViewState extends State { final ItemScrollController _itemScrollController = ItemScrollController(); + final ScrollOffsetController _scrollOffsetController = + ScrollOffsetController(); final ItemPositionsListener _itemPositionsListener = ItemPositionsListener.create(); @@ -83,6 +88,12 @@ class ImmichAssetGridViewState extends State { final Set _selectedAssets = LinkedHashSet(equals: (a, b) => a.id == b.id, hashCode: (a) => a.id); + bool _dragging = false; + int? _dragAnchorAssetIndex; + int? _dragAnchorSectionIndex; + final Set _draggedAssets = + HashSet(equals: (a, b) => a.id == b.id, hashCode: (a) => a.id); + Set _getSelectedAssets() { return Set.from(_selectedAssets); } @@ -93,20 +104,26 @@ class ImmichAssetGridViewState extends State { void _selectAssets(List assets) { setState(() { + if (_dragging) { + _draggedAssets.addAll(assets); + } _selectedAssets.addAll(assets); _callSelectionListener(true); }); } void _deselectAssets(List assets) { + final assetsToDeselect = assets.where( + (a) => + widget.canDeselect || + !(widget.preselectedAssets?.contains(a) ?? false), + ); + setState(() { - _selectedAssets.removeAll( - assets.where( - (a) => - widget.canDeselect || - !(widget.preselectedAssets?.contains(a) ?? false), - ), - ); + _selectedAssets.removeAll(assetsToDeselect); + if (_dragging) { + _draggedAssets.removeAll(assetsToDeselect); + } _callSelectionListener(_selectedAssets.isNotEmpty); }); } @@ -114,6 +131,10 @@ class ImmichAssetGridViewState extends State { void _deselectAll() { setState(() { _selectedAssets.clear(); + _dragAnchorAssetIndex = null; + _dragAnchorSectionIndex = null; + _draggedAssets.clear(); + _dragging = false; if (!widget.canDeselect && widget.preselectedAssets != null && widget.preselectedAssets!.isNotEmpty) { @@ -142,6 +163,7 @@ class ImmichAssetGridViewState extends State { showStorageIndicator: widget.showStorageIndicator, selectedAssets: _selectedAssets, selectionActive: widget.selectionActive, + sectionIndex: index, section: section, margin: widget.margin, renderList: widget.renderList, @@ -199,6 +221,7 @@ class ImmichAssetGridViewState extends State { itemBuilder: _itemBuilder, itemPositionsListener: _itemPositionsListener, itemScrollController: _itemScrollController, + scrollOffsetController: _scrollOffsetController, itemCount: widget.renderList.elements.length + (widget.topWidget != null ? 1 : 0), addRepaintBoundaries: true, @@ -253,6 +276,7 @@ class ImmichAssetGridViewState extends State { if (widget.visibleItemsListener != null) { _itemPositionsListener.itemPositions.removeListener(_positionListener); } + _itemPositionsListener.itemPositions.removeListener(_hapticsListener); super.dispose(); } @@ -308,6 +332,107 @@ class ImmichAssetGridViewState extends State { ); } + void _setDragStartIndex(AssetIndex index) { + setState(() { + _dragAnchorAssetIndex = index.rowIndex; + _dragAnchorSectionIndex = index.sectionIndex; + _dragging = true; + }); + } + + void _stopDrag() { + setState(() { + _dragging = false; + _draggedAssets.clear(); + }); + } + + void _dragDragScroll(ScrollDirection direction) { + _scrollOffsetController.animateScroll( + offset: direction == ScrollDirection.forward ? 175 : -175, + duration: const Duration(milliseconds: 125), + ); + } + + void _handleDragAssetEnter(AssetIndex index) { + if (_dragAnchorSectionIndex == null || _dragAnchorAssetIndex == null) { + return; + } + + final dragAnchorSectionIndex = _dragAnchorSectionIndex!; + final dragAnchorAssetIndex = _dragAnchorAssetIndex!; + + late final int startSectionIndex; + late final int startSectionAssetIndex; + late final int endSectionIndex; + late final int endSectionAssetIndex; + + if (index.sectionIndex < dragAnchorSectionIndex) { + startSectionIndex = index.sectionIndex; + startSectionAssetIndex = index.rowIndex; + endSectionIndex = dragAnchorSectionIndex; + endSectionAssetIndex = dragAnchorAssetIndex; + } else if (index.sectionIndex > dragAnchorSectionIndex) { + startSectionIndex = dragAnchorSectionIndex; + startSectionAssetIndex = dragAnchorAssetIndex; + endSectionIndex = index.sectionIndex; + endSectionAssetIndex = index.rowIndex; + } else { + startSectionIndex = dragAnchorSectionIndex; + endSectionIndex = dragAnchorSectionIndex; + + // If same section, assign proper start / end asset Index + if (dragAnchorAssetIndex < index.rowIndex) { + startSectionAssetIndex = dragAnchorAssetIndex; + endSectionAssetIndex = index.rowIndex; + } else { + startSectionAssetIndex = index.rowIndex; + endSectionAssetIndex = dragAnchorAssetIndex; + } + } + + final selectedAssets = {}; + var currentSectionIndex = startSectionIndex; + while (currentSectionIndex < endSectionIndex) { + final section = + widget.renderList.elements.elementAtOrNull(currentSectionIndex); + if (section == null) continue; + + final sectionAssets = + widget.renderList.loadAssets(section.offset, section.count); + + if (currentSectionIndex == startSectionIndex) { + selectedAssets.addAll( + sectionAssets.slice(startSectionAssetIndex, sectionAssets.length), + ); + } else { + selectedAssets.addAll(sectionAssets); + } + + currentSectionIndex += 1; + } + + final section = widget.renderList.elements.elementAtOrNull(endSectionIndex); + if (section != null) { + final sectionAssets = + widget.renderList.loadAssets(section.offset, section.count); + if (startSectionIndex == endSectionIndex) { + selectedAssets.addAll( + sectionAssets.slice(startSectionAssetIndex, endSectionAssetIndex + 1), + ); + } else { + selectedAssets.addAll( + sectionAssets.slice(0, endSectionAssetIndex + 1), + ); + } + } + + _deselectAssets(_draggedAssets.toList()); + _draggedAssets.clear(); + _draggedAssets.addAll(selectedAssets); + _selectAssets(_draggedAssets.toList()); + } + @override Widget build(BuildContext context) { return PopScope( @@ -315,7 +440,16 @@ class ImmichAssetGridViewState extends State { onPopInvoked: (didPop) => !didPop ? _deselectAll() : null, child: Stack( children: [ - _buildAssetGrid(), + AssetDragRegion( + onStart: _setDragStartIndex, + onAssetEnter: _handleDragAssetEnter, + onEnd: _stopDrag, + onScroll: _dragDragScroll, + onScrollStart: () => WidgetsBinding.instance.addPostFrameCallback( + (_) => controlBottomAppBarNotifier.minimize(), + ), + child: _buildAssetGrid(), + ), if (widget.showMultiSelectIndicator && widget.selectionActive) _buildMultiSelectIndicator(), ], @@ -361,6 +495,7 @@ class _PlaceholderRow extends StatelessWidget { /// A section for the render grid class _Section extends StatelessWidget { final RenderAssetGridElement section; + final int sectionIndex; final Set selectedAssets; final bool scrolling; final double margin; @@ -377,6 +512,7 @@ class _Section extends StatelessWidget { const _Section({ required this.section, + required this.sectionIndex, required this.scrolling, required this.margin, required this.assetsPerRow, @@ -435,6 +571,8 @@ class _Section extends StatelessWidget { ) : _AssetRow( key: ValueKey(i), + rowStartIndex: i * assetsPerRow, + sectionIndex: sectionIndex, assets: assetsToRender.nestedSlice( i * assetsPerRow, min((i + 1) * assetsPerRow, section.count), @@ -522,6 +660,8 @@ class _Title extends StatelessWidget { /// The row of assets class _AssetRow extends StatelessWidget { final List assets; + final int rowStartIndex; + final int sectionIndex; final Set selectedAssets; final int absoluteOffset; final double width; @@ -539,6 +679,8 @@ class _AssetRow extends StatelessWidget { const _AssetRow({ super.key, + required this.rowStartIndex, + required this.sectionIndex, required this.assets, required this.absoluteOffset, required this.width, @@ -594,18 +736,22 @@ class _AssetRow extends StatelessWidget { bottom: margin, right: last ? 0.0 : margin, ), - child: ThumbnailImage( - asset: asset, - index: absoluteOffset + index, - loadAsset: renderList.loadAsset, - totalAssets: renderList.totalAssets, - multiselectEnabled: selectionActive, - isSelected: isSelectionActive && selectedAssets.contains(asset), - onSelect: () => onSelect?.call(asset), - onDeselect: () => onDeselect?.call(asset), - showStorageIndicator: showStorageIndicator, - heroOffset: heroOffset, - showStack: showStack, + child: AssetIndexWrapper( + rowIndex: rowStartIndex + index, + sectionIndex: sectionIndex, + child: ThumbnailImage( + asset: asset, + index: absoluteOffset + index, + loadAsset: renderList.loadAsset, + totalAssets: renderList.totalAssets, + multiselectEnabled: selectionActive, + isSelected: isSelectionActive && selectedAssets.contains(asset), + onSelect: () => onSelect?.call(asset), + onDeselect: () => onDeselect?.call(asset), + showStorageIndicator: showStorageIndicator, + heroOffset: heroOffset, + showStack: showStack, + ), ), ); }).toList(), diff --git a/mobile/lib/modules/home/ui/control_bottom_app_bar.dart b/mobile/lib/modules/home/ui/control_bottom_app_bar.dart index 2a2843089e..23bb2ed61e 100644 --- a/mobile/lib/modules/home/ui/control_bottom_app_bar.dart +++ b/mobile/lib/modules/home/ui/control_bottom_app_bar.dart @@ -1,5 +1,6 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/modules/album/providers/album.provider.dart'; @@ -11,8 +12,17 @@ import 'package:immich_mobile/modules/home/ui/upload_dialog.dart'; import 'package:immich_mobile/shared/providers/server_info.provider.dart'; import 'package:immich_mobile/shared/ui/drag_sheet.dart'; import 'package:immich_mobile/shared/models/album.dart'; +import 'package:immich_mobile/utils/draggable_scroll_controller.dart'; -class ControlBottomAppBar extends ConsumerWidget { +final controlBottomAppBarNotifier = ControlBottomAppBarNotifier(); + +class ControlBottomAppBarNotifier with ChangeNotifier { + void minimize() { + notifyListeners(); + } +} + +class ControlBottomAppBar extends HookConsumerWidget { final void Function(bool shareLocal) onShare; final void Function()? onFavorite; final void Function()? onArchive; @@ -64,6 +74,25 @@ class ControlBottomAppBar extends ConsumerWidget { final albums = ref.watch(albumProvider).where((a) => a.isRemote).toList(); final sharedAlbums = ref.watch(sharedAlbumProvider); const bottomPadding = 0.20; + final scrollController = useDraggableScrollController(); + + void minimize() { + scrollController.animateTo( + bottomPadding, + duration: const Duration(milliseconds: 300), + curve: Curves.easeOut, + ); + } + + useEffect( + () { + controlBottomAppBarNotifier.addListener(minimize); + return () { + controlBottomAppBarNotifier.removeListener(minimize); + }; + }, + [], + ); void showForceDeleteDialog( Function(bool) deleteCb, { @@ -242,6 +271,7 @@ class ControlBottomAppBar extends ConsumerWidget { } return DraggableScrollableSheet( + controller: scrollController, initialChildSize: hasRemote ? 0.35 : bottomPadding, minChildSize: bottomPadding, maxChildSize: hasRemote ? 0.65 : bottomPadding,