collection: action to show moved/copied items
This commit is contained in:
parent
fa5f30ea7c
commit
9c8d0215c6
11 changed files with 190 additions and 98 deletions
|
@ -18,6 +18,8 @@
|
||||||
"@applyButtonLabel": {},
|
"@applyButtonLabel": {},
|
||||||
"deleteButtonLabel": "DELETE",
|
"deleteButtonLabel": "DELETE",
|
||||||
"@deleteButtonLabel": {},
|
"@deleteButtonLabel": {},
|
||||||
|
"showButtonLabel": "SHOW",
|
||||||
|
"@showButtonLabel": {},
|
||||||
"hideButtonLabel": "HIDE",
|
"hideButtonLabel": "HIDE",
|
||||||
"@hideButtonLabel": {},
|
"@hideButtonLabel": {},
|
||||||
"continueButtonLabel": "CONTINUE",
|
"continueButtonLabel": "CONTINUE",
|
||||||
|
|
|
@ -7,6 +7,7 @@
|
||||||
|
|
||||||
"applyButtonLabel": "확인",
|
"applyButtonLabel": "확인",
|
||||||
"deleteButtonLabel": "삭제",
|
"deleteButtonLabel": "삭제",
|
||||||
|
"showButtonLabel": "보기",
|
||||||
"hideButtonLabel": "숨기기",
|
"hideButtonLabel": "숨기기",
|
||||||
"continueButtonLabel": "다음",
|
"continueButtonLabel": "다음",
|
||||||
"clearTooltip": "초기화",
|
"clearTooltip": "초기화",
|
||||||
|
|
|
@ -1,6 +1,16 @@
|
||||||
|
import 'package:event_bus/event_bus.dart';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
|
|
||||||
class HighlightInfo extends ChangeNotifier {
|
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;
|
Object? _item;
|
||||||
|
|
||||||
void set(Object item) {
|
void set(Object item) {
|
||||||
|
@ -22,3 +32,12 @@ class HighlightInfo extends ChangeNotifier {
|
||||||
@override
|
@override
|
||||||
String toString() => '$runtimeType#${shortHash(this)}{item=$_item}';
|
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);
|
||||||
|
}
|
||||||
|
|
|
@ -55,9 +55,10 @@ class Durations {
|
||||||
|
|
||||||
// delays & refresh intervals
|
// delays & refresh intervals
|
||||||
static const opToastDisplay = Duration(seconds: 3);
|
static const opToastDisplay = Duration(seconds: 3);
|
||||||
|
static const opToastActionDisplay = Duration(seconds: 5);
|
||||||
static const infoScrollMonitoringTimerDelay = Duration(milliseconds: 100);
|
static const infoScrollMonitoringTimerDelay = Duration(milliseconds: 100);
|
||||||
static const collectionScrollMonitoringTimerDelay = 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 highlightScrollInitDelay = Duration(milliseconds: 800);
|
||||||
static const videoProgressTimerInterval = Duration(milliseconds: 300);
|
static const videoProgressTimerInterval = Duration(milliseconds: 300);
|
||||||
static Duration staggeredAnimationDelay = Durations.staggeredAnimation ~/ 6 * timeDilation;
|
static Duration staggeredAnimationDelay = Durations.staggeredAnimation ~/ 6 * timeDilation;
|
||||||
|
|
|
@ -32,6 +32,7 @@ class Themes {
|
||||||
),
|
),
|
||||||
snackBarTheme: SnackBarThemeData(
|
snackBarTheme: SnackBarThemeData(
|
||||||
backgroundColor: Colors.grey.shade800,
|
backgroundColor: Colors.grey.shade800,
|
||||||
|
actionTextColor: _accentColor,
|
||||||
contentTextStyle: const TextStyle(
|
contentTextStyle: const TextStyle(
|
||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
),
|
),
|
||||||
|
|
|
@ -4,7 +4,6 @@ import 'package:aves/app_mode.dart';
|
||||||
import 'package:aves/model/entry.dart';
|
import 'package:aves/model/entry.dart';
|
||||||
import 'package:aves/model/filters/favourite.dart';
|
import 'package:aves/model/filters/favourite.dart';
|
||||||
import 'package:aves/model/filters/mime.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/collection_lens.dart';
|
||||||
import 'package:aves/model/source/enums.dart';
|
import 'package:aves/model/source/enums.dart';
|
||||||
import 'package:aves/ref/mime_types.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/behaviour/sloppy_scroll_physics.dart';
|
||||||
import 'package:aves/widgets/common/extensions/build_context.dart';
|
import 'package:aves/widgets/common/extensions/build_context.dart';
|
||||||
import 'package:aves/widgets/common/extensions/media_query.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/grid/sliver.dart';
|
||||||
import 'package:aves/widgets/common/identity/empty.dart';
|
import 'package:aves/widgets/common/identity/empty.dart';
|
||||||
import 'package:aves/widgets/common/identity/scroll_thumb.dart';
|
import 'package:aves/widgets/common/identity/scroll_thumb.dart';
|
||||||
|
@ -131,33 +130,37 @@ class _CollectionSectionedContent extends StatefulWidget {
|
||||||
_CollectionSectionedContentState createState() => _CollectionSectionedContentState();
|
_CollectionSectionedContentState createState() => _CollectionSectionedContentState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _CollectionSectionedContentState extends State<_CollectionSectionedContent> {
|
class _CollectionSectionedContentState extends State<_CollectionSectionedContent> with GridItemTrackerMixin<AvesEntry, _CollectionSectionedContent> {
|
||||||
CollectionLens get collection => widget.collection;
|
CollectionLens get collection => widget.collection;
|
||||||
|
|
||||||
|
@override
|
||||||
ScrollController get scrollController => widget.scrollController;
|
ScrollController get scrollController => widget.scrollController;
|
||||||
|
|
||||||
final ValueNotifier<double> _appBarHeightNotifier = ValueNotifier(0);
|
@override
|
||||||
final GlobalKey _scrollableKey = GlobalKey(debugLabel: 'thumbnail-collection-scrollable');
|
final ValueNotifier<double> appBarHeightNotifier = ValueNotifier(0);
|
||||||
|
|
||||||
|
@override
|
||||||
|
final GlobalKey scrollableKey = GlobalKey(debugLabel: 'thumbnail-collection-scrollable');
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final scrollView = AnimationLimiter(
|
final scrollView = AnimationLimiter(
|
||||||
child: _CollectionScrollView(
|
child: _CollectionScrollView(
|
||||||
scrollableKey: _scrollableKey,
|
scrollableKey: scrollableKey,
|
||||||
collection: collection,
|
collection: collection,
|
||||||
appBar: CollectionAppBar(
|
appBar: CollectionAppBar(
|
||||||
appBarHeightNotifier: _appBarHeightNotifier,
|
appBarHeightNotifier: appBarHeightNotifier,
|
||||||
collection: collection,
|
collection: collection,
|
||||||
),
|
),
|
||||||
appBarHeightNotifier: _appBarHeightNotifier,
|
appBarHeightNotifier: appBarHeightNotifier,
|
||||||
isScrollingNotifier: widget.isScrollingNotifier,
|
isScrollingNotifier: widget.isScrollingNotifier,
|
||||||
scrollController: scrollController,
|
scrollController: scrollController,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
final scaler = _CollectionScaler(
|
final scaler = _CollectionScaler(
|
||||||
scrollableKey: _scrollableKey,
|
scrollableKey: scrollableKey,
|
||||||
appBarHeightNotifier: _appBarHeightNotifier,
|
appBarHeightNotifier: appBarHeightNotifier,
|
||||||
child: scrollView,
|
child: scrollView,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@ -166,7 +169,7 @@ class _CollectionSectionedContentState extends State<_CollectionSectionedContent
|
||||||
selectable: isMainMode,
|
selectable: isMainMode,
|
||||||
collection: collection,
|
collection: collection,
|
||||||
scrollController: scrollController,
|
scrollController: scrollController,
|
||||||
appBarHeightNotifier: _appBarHeightNotifier,
|
appBarHeightNotifier: appBarHeightNotifier,
|
||||||
child: scaler,
|
child: scaler,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@ -190,7 +193,6 @@ class _CollectionScaler extends StatelessWidget {
|
||||||
final tileSpacing = context.select<TileExtentController, double>((controller) => controller.spacing);
|
final tileSpacing = context.select<TileExtentController, double>((controller) => controller.spacing);
|
||||||
return GridScaleGestureDetector<AvesEntry>(
|
return GridScaleGestureDetector<AvesEntry>(
|
||||||
scrollableKey: scrollableKey,
|
scrollableKey: scrollableKey,
|
||||||
appBarHeightNotifier: appBarHeightNotifier,
|
|
||||||
gridBuilder: (center, extent, child) => CustomPaint(
|
gridBuilder: (center, extent, child) => CustomPaint(
|
||||||
painter: GridPainter(
|
painter: GridPainter(
|
||||||
center: center,
|
center: center,
|
||||||
|
@ -211,11 +213,6 @@ class _CollectionScaler extends StatelessWidget {
|
||||||
highlightable: false,
|
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,
|
child: child,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -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/entry_actions.dart';
|
||||||
import 'package:aves/model/actions/move_type.dart';
|
import 'package:aves/model/actions/move_type.dart';
|
||||||
import 'package:aves/model/entry.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_lens.dart';
|
||||||
import 'package:aves/model/source/collection_source.dart';
|
import 'package:aves/model/source/collection_source.dart';
|
||||||
import 'package:aves/services/android_app_service.dart';
|
import 'package:aves/services/android_app_service.dart';
|
||||||
import 'package:aves/services/image_op_events.dart';
|
import 'package:aves/services/image_op_events.dart';
|
||||||
import 'package:aves/services/services.dart';
|
import 'package:aves/services/services.dart';
|
||||||
|
import 'package:aves/theme/durations.dart';
|
||||||
import 'package:aves/utils/android_file_utils.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/feedback.dart';
|
||||||
import 'package:aves/widgets/common/action_mixins/permission_aware.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/action_mixins/size_aware.dart';
|
||||||
import 'package:aves/widgets/common/extensions/build_context.dart';
|
import 'package:aves/widgets/common/extensions/build_context.dart';
|
||||||
import 'package:aves/widgets/dialogs/aves_dialog.dart';
|
import 'package:aves/widgets/dialogs/aves_dialog.dart';
|
||||||
import 'package:aves/widgets/filter_grids/album_pick.dart';
|
import 'package:aves/widgets/filter_grids/album_pick.dart';
|
||||||
|
import 'package:collection/collection.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/widgets.dart';
|
import 'package:flutter/widgets.dart';
|
||||||
|
import 'package:pedantic/pedantic.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMixin {
|
class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMixin {
|
||||||
final CollectionLens collection;
|
final CollectionLens collection;
|
||||||
|
@ -114,6 +121,11 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware
|
||||||
collection.browse();
|
collection.browse();
|
||||||
source.resumeMonitoring();
|
source.resumeMonitoring();
|
||||||
|
|
||||||
|
// cleanup
|
||||||
|
if (moveType == MoveType.move) {
|
||||||
|
await storageService.deleteEmptyDirectories(selectionDirs);
|
||||||
|
}
|
||||||
|
|
||||||
final l10n = context.l10n;
|
final l10n = context.l10n;
|
||||||
final movedCount = movedOps.length;
|
final movedCount = movedOps.length;
|
||||||
if (movedCount < todoCount) {
|
if (movedCount < todoCount) {
|
||||||
|
@ -121,12 +133,46 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware
|
||||||
showFeedback(context, copy ? l10n.collectionCopyFailureFeedback(count) : l10n.collectionMoveFailureFeedback(count));
|
showFeedback(context, copy ? l10n.collectionCopyFailureFeedback(count) : l10n.collectionMoveFailureFeedback(count));
|
||||||
} else {
|
} else {
|
||||||
final count = movedCount;
|
final count = movedCount;
|
||||||
showFeedback(context, copy ? l10n.collectionCopySuccessFeedback(count) : l10n.collectionMoveSuccessFeedback(count));
|
showFeedback(
|
||||||
}
|
context,
|
||||||
|
copy ? l10n.collectionCopySuccessFeedback(count) : l10n.collectionMoveSuccessFeedback(count),
|
||||||
// cleanup
|
SnackBarAction(
|
||||||
if (moveType == MoveType.move) {
|
label: context.l10n.showButtonLabel,
|
||||||
await storageService.deleteEmptyDirectories(selectionDirs);
|
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);
|
||||||
|
}
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
|
@ -6,15 +6,16 @@ import 'package:percent_indicator/circular_percent_indicator.dart';
|
||||||
mixin FeedbackMixin {
|
mixin FeedbackMixin {
|
||||||
void dismissFeedback(BuildContext context) => ScaffoldMessenger.of(context).hideCurrentSnackBar();
|
void dismissFeedback(BuildContext context) => ScaffoldMessenger.of(context).hideCurrentSnackBar();
|
||||||
|
|
||||||
void showFeedback(BuildContext context, String message) {
|
void showFeedback(BuildContext context, String message, [SnackBarAction? action]) {
|
||||||
showFeedbackWithMessenger(ScaffoldMessenger.of(context), message);
|
showFeedbackWithMessenger(ScaffoldMessenger.of(context), message, action);
|
||||||
}
|
}
|
||||||
|
|
||||||
// provide the messenger if feedback happens as the widget is disposed
|
// 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(
|
messenger.showSnackBar(SnackBar(
|
||||||
content: Text(message),
|
content: Text(message),
|
||||||
duration: Durations.opToastDisplay,
|
action: action,
|
||||||
|
duration: action != null ? Durations.opToastActionDisplay : Durations.opToastDisplay,
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
73
lib/widgets/common/grid/item_tracker.dart
Normal file
73
lib/widgets/common/grid/item_tracker.dart
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,5 +1,6 @@
|
||||||
import 'dart:ui' as ui;
|
import 'dart:ui' as ui;
|
||||||
|
|
||||||
|
import 'package:aves/model/highlight.dart';
|
||||||
import 'package:aves/theme/durations.dart';
|
import 'package:aves/theme/durations.dart';
|
||||||
import 'package:aves/widgets/common/providers/media_query_data_provider.dart';
|
import 'package:aves/widgets/common/providers/media_query_data_provider.dart';
|
||||||
import 'package:aves/widgets/common/tile_extent_controller.dart';
|
import 'package:aves/widgets/common/tile_extent_controller.dart';
|
||||||
|
@ -17,20 +18,16 @@ class ScalerMetadata<T> {
|
||||||
|
|
||||||
class GridScaleGestureDetector<T> extends StatefulWidget {
|
class GridScaleGestureDetector<T> extends StatefulWidget {
|
||||||
final GlobalKey scrollableKey;
|
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 Widget Function(T item, double extent) scaledBuilder;
|
||||||
final Rect Function(BuildContext context, T item) getScaledItemTileRect;
|
final Object Function(T item)? highlightItem;
|
||||||
final void Function(T item) onScaled;
|
|
||||||
final Widget child;
|
final Widget child;
|
||||||
|
|
||||||
const GridScaleGestureDetector({
|
const GridScaleGestureDetector({
|
||||||
required this.scrollableKey,
|
required this.scrollableKey,
|
||||||
required this.appBarHeightNotifier,
|
required this.gridBuilder,
|
||||||
this.gridBuilder,
|
|
||||||
required this.scaledBuilder,
|
required this.scaledBuilder,
|
||||||
required this.getScaledItemTileRect,
|
this.highlightItem,
|
||||||
required this.onScaled,
|
|
||||||
required this.child,
|
required this.child,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -114,11 +111,9 @@ class _GridScaleGestureDetectorState<T> extends State<GridScaleGestureDetector<T
|
||||||
} else {
|
} else {
|
||||||
// scroll to show the focal point thumbnail at its new position
|
// scroll to show the focal point thumbnail at its new position
|
||||||
WidgetsBinding.instance!.addPostFrameCallback((_) {
|
WidgetsBinding.instance!.addPostFrameCallback((_) {
|
||||||
final entry = _metadata!.item;
|
final trackItem = _metadata!.item;
|
||||||
_scrollToItem(entry);
|
final highlightItem = widget.highlightItem?.call(trackItem) ?? trackItem;
|
||||||
// warning: posting `onScaled` in the next frame with `addPostFrameCallback`
|
context.read<HighlightInfo>().trackItem(trackItem, animate: false, highlight: highlightItem);
|
||||||
// would trigger only when the scrollable offset actually changes
|
|
||||||
Future.delayed(Durations.collectionScalingCompleteNotificationDelay).then((_) => widget.onScaled(entry));
|
|
||||||
_applyingScale = false;
|
_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 {
|
class ScaleOverlay extends StatefulWidget {
|
||||||
|
@ -162,14 +137,14 @@ class ScaleOverlay extends StatefulWidget {
|
||||||
final Offset center;
|
final Offset center;
|
||||||
final double viewportWidth;
|
final double viewportWidth;
|
||||||
final ValueNotifier<double> scaledExtentNotifier;
|
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({
|
const ScaleOverlay({
|
||||||
required this.builder,
|
required this.builder,
|
||||||
required this.center,
|
required this.center,
|
||||||
required this.viewportWidth,
|
required this.viewportWidth,
|
||||||
required this.scaledExtentNotifier,
|
required this.scaledExtentNotifier,
|
||||||
this.gridBuilder,
|
required this.gridBuilder,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@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;
|
return child;
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|
|
@ -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/behaviour/double_back_pop.dart';
|
||||||
import 'package:aves/widgets/common/extensions/build_context.dart';
|
import 'package:aves/widgets/common/extensions/build_context.dart';
|
||||||
import 'package:aves/widgets/common/extensions/media_query.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/section_layout.dart';
|
||||||
import 'package:aves/widgets/common/grid/sliver.dart';
|
import 'package:aves/widgets/common/grid/sliver.dart';
|
||||||
import 'package:aves/widgets/common/identity/aves_filter_chip.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>();
|
_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;
|
Widget get appBar => widget.appBar;
|
||||||
|
|
||||||
|
@override
|
||||||
ValueNotifier<double> get appBarHeightNotifier => widget.appBarHeightNotifier;
|
ValueNotifier<double> get appBarHeightNotifier => widget.appBarHeightNotifier;
|
||||||
|
|
||||||
Map<ChipSectionKey, List<FilterGridItem<T>>> get visibleFilterSections => widget.visibleFilterSections;
|
Map<ChipSectionKey, List<FilterGridItem<T>>> get visibleFilterSections => widget.visibleFilterSections;
|
||||||
|
|
||||||
Widget Function() get emptyBuilder => widget.emptyBuilder;
|
Widget Function() get emptyBuilder => widget.emptyBuilder;
|
||||||
|
|
||||||
|
@override
|
||||||
ScrollController get scrollController => widget.scrollController;
|
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
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
|
@ -303,7 +307,7 @@ class _FilterSectionedContentState<T extends CollectionFilter> extends State<_Fi
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final scrollView = AnimationLimiter(
|
final scrollView = AnimationLimiter(
|
||||||
child: _FilterScrollView<T>(
|
child: _FilterScrollView<T>(
|
||||||
scrollableKey: _scrollableKey,
|
scrollableKey: scrollableKey,
|
||||||
appBar: appBar,
|
appBar: appBar,
|
||||||
appBarHeightNotifier: appBarHeightNotifier,
|
appBarHeightNotifier: appBarHeightNotifier,
|
||||||
sortFactor: widget.sortFactor,
|
sortFactor: widget.sortFactor,
|
||||||
|
@ -313,7 +317,7 @@ class _FilterSectionedContentState<T extends CollectionFilter> extends State<_Fi
|
||||||
);
|
);
|
||||||
|
|
||||||
final scaler = _FilterScaler<T>(
|
final scaler = _FilterScaler<T>(
|
||||||
scrollableKey: _scrollableKey,
|
scrollableKey: scrollableKey,
|
||||||
appBarHeightNotifier: appBarHeightNotifier,
|
appBarHeightNotifier: appBarHeightNotifier,
|
||||||
child: scrollView,
|
child: scrollView,
|
||||||
);
|
);
|
||||||
|
@ -328,33 +332,10 @@ class _FilterSectionedContentState<T extends CollectionFilter> extends State<_Fi
|
||||||
final gridItem = visibleFilterSections.values.expand((list) => list).firstWhereOrNull((gridItem) => gridItem.filter == filter);
|
final gridItem = visibleFilterSections.values.expand((list) => list).firstWhereOrNull((gridItem) => gridItem.filter == filter);
|
||||||
if (gridItem != null) {
|
if (gridItem != null) {
|
||||||
await Future.delayed(Durations.highlightScrollInitDelay);
|
await Future.delayed(Durations.highlightScrollInitDelay);
|
||||||
final sectionedListLayout = context.read<SectionedListLayout<FilterGridItem<T>>>();
|
highlightInfo.trackItem(gridItem, animate: true, highlight: filter);
|
||||||
final tileRect = sectionedListLayout.getTileRect(gridItem);
|
|
||||||
if (tileRect != null) {
|
|
||||||
await _scrollToItem(tileRect);
|
|
||||||
highlightInfo.set(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 {
|
class _FilterScaler<T extends CollectionFilter> extends StatelessWidget {
|
||||||
|
@ -374,7 +355,6 @@ class _FilterScaler<T extends CollectionFilter> extends StatelessWidget {
|
||||||
final tileSpacing = context.select<TileExtentController, double>((controller) => controller.spacing);
|
final tileSpacing = context.select<TileExtentController, double>((controller) => controller.spacing);
|
||||||
return GridScaleGestureDetector<FilterGridItem<T>>(
|
return GridScaleGestureDetector<FilterGridItem<T>>(
|
||||||
scrollableKey: scrollableKey,
|
scrollableKey: scrollableKey,
|
||||||
appBarHeightNotifier: appBarHeightNotifier,
|
|
||||||
gridBuilder: (center, extent, child) => CustomPaint(
|
gridBuilder: (center, extent, child) => CustomPaint(
|
||||||
painter: GridPainter(
|
painter: GridPainter(
|
||||||
center: center,
|
center: center,
|
||||||
|
@ -396,11 +376,7 @@ class _FilterScaler<T extends CollectionFilter> extends StatelessWidget {
|
||||||
highlightable: false,
|
highlightable: false,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
getScaledItemTileRect: (context, item) {
|
highlightItem: (item) => item.filter,
|
||||||
final sectionedListLayout = context.read<SectionedListLayout<FilterGridItem<T>>>();
|
|
||||||
return sectionedListLayout.getTileRect(item) ?? Rect.zero;
|
|
||||||
},
|
|
||||||
onScaled: (item) => context.read<HighlightInfo>().set(item.filter),
|
|
||||||
child: child,
|
child: child,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in a new issue