diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ad950ee9..94e856e3a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +### Added +- Collection: long press and move to select/deselect multiple entries +- Info: show Spherical Video V1 metadata ## [v1.3.0] - 2020-12-26 ### Added diff --git a/lib/widgets/collection/grid/list_section_layout.dart b/lib/widgets/collection/grid/list_section_layout.dart index 34665e056..368b3bbeb 100644 --- a/lib/widgets/collection/grid/list_section_layout.dart +++ b/lib/widgets/collection/grid/list_section_layout.dart @@ -3,6 +3,7 @@ import 'dart:math'; import 'package:aves/model/image_entry.dart'; import 'package:aves/model/source/collection_lens.dart'; import 'package:aves/widgets/collection/grid/header_generic.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; @@ -143,6 +144,25 @@ class SectionedListLayout { final top = sectionLayout.indexToLayoutOffset(listIndex); return Rect.fromLTWH(left, top, tileExtent, tileExtent); } + + ImageEntry getEntryAt(Offset position) { + var dy = position.dy; + final sectionLayout = sectionLayouts.firstWhere((sl) => dy < sl.maxOffset, orElse: () => null); + if (sectionLayout == null) return null; + + final section = collection.sections[sectionLayout.sectionKey]; + if (section == null) return null; + + dy -= sectionLayout.minOffset + sectionLayout.headerExtent; + if (dy < 0) return null; + + final row = dy ~/ tileExtent; + final column = position.dx ~/ tileExtent; + final index = row * columnCount + column; + if (index >= section.length) return null; + + return section[index]; + } } class SectionLayout { @@ -184,4 +204,7 @@ class SectionLayout { scrollOffset -= minOffset + headerExtent; return firstIndex + (scrollOffset < 0 ? 0 : (scrollOffset / tileExtent).ceil() - 1); } + + @override + String toString() => '$runtimeType#${shortHash(this)}{sectionKey=$sectionKey, firstIndex=$firstIndex, lastIndex=$lastIndex, minOffset=$minOffset, maxOffset=$maxOffset, headerExtent=$headerExtent}'; } diff --git a/lib/widgets/collection/grid/list_sliver.dart b/lib/widgets/collection/grid/list_sliver.dart index d01c7800e..9d3189382 100644 --- a/lib/widgets/collection/grid/list_sliver.dart +++ b/lib/widgets/collection/grid/list_sliver.dart @@ -65,11 +65,6 @@ class GridThumbnail extends StatelessWidget { ViewerService.pick(entry.uri); } }, - onLongPress: () { - if (AvesApp.mode == AppMode.main) { - collection.toggleSelection(entry); - } - }, child: MetaData( metaData: ScalerMetadata(entry), child: DecoratedThumbnail( diff --git a/lib/widgets/collection/grid/selector.dart b/lib/widgets/collection/grid/selector.dart new file mode 100644 index 000000000..8e7b5e4a3 --- /dev/null +++ b/lib/widgets/collection/grid/selector.dart @@ -0,0 +1,169 @@ +import 'dart:async'; +import 'dart:math'; + +import 'package:aves/model/image_entry.dart'; +import 'package:aves/model/source/collection_lens.dart'; +import 'package:aves/utils/math_utils.dart'; +import 'package:aves/widgets/collection/grid/list_section_layout.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:provider/provider.dart'; + +class GridSelectionGestureDetector extends StatefulWidget { + final bool selectable; + final CollectionLens collection; + final ScrollController scrollController; + final ValueNotifier appBarHeightNotifier; + final Widget child; + + const GridSelectionGestureDetector({ + this.selectable = true, + @required this.collection, + @required this.scrollController, + @required this.appBarHeightNotifier, + @required this.child, + }); + + @override + _GridSelectionGestureDetectorState createState() => _GridSelectionGestureDetectorState(); +} + +class _GridSelectionGestureDetectorState extends State { + bool _pressing, _selecting; + int _fromIndex, _lastToIndex; + Offset _localPosition; + EdgeInsets _scrollableInsets; + double _scrollSpeedFactor; + Timer _updateTimer; + + CollectionLens get collection => widget.collection; + + List get entries => collection.sortedEntries; + + ScrollController get scrollController => widget.scrollController; + + double get appBarHeight => widget.appBarHeightNotifier.value; + + static const double scrollEdgeRatio = .15; + static const double scrollMaxPixelPerSecond = 600.0; + static const Duration scrollUpdateInterval = Duration(milliseconds: 100); + + @override + Widget build(BuildContext context) { + return GestureDetector( + onLongPressStart: widget.selectable + ? (details) { + final fromEntry = _getEntryAt(details.localPosition); + if (fromEntry == null) return; + + collection.toggleSelection(fromEntry); + _selecting = collection.isSelected([fromEntry]); + _fromIndex = entries.indexOf(fromEntry); + _lastToIndex = _fromIndex; + _scrollableInsets = EdgeInsets.only( + top: appBarHeight, + bottom: context.read().viewInsets.bottom, + ); + _scrollSpeedFactor = 0; + _pressing = true; + } + : null, + onLongPressMoveUpdate: widget.selectable + ? (details) { + if (!_pressing) return; + _localPosition = details.localPosition; + _onLongPressUpdate(); + } + : null, + onLongPressEnd: widget.selectable + ? (details) { + if (!_pressing) return; + _setScrollSpeed(0); + _pressing = false; + } + : null, + child: widget.child, + ); + } + + void _onLongPressUpdate() { + final dy = _localPosition.dy; + + final height = scrollController.position.viewportDimension; + final top = dy < height / 2; + + final distanceToEdge = max(0, top ? dy - _scrollableInsets.top : height - dy - _scrollableInsets.bottom); + final threshold = height * scrollEdgeRatio; + if (distanceToEdge < threshold) { + _setScrollSpeed((top ? -1 : 1) * roundToPrecision((threshold - distanceToEdge) / threshold, decimals: 1)); + } else { + _setScrollSpeed(0); + } + + final toEntry = _getEntryAt(_localPosition); + _toggleSelectionToIndex(entries.indexOf(toEntry)); + } + + void _setScrollSpeed(double speedFactor) { + if (speedFactor == _scrollSpeedFactor) return; + _scrollSpeedFactor = speedFactor; + _updateTimer?.cancel(); + + final current = scrollController.offset; + if (speedFactor == 0) { + scrollController.jumpTo(current); + return; + } + + final target = speedFactor > 0 ? scrollController.position.maxScrollExtent : .0; + if (target != current) { + final distance = target - current; + final millis = distance * 1000 / scrollMaxPixelPerSecond / speedFactor; + scrollController.animateTo( + target, + duration: Duration(milliseconds: millis.round()), + curve: Curves.linear, + ); + // use a timer to update the entry selection, because `onLongPressMoveUpdate` + // is not called when the pointer stays still while the view is scrolling + _updateTimer = Timer.periodic(scrollUpdateInterval, (_) => _onLongPressUpdate()); + } + } + + ImageEntry _getEntryAt(Offset localPosition) { + // as of Flutter v1.22.5, `hitTest` on the `ScrollView` render object works fine when it is static, + // but when it is scrolling (through controller animation), result is incomplete and children are missing, + // so we use custom layout computation instead to find the entry. + final offset = Offset(0, scrollController.offset - appBarHeight) + localPosition; + return context.read().getEntryAt(offset); + } + + void _toggleSelectionToIndex(int toIndex) { + if (toIndex == -1) return; + + if (_selecting) { + if (toIndex <= _fromIndex) { + if (toIndex < _lastToIndex) { + collection.addToSelection(entries.getRange(toIndex, min(_fromIndex, _lastToIndex))); + if (_fromIndex < _lastToIndex) { + collection.removeFromSelection(entries.getRange(_fromIndex + 1, _lastToIndex + 1)); + } + } else if (_lastToIndex < toIndex) { + collection.removeFromSelection(entries.getRange(_lastToIndex, toIndex)); + } + } else if (_fromIndex < toIndex) { + if (_lastToIndex < toIndex) { + collection.addToSelection(entries.getRange(max(_fromIndex, _lastToIndex), toIndex + 1)); + if (_lastToIndex < _fromIndex) { + collection.removeFromSelection(entries.getRange(_lastToIndex, _fromIndex)); + } + } else if (toIndex < _lastToIndex) { + collection.removeFromSelection(entries.getRange(toIndex + 1, _lastToIndex + 1)); + } + } + _lastToIndex = toIndex; + } else { + collection.removeFromSelection(entries.getRange(min(_fromIndex, toIndex), max(_fromIndex, toIndex) + 1)); + } + } +} diff --git a/lib/widgets/collection/thumbnail_collection.dart b/lib/widgets/collection/thumbnail_collection.dart index 59343b08b..d58c2a8ca 100644 --- a/lib/widgets/collection/thumbnail_collection.dart +++ b/lib/widgets/collection/thumbnail_collection.dart @@ -1,5 +1,6 @@ import 'dart:async'; +import 'package:aves/main.dart'; import 'package:aves/model/filters/favourite.dart'; import 'package:aves/model/filters/mime.dart'; import 'package:aves/model/highlight.dart'; @@ -13,6 +14,7 @@ import 'package:aves/widgets/collection/app_bar.dart'; import 'package:aves/widgets/collection/empty.dart'; import 'package:aves/widgets/collection/grid/list_section_layout.dart'; import 'package:aves/widgets/collection/grid/list_sliver.dart'; +import 'package:aves/widgets/collection/grid/selector.dart'; import 'package:aves/widgets/collection/thumbnail/decorated.dart'; import 'package:aves/widgets/common/behaviour/routes.dart'; import 'package:aves/widgets/common/behaviour/sloppy_scroll_physics.dart'; @@ -53,6 +55,7 @@ class ThumbnailCollection extends StatelessWidget { spacing: spacing, )..applyTileExtent(viewportSize: viewportSize); final cacheExtent = tileExtentManager.getEffectiveExtentMax(viewportSize) * 2; + final scrollController = PrimaryScrollController.of(context); // do not replace by Provider.of // so that view updates on collection filter changes @@ -67,7 +70,7 @@ class ThumbnailCollection extends StatelessWidget { ), appBarHeightNotifier: _appBarHeightNotifier, isScrollingNotifier: _isScrollingNotifier, - scrollController: PrimaryScrollController.of(context), + scrollController: scrollController, cacheExtent: cacheExtent, ); @@ -102,6 +105,14 @@ class ThumbnailCollection extends StatelessWidget { child: scrollView, ); + final selector = GridSelectionGestureDetector( + selectable: AvesApp.mode == AppMode.main, + collection: collection, + scrollController: scrollController, + appBarHeightNotifier: _appBarHeightNotifier, + child: scaler, + ); + final sectionedListLayoutProvider = ValueListenableBuilder( valueListenable: _tileExtentNotifier, builder: (context, tileExtent, child) => SectionedListLayoutProvider( @@ -116,7 +127,7 @@ class ThumbnailCollection extends StatelessWidget { tileExtent: tileExtent, isScrollingNotifier: _isScrollingNotifier, ), - child: scaler, + child: selector, ), ); return sectionedListLayoutProvider; diff --git a/lib/widgets/common/identity/aves_filter_chip.dart b/lib/widgets/common/identity/aves_filter_chip.dart index 50fd94e47..5351522a1 100644 --- a/lib/widgets/common/identity/aves_filter_chip.dart +++ b/lib/widgets/common/identity/aves_filter_chip.dart @@ -168,6 +168,8 @@ class _AvesFilterChipState extends State { borderRadius: borderRadius, ), child: InkWell( + // as of Flutter v1.22.5, `InkWell` does not have `onLongPressStart` like `GestureDetector`, + // so we get the long press details from the tap instead onTapDown: (details) => _tapPosition = details.globalPosition, onTap: widget.onTap != null ? () { diff --git a/lib/widgets/filter_grids/common/filter_nav_page.dart b/lib/widgets/filter_grids/common/filter_nav_page.dart index ee10b06af..6640f8d89 100644 --- a/lib/widgets/filter_grids/common/filter_nav_page.dart +++ b/lib/widgets/filter_grids/common/filter_nav_page.dart @@ -85,6 +85,7 @@ class FilterNavigationPage extends StatelessWidget { Future _showMenu(BuildContext context, T filter, Offset tapPosition) async { final RenderBox overlay = Overlay.of(context).context.findRenderObject(); final touchArea = Size(40, 40); + // TODO TLAD show menu within safe area final selectedAction = await showMenu( context: context, position: RelativeRect.fromRect(tapPosition & touchArea, Offset.zero & overlay.size),