From 9c8d0215c624fd24978388d14bc289f090db5739 Mon Sep 17 00:00:00 2001 From: Thibault Deckers Date: Wed, 9 Jun 2021 08:05:35 +0900 Subject: [PATCH] collection: action to show moved/copied items --- lib/l10n/app_en.arb | 2 + lib/l10n/app_ko.arb | 1 + lib/model/highlight.dart | 19 +++++ lib/theme/durations.dart | 3 +- lib/theme/themes.dart | 1 + lib/widgets/collection/collection_grid.dart | 31 ++++---- .../collection/entry_set_action_delegate.dart | 58 +++++++++++++-- .../common/action_mixins/feedback.dart | 9 ++- lib/widgets/common/grid/item_tracker.dart | 73 +++++++++++++++++++ lib/widgets/common/scaling.dart | 47 +++--------- .../filter_grids/common/filter_grid_page.dart | 44 +++-------- 11 files changed, 190 insertions(+), 98 deletions(-) create mode 100644 lib/widgets/common/grid/item_tracker.dart diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 44bde27ab..b97799115 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -18,6 +18,8 @@ "@applyButtonLabel": {}, "deleteButtonLabel": "DELETE", "@deleteButtonLabel": {}, + "showButtonLabel": "SHOW", + "@showButtonLabel": {}, "hideButtonLabel": "HIDE", "@hideButtonLabel": {}, "continueButtonLabel": "CONTINUE", diff --git a/lib/l10n/app_ko.arb b/lib/l10n/app_ko.arb index b6628a413..4a5cb44dc 100644 --- a/lib/l10n/app_ko.arb +++ b/lib/l10n/app_ko.arb @@ -7,6 +7,7 @@ "applyButtonLabel": "확인", "deleteButtonLabel": "삭제", + "showButtonLabel": "보기", "hideButtonLabel": "숨기기", "continueButtonLabel": "다음", "clearTooltip": "초기화", diff --git a/lib/model/highlight.dart b/lib/model/highlight.dart index 5bd3682d4..46d8a669a 100644 --- a/lib/model/highlight.dart +++ b/lib/model/highlight.dart @@ -1,6 +1,16 @@ +import 'package:event_bus/event_bus.dart'; import 'package:flutter/foundation.dart'; class HighlightInfo extends ChangeNotifier { + final EventBus eventBus = EventBus(); + + void trackItem( + T item, { + required bool animate, + Object? highlight, + }) => + eventBus.fire(TrackEvent(item, animate, highlight)); + Object? _item; void set(Object item) { @@ -22,3 +32,12 @@ class HighlightInfo extends ChangeNotifier { @override String toString() => '$runtimeType#${shortHash(this)}{item=$_item}'; } + +@immutable +class TrackEvent { + final T item; + final bool animate; + final Object? highlight; + + const TrackEvent(this.item, this.animate, this.highlight); +} diff --git a/lib/theme/durations.dart b/lib/theme/durations.dart index 2b9f07fe6..1f85b0f8c 100644 --- a/lib/theme/durations.dart +++ b/lib/theme/durations.dart @@ -55,9 +55,10 @@ class Durations { // delays & refresh intervals static const opToastDisplay = Duration(seconds: 3); + static const opToastActionDisplay = Duration(seconds: 5); static const infoScrollMonitoringTimerDelay = Duration(milliseconds: 100); static const collectionScrollMonitoringTimerDelay = Duration(milliseconds: 100); - static const collectionScalingCompleteNotificationDelay = Duration(milliseconds: 300); + static const highlightJumpDelay = Duration(milliseconds: 400); static const highlightScrollInitDelay = Duration(milliseconds: 800); static const videoProgressTimerInterval = Duration(milliseconds: 300); static Duration staggeredAnimationDelay = Durations.staggeredAnimation ~/ 6 * timeDilation; diff --git a/lib/theme/themes.dart b/lib/theme/themes.dart index 0c9e692e6..9062cdf99 100644 --- a/lib/theme/themes.dart +++ b/lib/theme/themes.dart @@ -32,6 +32,7 @@ class Themes { ), snackBarTheme: SnackBarThemeData( backgroundColor: Colors.grey.shade800, + actionTextColor: _accentColor, contentTextStyle: const TextStyle( color: Colors.white, ), diff --git a/lib/widgets/collection/collection_grid.dart b/lib/widgets/collection/collection_grid.dart index 6360661e1..1eaf2f30f 100644 --- a/lib/widgets/collection/collection_grid.dart +++ b/lib/widgets/collection/collection_grid.dart @@ -4,7 +4,6 @@ import 'package:aves/app_mode.dart'; import 'package:aves/model/entry.dart'; import 'package:aves/model/filters/favourite.dart'; import 'package:aves/model/filters/mime.dart'; -import 'package:aves/model/highlight.dart'; import 'package:aves/model/source/collection_lens.dart'; import 'package:aves/model/source/enums.dart'; import 'package:aves/ref/mime_types.dart'; @@ -22,7 +21,7 @@ import 'package:aves/widgets/common/basic/insets.dart'; import 'package:aves/widgets/common/behaviour/sloppy_scroll_physics.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/common/extensions/media_query.dart'; -import 'package:aves/widgets/common/grid/section_layout.dart'; +import 'package:aves/widgets/common/grid/item_tracker.dart'; import 'package:aves/widgets/common/grid/sliver.dart'; import 'package:aves/widgets/common/identity/empty.dart'; import 'package:aves/widgets/common/identity/scroll_thumb.dart'; @@ -131,33 +130,37 @@ class _CollectionSectionedContent extends StatefulWidget { _CollectionSectionedContentState createState() => _CollectionSectionedContentState(); } -class _CollectionSectionedContentState extends State<_CollectionSectionedContent> { +class _CollectionSectionedContentState extends State<_CollectionSectionedContent> with GridItemTrackerMixin { CollectionLens get collection => widget.collection; + @override ScrollController get scrollController => widget.scrollController; - final ValueNotifier _appBarHeightNotifier = ValueNotifier(0); - final GlobalKey _scrollableKey = GlobalKey(debugLabel: 'thumbnail-collection-scrollable'); + @override + final ValueNotifier appBarHeightNotifier = ValueNotifier(0); + + @override + final GlobalKey scrollableKey = GlobalKey(debugLabel: 'thumbnail-collection-scrollable'); @override Widget build(BuildContext context) { final scrollView = AnimationLimiter( child: _CollectionScrollView( - scrollableKey: _scrollableKey, + scrollableKey: scrollableKey, collection: collection, appBar: CollectionAppBar( - appBarHeightNotifier: _appBarHeightNotifier, + appBarHeightNotifier: appBarHeightNotifier, collection: collection, ), - appBarHeightNotifier: _appBarHeightNotifier, + appBarHeightNotifier: appBarHeightNotifier, isScrollingNotifier: widget.isScrollingNotifier, scrollController: scrollController, ), ); final scaler = _CollectionScaler( - scrollableKey: _scrollableKey, - appBarHeightNotifier: _appBarHeightNotifier, + scrollableKey: scrollableKey, + appBarHeightNotifier: appBarHeightNotifier, child: scrollView, ); @@ -166,7 +169,7 @@ class _CollectionSectionedContentState extends State<_CollectionSectionedContent selectable: isMainMode, collection: collection, scrollController: scrollController, - appBarHeightNotifier: _appBarHeightNotifier, + appBarHeightNotifier: appBarHeightNotifier, child: scaler, ); @@ -190,7 +193,6 @@ class _CollectionScaler extends StatelessWidget { final tileSpacing = context.select((controller) => controller.spacing); return GridScaleGestureDetector( scrollableKey: scrollableKey, - appBarHeightNotifier: appBarHeightNotifier, gridBuilder: (center, extent, child) => CustomPaint( painter: GridPainter( center: center, @@ -211,11 +213,6 @@ class _CollectionScaler extends StatelessWidget { highlightable: false, ), ), - getScaledItemTileRect: (context, entry) { - final sectionedListLayout = context.read>(); - return sectionedListLayout.getTileRect(entry) ?? Rect.zero; - }, - onScaled: (entry) => context.read().set(entry), child: child, ); } diff --git a/lib/widgets/collection/entry_set_action_delegate.dart b/lib/widgets/collection/entry_set_action_delegate.dart index b92d7aaae..4973cac16 100644 --- a/lib/widgets/collection/entry_set_action_delegate.dart +++ b/lib/widgets/collection/entry_set_action_delegate.dart @@ -4,20 +4,27 @@ import 'package:aves/model/actions/collection_actions.dart'; import 'package:aves/model/actions/entry_actions.dart'; import 'package:aves/model/actions/move_type.dart'; import 'package:aves/model/entry.dart'; +import 'package:aves/model/filters/album.dart'; +import 'package:aves/model/highlight.dart'; import 'package:aves/model/source/collection_lens.dart'; import 'package:aves/model/source/collection_source.dart'; import 'package:aves/services/android_app_service.dart'; import 'package:aves/services/image_op_events.dart'; import 'package:aves/services/services.dart'; +import 'package:aves/theme/durations.dart'; import 'package:aves/utils/android_file_utils.dart'; +import 'package:aves/widgets/collection/collection_page.dart'; import 'package:aves/widgets/common/action_mixins/feedback.dart'; import 'package:aves/widgets/common/action_mixins/permission_aware.dart'; import 'package:aves/widgets/common/action_mixins/size_aware.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/dialogs/aves_dialog.dart'; import 'package:aves/widgets/filter_grids/album_pick.dart'; +import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:flutter/widgets.dart'; +import 'package:pedantic/pedantic.dart'; +import 'package:provider/provider.dart'; class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMixin { final CollectionLens collection; @@ -114,6 +121,11 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware collection.browse(); source.resumeMonitoring(); + // cleanup + if (moveType == MoveType.move) { + await storageService.deleteEmptyDirectories(selectionDirs); + } + final l10n = context.l10n; final movedCount = movedOps.length; if (movedCount < todoCount) { @@ -121,12 +133,46 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware showFeedback(context, copy ? l10n.collectionCopyFailureFeedback(count) : l10n.collectionMoveFailureFeedback(count)); } else { final count = movedCount; - showFeedback(context, copy ? l10n.collectionCopySuccessFeedback(count) : l10n.collectionMoveSuccessFeedback(count)); - } - - // cleanup - if (moveType == MoveType.move) { - await storageService.deleteEmptyDirectories(selectionDirs); + showFeedback( + context, + copy ? l10n.collectionCopySuccessFeedback(count) : l10n.collectionMoveSuccessFeedback(count), + SnackBarAction( + label: context.l10n.showButtonLabel, + onPressed: () async { + final highlightInfo = context.read(); + var targetCollection = collection; + if (collection.filters.any((f) => f is AlbumFilter)) { + final filter = AlbumFilter(destinationAlbum, source.getAlbumDisplayName(context, destinationAlbum)); + // we could simply add the filter to the current collection + // but navigating makes the change less jarring + targetCollection = CollectionLens( + source: collection.source, + filters: collection.filters, + groupFactor: collection.groupFactor, + sortFactor: collection.sortFactor, + )..addFilter(filter); + unawaited(Navigator.pushReplacement( + context, + MaterialPageRoute( + settings: const RouteSettings(name: CollectionPage.routeName), + builder: (context) { + return CollectionPage( + targetCollection, + ); + }, + ), + )); + await Future.delayed(Durations.staggeredAnimationPageTarget); + } + await Future.delayed(Durations.highlightScrollInitDelay); + final newUris = movedOps.map((v) => v.newFields['uri'] as String?).toSet(); + final targetEntry = targetCollection.sortedEntries.firstWhereOrNull((entry) => newUris.contains(entry.uri)); + if (targetEntry != null) { + highlightInfo.trackItem(targetEntry, animate: true, highlight: targetEntry); + } + }, + ), + ); } }, ); diff --git a/lib/widgets/common/action_mixins/feedback.dart b/lib/widgets/common/action_mixins/feedback.dart index 8d7ab3e53..99cd02a54 100644 --- a/lib/widgets/common/action_mixins/feedback.dart +++ b/lib/widgets/common/action_mixins/feedback.dart @@ -6,15 +6,16 @@ import 'package:percent_indicator/circular_percent_indicator.dart'; mixin FeedbackMixin { void dismissFeedback(BuildContext context) => ScaffoldMessenger.of(context).hideCurrentSnackBar(); - void showFeedback(BuildContext context, String message) { - showFeedbackWithMessenger(ScaffoldMessenger.of(context), message); + void showFeedback(BuildContext context, String message, [SnackBarAction? action]) { + showFeedbackWithMessenger(ScaffoldMessenger.of(context), message, action); } // provide the messenger if feedback happens as the widget is disposed - void showFeedbackWithMessenger(ScaffoldMessengerState messenger, String message) { + void showFeedbackWithMessenger(ScaffoldMessengerState messenger, String message, [SnackBarAction? action]) { messenger.showSnackBar(SnackBar( content: Text(message), - duration: Durations.opToastDisplay, + action: action, + duration: action != null ? Durations.opToastActionDisplay : Durations.opToastDisplay, )); } diff --git a/lib/widgets/common/grid/item_tracker.dart b/lib/widgets/common/grid/item_tracker.dart new file mode 100644 index 000000000..384611856 --- /dev/null +++ b/lib/widgets/common/grid/item_tracker.dart @@ -0,0 +1,73 @@ +import 'dart:async'; + +import 'package:aves/model/highlight.dart'; +import 'package:aves/theme/durations.dart'; +import 'package:aves/widgets/common/grid/section_layout.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +mixin GridItemTrackerMixin on State { + ValueNotifier get appBarHeightNotifier; + + GlobalKey get scrollableKey; + + ScrollController get scrollController; + + final List _subscriptions = []; + + @override + void initState() { + super.initState(); + final highlightInfo = context.read(); + _subscriptions.add(highlightInfo.eventBus.on>().listen((e) => _trackItem( + e.item, + animate: e.animate, + highlight: e.highlight, + ))); + } + + @override + void dispose() { + _subscriptions + ..forEach((sub) => sub.cancel()) + ..clear(); + super.dispose(); + } + + // about scrolling & offset retrieval: + // `Scrollable.ensureVisible` only works on already rendered objects + // `RenderViewport.showOnScreen` can find any `RenderSliver`, but not always a `RenderMetadata` + // `RenderViewport.scrollOffsetOf` is a good alternative + Future _trackItem(T item, {required bool animate, required Object? highlight}) async { + final sectionedListLayout = context.read>(); + final tileRect = sectionedListLayout.getTileRect(item); + if (tileRect == null) return; + + final scrollableContext = scrollableKey.currentContext!; + final scrollableHeight = (scrollableContext.findRenderObject() as RenderBox).size.height; + + // most of the time the app bar will be scrolled away after scaling, + // so we compensate for it to center the focal point thumbnail + final appBarHeight = appBarHeightNotifier.value; + final scrollOffset = tileRect.top + (tileRect.height - scrollableHeight) / 2 + appBarHeight; + + if (animate) { + if (scrollOffset > 0) { + await scrollController.animateTo( + scrollOffset, + duration: Duration(milliseconds: (scrollOffset / 2).round().clamp(Durations.highlightScrollAnimationMinMillis, Durations.highlightScrollAnimationMaxMillis)), + curve: Curves.easeInOutCubic, + ); + } + } else { + final maxScrollExtent = scrollController.position.maxScrollExtent; + scrollController.jumpTo(scrollOffset.clamp(.0, maxScrollExtent)); + await Future.delayed(Durations.highlightJumpDelay); + } + + if (highlight != null) { + context.read().set(highlight); + } + } +} diff --git a/lib/widgets/common/scaling.dart b/lib/widgets/common/scaling.dart index 9a0f48acc..a20cae9ea 100644 --- a/lib/widgets/common/scaling.dart +++ b/lib/widgets/common/scaling.dart @@ -1,5 +1,6 @@ import 'dart:ui' as ui; +import 'package:aves/model/highlight.dart'; import 'package:aves/theme/durations.dart'; import 'package:aves/widgets/common/providers/media_query_data_provider.dart'; import 'package:aves/widgets/common/tile_extent_controller.dart'; @@ -17,20 +18,16 @@ class ScalerMetadata { class GridScaleGestureDetector extends StatefulWidget { final GlobalKey scrollableKey; - final ValueNotifier appBarHeightNotifier; - final Widget Function(Offset center, double extent, Widget child)? gridBuilder; + final Widget Function(Offset center, double extent, Widget child) gridBuilder; final Widget Function(T item, double extent) scaledBuilder; - final Rect Function(BuildContext context, T item) getScaledItemTileRect; - final void Function(T item) onScaled; + final Object Function(T item)? highlightItem; final Widget child; const GridScaleGestureDetector({ required this.scrollableKey, - required this.appBarHeightNotifier, - this.gridBuilder, + required this.gridBuilder, required this.scaledBuilder, - required this.getScaledItemTileRect, - required this.onScaled, + this.highlightItem, required this.child, }); @@ -114,11 +111,9 @@ class _GridScaleGestureDetectorState extends State widget.onScaled(entry)); + final trackItem = _metadata!.item; + final highlightItem = widget.highlightItem?.call(trackItem) ?? trackItem; + context.read().trackItem(trackItem, animate: false, highlight: highlightItem); _applyingScale = false; }); } @@ -135,26 +130,6 @@ class _GridScaleGestureDetectorState extends State scaledExtentNotifier; - final Widget Function(Offset center, double extent, Widget child)? gridBuilder; + final Widget Function(Offset center, double extent, Widget child) gridBuilder; const ScaleOverlay({ required this.builder, required this.center, required this.viewportWidth, required this.scaledExtentNotifier, - this.gridBuilder, + required this.gridBuilder, }); @override @@ -243,7 +218,7 @@ class _ScaleOverlayState extends State { ), ], ); - child = widget.gridBuilder?.call(clampedCenter, extent, child) ?? child; + child = widget.gridBuilder(clampedCenter, extent, child); return child; }, ), diff --git a/lib/widgets/filter_grids/common/filter_grid_page.dart b/lib/widgets/filter_grids/common/filter_grid_page.dart index ebdfd6c7a..9c0f890f9 100644 --- a/lib/widgets/filter_grids/common/filter_grid_page.dart +++ b/lib/widgets/filter_grids/common/filter_grid_page.dart @@ -11,6 +11,7 @@ import 'package:aves/widgets/common/basic/insets.dart'; import 'package:aves/widgets/common/behaviour/double_back_pop.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/common/extensions/media_query.dart'; +import 'package:aves/widgets/common/grid/item_tracker.dart'; import 'package:aves/widgets/common/grid/section_layout.dart'; import 'package:aves/widgets/common/grid/sliver.dart'; import 'package:aves/widgets/common/identity/aves_filter_chip.dart'; @@ -280,18 +281,21 @@ class _FilterSectionedContent extends StatefulWidget _FilterSectionedContentState createState() => _FilterSectionedContentState(); } -class _FilterSectionedContentState extends State<_FilterSectionedContent> { +class _FilterSectionedContentState extends State<_FilterSectionedContent> with GridItemTrackerMixin, _FilterSectionedContent> { Widget get appBar => widget.appBar; + @override ValueNotifier get appBarHeightNotifier => widget.appBarHeightNotifier; Map>> get visibleFilterSections => widget.visibleFilterSections; Widget Function() get emptyBuilder => widget.emptyBuilder; + @override ScrollController get scrollController => widget.scrollController; - final GlobalKey _scrollableKey = GlobalKey(debugLabel: 'filter-grid-page-scrollable'); + @override + final GlobalKey scrollableKey = GlobalKey(debugLabel: 'filter-grid-page-scrollable'); @override void initState() { @@ -303,7 +307,7 @@ class _FilterSectionedContentState extends State<_Fi Widget build(BuildContext context) { final scrollView = AnimationLimiter( child: _FilterScrollView( - scrollableKey: _scrollableKey, + scrollableKey: scrollableKey, appBar: appBar, appBarHeightNotifier: appBarHeightNotifier, sortFactor: widget.sortFactor, @@ -313,7 +317,7 @@ class _FilterSectionedContentState extends State<_Fi ); final scaler = _FilterScaler( - scrollableKey: _scrollableKey, + scrollableKey: scrollableKey, appBarHeightNotifier: appBarHeightNotifier, child: scrollView, ); @@ -328,33 +332,10 @@ class _FilterSectionedContentState extends State<_Fi final gridItem = visibleFilterSections.values.expand((list) => list).firstWhereOrNull((gridItem) => gridItem.filter == filter); if (gridItem != null) { await Future.delayed(Durations.highlightScrollInitDelay); - final sectionedListLayout = context.read>>(); - final tileRect = sectionedListLayout.getTileRect(gridItem); - if (tileRect != null) { - await _scrollToItem(tileRect); - highlightInfo.set(filter); - } + highlightInfo.trackItem(gridItem, animate: true, highlight: filter); } } } - - Future _scrollToItem(Rect tileRect) async { - final scrollableContext = _scrollableKey.currentContext!; - final scrollableHeight = (scrollableContext.findRenderObject() as RenderBox).size.height; - - // most of the time the app bar will be scrolled away after scaling, - // so we compensate for it to center the focal point thumbnail - final appBarHeight = appBarHeightNotifier.value; - final scrollOffset = tileRect.top + (tileRect.height - scrollableHeight) / 2 + appBarHeight; - - if (scrollOffset > 0) { - await scrollController.animateTo( - scrollOffset, - duration: Duration(milliseconds: (scrollOffset / 2).round().clamp(Durations.highlightScrollAnimationMinMillis, Durations.highlightScrollAnimationMaxMillis)), - curve: Curves.easeInOutCubic, - ); - } - } } class _FilterScaler extends StatelessWidget { @@ -374,7 +355,6 @@ class _FilterScaler extends StatelessWidget { final tileSpacing = context.select((controller) => controller.spacing); return GridScaleGestureDetector>( scrollableKey: scrollableKey, - appBarHeightNotifier: appBarHeightNotifier, gridBuilder: (center, extent, child) => CustomPaint( painter: GridPainter( center: center, @@ -396,11 +376,7 @@ class _FilterScaler extends StatelessWidget { highlightable: false, ); }, - getScaledItemTileRect: (context, item) { - final sectionedListLayout = context.read>>(); - return sectionedListLayout.getTileRect(item) ?? Rect.zero; - }, - onScaled: (item) => context.read().set(item.filter), + highlightItem: (item) => item.filter, child: child, ); }