collection: action to show moved/copied items

This commit is contained in:
Thibault Deckers 2021-06-09 08:05:35 +09:00
parent fa5f30ea7c
commit 9c8d0215c6
11 changed files with 190 additions and 98 deletions

View file

@ -18,6 +18,8 @@
"@applyButtonLabel": {},
"deleteButtonLabel": "DELETE",
"@deleteButtonLabel": {},
"showButtonLabel": "SHOW",
"@showButtonLabel": {},
"hideButtonLabel": "HIDE",
"@hideButtonLabel": {},
"continueButtonLabel": "CONTINUE",

View file

@ -7,6 +7,7 @@
"applyButtonLabel": "확인",
"deleteButtonLabel": "삭제",
"showButtonLabel": "보기",
"hideButtonLabel": "숨기기",
"continueButtonLabel": "다음",
"clearTooltip": "초기화",

View file

@ -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>(
T item, {
required bool animate,
Object? highlight,
}) =>
eventBus.fire(TrackEvent<T>(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<T> {
final T item;
final bool animate;
final Object? highlight;
const TrackEvent(this.item, this.animate, this.highlight);
}

View file

@ -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;

View file

@ -32,6 +32,7 @@ class Themes {
),
snackBarTheme: SnackBarThemeData(
backgroundColor: Colors.grey.shade800,
actionTextColor: _accentColor,
contentTextStyle: const TextStyle(
color: Colors.white,
),

View file

@ -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<AvesEntry, _CollectionSectionedContent> {
CollectionLens get collection => widget.collection;
@override
ScrollController get scrollController => widget.scrollController;
final ValueNotifier<double> _appBarHeightNotifier = ValueNotifier(0);
final GlobalKey _scrollableKey = GlobalKey(debugLabel: 'thumbnail-collection-scrollable');
@override
final ValueNotifier<double> 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<TileExtentController, double>((controller) => controller.spacing);
return GridScaleGestureDetector<AvesEntry>(
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<SectionedListLayout<AvesEntry>>();
return sectionedListLayout.getTileRect(entry) ?? Rect.zero;
},
onScaled: (entry) => context.read<HighlightInfo>().set(entry),
child: child,
);
}

View file

@ -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));
showFeedback(
context,
copy ? l10n.collectionCopySuccessFeedback(count) : l10n.collectionMoveSuccessFeedback(count),
SnackBarAction(
label: context.l10n.showButtonLabel,
onPressed: () async {
final highlightInfo = context.read<HighlightInfo>();
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);
}
// cleanup
if (moveType == MoveType.move) {
await storageService.deleteEmptyDirectories(selectionDirs);
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);
}
},
),
);
}
},
);

View file

@ -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,
));
}

View file

@ -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<T, U extends StatefulWidget> on State<U> {
ValueNotifier<double> get appBarHeightNotifier;
GlobalKey get scrollableKey;
ScrollController get scrollController;
final List<StreamSubscription> _subscriptions = [];
@override
void initState() {
super.initState();
final highlightInfo = context.read<HighlightInfo>();
_subscriptions.add(highlightInfo.eventBus.on<TrackEvent<T>>().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<void> _trackItem(T item, {required bool animate, required Object? highlight}) async {
final sectionedListLayout = context.read<SectionedListLayout<T>>();
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<HighlightInfo>().set(highlight);
}
}
}

View file

@ -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<T> {
class GridScaleGestureDetector<T> extends StatefulWidget {
final GlobalKey scrollableKey;
final ValueNotifier<double> 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<T> extends State<GridScaleGestureDetector<T
} else {
// scroll to show the focal point thumbnail at its new position
WidgetsBinding.instance!.addPostFrameCallback((_) {
final entry = _metadata!.item;
_scrollToItem(entry);
// warning: posting `onScaled` in the next frame with `addPostFrameCallback`
// would trigger only when the scrollable offset actually changes
Future.delayed(Durations.collectionScalingCompleteNotificationDelay).then((_) => widget.onScaled(entry));
final trackItem = _metadata!.item;
final highlightItem = widget.highlightItem?.call(trackItem) ?? trackItem;
context.read<HighlightInfo>().trackItem(trackItem, animate: false, highlight: highlightItem);
_applyingScale = false;
});
}
@ -135,26 +130,6 @@ class _GridScaleGestureDetectorState<T> extends State<GridScaleGestureDetector<T
),
);
}
// 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
void _scrollToItem(T item) {
final scrollableContext = widget.scrollableKey.currentContext!;
final scrollableHeight = (scrollableContext.findRenderObject() as RenderBox).size.height;
final tileRect = widget.getScaledItemTileRect(context, item);
// 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 = widget.appBarHeightNotifier.value;
final scrollOffset = tileRect.top + (tileRect.height - scrollableHeight) / 2 + appBarHeight;
final controller = PrimaryScrollController.of(context);
if (controller != null) {
final maxScrollExtent = controller.position.maxScrollExtent;
controller.jumpTo(scrollOffset.clamp(.0, maxScrollExtent));
}
}
}
class ScaleOverlay extends StatefulWidget {
@ -162,14 +137,14 @@ class ScaleOverlay extends StatefulWidget {
final Offset center;
final double viewportWidth;
final ValueNotifier<double> 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<ScaleOverlay> {
),
],
);
child = widget.gridBuilder?.call(clampedCenter, extent, child) ?? child;
child = widget.gridBuilder(clampedCenter, extent, child);
return child;
},
),

View file

@ -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<T extends CollectionFilter> extends StatefulWidget
_FilterSectionedContentState createState() => _FilterSectionedContentState<T>();
}
class _FilterSectionedContentState<T extends CollectionFilter> extends State<_FilterSectionedContent<T>> {
class _FilterSectionedContentState<T extends CollectionFilter> extends State<_FilterSectionedContent<T>> with GridItemTrackerMixin<FilterGridItem<T>, _FilterSectionedContent<T>> {
Widget get appBar => widget.appBar;
@override
ValueNotifier<double> get appBarHeightNotifier => widget.appBarHeightNotifier;
Map<ChipSectionKey, List<FilterGridItem<T>>> 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<T extends CollectionFilter> extends State<_Fi
Widget build(BuildContext context) {
final scrollView = AnimationLimiter(
child: _FilterScrollView<T>(
scrollableKey: _scrollableKey,
scrollableKey: scrollableKey,
appBar: appBar,
appBarHeightNotifier: appBarHeightNotifier,
sortFactor: widget.sortFactor,
@ -313,7 +317,7 @@ class _FilterSectionedContentState<T extends CollectionFilter> extends State<_Fi
);
final scaler = _FilterScaler<T>(
scrollableKey: _scrollableKey,
scrollableKey: scrollableKey,
appBarHeightNotifier: appBarHeightNotifier,
child: scrollView,
);
@ -328,35 +332,12 @@ class _FilterSectionedContentState<T extends CollectionFilter> 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<SectionedListLayout<FilterGridItem<T>>>();
final tileRect = sectionedListLayout.getTileRect(gridItem);
if (tileRect != null) {
await _scrollToItem(tileRect);
highlightInfo.set(filter);
highlightInfo.trackItem(gridItem, animate: true, highlight: filter);
}
}
}
}
Future<void> _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<T extends CollectionFilter> extends StatelessWidget {
final GlobalKey scrollableKey;
final ValueNotifier<double> appBarHeightNotifier;
@ -374,7 +355,6 @@ class _FilterScaler<T extends CollectionFilter> extends StatelessWidget {
final tileSpacing = context.select<TileExtentController, double>((controller) => controller.spacing);
return GridScaleGestureDetector<FilterGridItem<T>>(
scrollableKey: scrollableKey,
appBarHeightNotifier: appBarHeightNotifier,
gridBuilder: (center, extent, child) => CustomPaint(
painter: GridPainter(
center: center,
@ -396,11 +376,7 @@ class _FilterScaler<T extends CollectionFilter> extends StatelessWidget {
highlightable: false,
);
},
getScaledItemTileRect: (context, item) {
final sectionedListLayout = context.read<SectionedListLayout<FilterGridItem<T>>>();
return sectionedListLayout.getTileRect(item) ?? Rect.zero;
},
onScaled: (item) => context.read<HighlightInfo>().set(item.filter),
highlightItem: (item) => item.filter,
child: child,
);
}