diff --git a/lib/model/source/collection_lens.dart b/lib/model/source/collection_lens.dart index cda295720..95eb0a33e 100644 --- a/lib/model/source/collection_lens.dart +++ b/lib/model/source/collection_lens.dart @@ -62,19 +62,19 @@ class CollectionLens with ChangeNotifier { break; case MoveType.move: case MoveType.fromBin: - _refresh(); + refresh(); break; case MoveType.toBin: _onEntryRemoved(e.entries); break; } })); - _subscriptions.add(sourceEvents.on().listen((e) => _refresh())); - _subscriptions.add(sourceEvents.on().listen((e) => _refresh())); - _subscriptions.add(sourceEvents.on().listen((e) => _refresh())); + _subscriptions.add(sourceEvents.on().listen((e) => refresh())); + _subscriptions.add(sourceEvents.on().listen((e) => refresh())); + _subscriptions.add(sourceEvents.on().listen((e) => refresh())); _subscriptions.add(sourceEvents.on().listen((e) { if (this.filters.any((filter) => filter is LocationFilter)) { - _refresh(); + refresh(); } })); favourites.addListener(_onFavouritesChanged); @@ -85,7 +85,7 @@ class CollectionLens with ChangeNotifier { Settings.collectionGroupFactorKey, ].contains(event.key)) .listen((_) => _onSettingsChanged())); - _refresh(); + refresh(); } @override @@ -171,7 +171,7 @@ class CollectionLens with ChangeNotifier { } void _onFilterChanged() { - _refresh(); + refresh(); filterChangeNotifier.notifyListeners(); } @@ -259,7 +259,7 @@ class CollectionLens with ChangeNotifier { // metadata change should also trigger a full refresh // as dates impact sorting and sectioning - void _refresh() { + void refresh() { _applyFilters(); _applySort(); _applySection(); @@ -267,7 +267,7 @@ class CollectionLens with ChangeNotifier { void _onFavouritesChanged() { if (filters.any((filter) => filter is FavouriteFilter)) { - _refresh(); + refresh(); } } @@ -292,7 +292,7 @@ class CollectionLens with ChangeNotifier { } void _onEntryAdded(Set? entries) { - _refresh(); + refresh(); } void _onEntryRemoved(Set entries) { diff --git a/lib/widgets/common/action_mixins/entry_storage.dart b/lib/widgets/common/action_mixins/entry_storage.dart index 3536b49b5..11a0b11be 100644 --- a/lib/widgets/common/action_mixins/entry_storage.dart +++ b/lib/widgets/common/action_mixins/entry_storage.dart @@ -27,6 +27,7 @@ import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/dialogs/aves_confirmation_dialog.dart'; import 'package:aves/widgets/dialogs/aves_selection_dialog.dart'; import 'package:aves/widgets/filter_grids/album_pick.dart'; +import 'package:aves/widgets/viewer/notifications.dart'; import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; @@ -36,6 +37,7 @@ mixin EntryStorageMixin on FeedbackMixin, PermissionAwareMixin, SizeAwareMixin { BuildContext context, { required MoveType moveType, required Set entries, + bool hideShowAction = false, VoidCallback? onSuccess, }) async { final todoCount = entries.length; @@ -128,8 +130,9 @@ mixin EntryStorageMixin on FeedbackMixin, PermissionAwareMixin, SizeAwareMixin { itemCount: todoCount, onCancel: () => mediaFileService.cancelFileOp(opId), onDone: (processed) async { - final successOps = processed.where((e) => e.success).toSet(); - final movedOps = successOps.where((e) => !e.skipped).toSet(); + final successOps = processed.where((v) => v.success).toSet(); + final movedOps = successOps.where((v) => !v.skipped).toSet(); + final movedEntries = movedOps.map((v) => v.uri).map((uri) => entries.firstWhereOrNull((entry) => entry.uri == uri)).whereNotNull().toSet(); await source.updateAfterMove( todoEntries: entries, moveType: moveType, @@ -152,51 +155,34 @@ mixin EntryStorageMixin on FeedbackMixin, PermissionAwareMixin, SizeAwareMixin { final appMode = context.read?>()?.value; SnackBarAction? action; - if (count > 0 && appMode == AppMode.main && !toBin) { - action = SnackBarAction( - label: l10n.showButtonLabel, - onPressed: () async { - final newUris = movedOps.map((v) => v.newFields['uri'] as String?).toSet(); - bool highlightTest(AvesEntry entry) => newUris.contains(entry.uri); - - final collection = context.read(); - if (collection == null || collection.filters.any((f) => f is AlbumFilter || f is TrashFilter)) { - final targetFilters = collection?.filters.where((f) => f != TrashFilter.instance).toSet() ?? {}; - // we could simply add the filter to the current collection - // but navigating makes the change less jarring - if (destinationAlbums.length == 1) { - final destinationAlbum = destinationAlbums.single; - targetFilters.removeWhere((f) => f is AlbumFilter); - targetFilters.add(AlbumFilter(destinationAlbum, source.getAlbumDisplayName(context, destinationAlbum))); - } - unawaited(Navigator.pushAndRemoveUntil( + if (count > 0 && appMode == AppMode.main) { + if (toBin) { + if (movedEntries.isNotEmpty) { + action = SnackBarAction( + // TODO TLAD [l10n] key for "RESTORE" + label: l10n.entryActionRestore.toUpperCase(), + onPressed: () => move( context, - MaterialPageRoute( - settings: const RouteSettings(name: CollectionPage.routeName), - builder: (context) => CollectionPage( - source: source, - filters: targetFilters, - highlightTest: highlightTest, - ), - ), - (route) => false, - )); - } else { - // track in current page, without navigation - await Future.delayed(Durations.highlightScrollInitDelay); - final targetEntry = collection.sortedEntries.firstWhereOrNull(highlightTest); - if (targetEntry != null) { - context.read().trackItem(targetEntry, highlightItem: targetEntry); - } - } - }, - ); + moveType: MoveType.fromBin, + entries: movedEntries, + hideShowAction: true, + ), + ); + } + } else if (!hideShowAction) { + action = SnackBarAction( + label: l10n.showButtonLabel, + onPressed: () => _showMovedItems(context, destinationAlbums, movedOps), + ); + } } showFeedback( context, copy ? l10n.collectionCopySuccessFeedback(count) : l10n.collectionMoveSuccessFeedback(count), action, ); + + EntryMovedNotification(moveType, movedEntries).dispatch(context); onSuccess?.call(); } }, @@ -280,6 +266,47 @@ mixin EntryStorageMixin on FeedbackMixin, PermissionAwareMixin, SizeAwareMixin { } return true; } + + Future _showMovedItems( + BuildContext context, + Set destinationAlbums, + Set movedOps, + ) async { + final newUris = movedOps.map((v) => v.newFields['uri'] as String?).toSet(); + bool highlightTest(AvesEntry entry) => newUris.contains(entry.uri); + + final collection = context.read(); + if (collection == null || collection.filters.any((f) => f is AlbumFilter || f is TrashFilter)) { + final source = context.read(); + final targetFilters = collection?.filters.where((f) => f != TrashFilter.instance).toSet() ?? {}; + // we could simply add the filter to the current collection + // but navigating makes the change less jarring + if (destinationAlbums.length == 1) { + final destinationAlbum = destinationAlbums.single; + targetFilters.removeWhere((f) => f is AlbumFilter); + targetFilters.add(AlbumFilter(destinationAlbum, source.getAlbumDisplayName(context, destinationAlbum))); + } + unawaited(Navigator.pushAndRemoveUntil( + context, + MaterialPageRoute( + settings: const RouteSettings(name: CollectionPage.routeName), + builder: (context) => CollectionPage( + source: source, + filters: targetFilters, + highlightTest: highlightTest, + ), + ), + (route) => false, + )); + } else { + // track in current page, without navigation + await Future.delayed(Durations.highlightScrollInitDelay); + final targetEntry = collection.sortedEntries.firstWhereOrNull(highlightTest); + if (targetEntry != null) { + context.read().trackItem(targetEntry, highlightItem: targetEntry); + } + } + } } class MoveUndatedConfirmationDialogDelegate extends ConfirmationDialogDelegate { diff --git a/lib/widgets/common/action_mixins/feedback.dart b/lib/widgets/common/action_mixins/feedback.dart index 966096f1e..ff483618c 100644 --- a/lib/widgets/common/action_mixins/feedback.dart +++ b/lib/widgets/common/action_mixins/feedback.dart @@ -50,20 +50,34 @@ mixin FeedbackMixin { // and space under the snack bar `margin` does not receive gestures // (because it is used by the `Dismissible` wrapping the snack bar) // so we use `showOverlayNotification` instead - showOverlayNotification( + OverlaySupportEntry? notificationOverlayEntry; + notificationOverlayEntry = showOverlayNotification( (context) => SafeArea( child: Padding( padding: margin, child: SnackBar( content: snackBarContent, animation: const AlwaysStoppedAnimation(1), - action: action, + action: action != null + ? SnackBarAction( + label: action.label, + onPressed: () { + // the regular snack bar dismiss behavior is confused + // because it expects a `Scaffold` in context, + // so we manually dimiss the overlay entry + notificationOverlayEntry?.dismiss(); + action.onPressed(); + }, + ) + : null, duration: duration, dismissDirection: DismissDirection.horizontal, ), ), ), duration: duration, + // reuse the same key to dismiss previous snack bar when a new one is shown + key: const Key('snack'), position: NotificationPosition.bottom, context: context, ); diff --git a/lib/widgets/common/map/buttons.dart b/lib/widgets/common/map/buttons.dart index 68595fd76..976f5561b 100644 --- a/lib/widgets/common/map/buttons.dart +++ b/lib/widgets/common/map/buttons.dart @@ -15,7 +15,7 @@ import 'package:aves/widgets/common/map/compass.dart'; import 'package:aves/widgets/common/map/theme.dart'; import 'package:aves/widgets/common/map/zoomed_bounds.dart'; import 'package:aves/widgets/dialogs/aves_selection_dialog.dart'; -import 'package:aves/widgets/viewer/info/notifications.dart'; +import 'package:aves/widgets/viewer/notifications.dart'; import 'package:flutter/material.dart'; import 'package:latlong2/latlong.dart'; import 'package:provider/provider.dart'; diff --git a/lib/widgets/map/map_page.dart b/lib/widgets/map/map_page.dart index 8d096677c..000557b94 100644 --- a/lib/widgets/map/map_page.dart +++ b/lib/widgets/map/map_page.dart @@ -26,7 +26,7 @@ import 'package:aves/widgets/common/providers/media_query_data_provider.dart'; import 'package:aves/widgets/common/thumbnail/scroller.dart'; import 'package:aves/widgets/map/map_info_row.dart'; import 'package:aves/widgets/viewer/entry_viewer_page.dart'; -import 'package:aves/widgets/viewer/info/notifications.dart'; +import 'package:aves/widgets/viewer/notifications.dart'; import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:flutter/scheduler.dart'; diff --git a/lib/widgets/viewer/action/entry_action_delegate.dart b/lib/widgets/viewer/action/entry_action_delegate.dart index aaac98e8d..10decf912 100644 --- a/lib/widgets/viewer/action/entry_action_delegate.dart +++ b/lib/widgets/viewer/action/entry_action_delegate.dart @@ -31,7 +31,7 @@ import 'package:aves/widgets/filter_grids/album_pick.dart'; import 'package:aves/widgets/viewer/action/printer.dart'; import 'package:aves/widgets/viewer/action/single_entry_editor.dart'; import 'package:aves/widgets/viewer/debug/debug_page.dart'; -import 'package:aves/widgets/viewer/info/notifications.dart'; +import 'package:aves/widgets/viewer/notifications.dart'; import 'package:aves/widgets/viewer/overlay/notifications.dart'; import 'package:aves/widgets/viewer/source_viewer_page.dart'; import 'package:aves/widgets/viewer/video/conductor.dart'; @@ -205,7 +205,7 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix if (source.initState != SourceInitializationState.none) { await source.removeEntries({entry.uri}, includeTrash: true); } - EntryRemovedNotification(entry).dispatch(context); + EntryDeletedNotification({entry}).dispatch(context); } } @@ -299,20 +299,11 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix ); } - Future _move(BuildContext context, {required MoveType moveType}) async { - await move( - context, - moveType: moveType, - entries: {entry}, - onSuccess: { - MoveType.move, - MoveType.toBin, - MoveType.fromBin, - }.contains(moveType) - ? () => EntryRemovedNotification(entry).dispatch(context) - : null, - ); - } + Future _move(BuildContext context, {required MoveType moveType}) => move( + context, + moveType: moveType, + entries: {entry}, + ); Future _rename(BuildContext context) async { final newName = await showDialog( diff --git a/lib/widgets/viewer/entry_vertical_pager.dart b/lib/widgets/viewer/entry_vertical_pager.dart index 59866e2cf..247de072a 100644 --- a/lib/widgets/viewer/entry_vertical_pager.dart +++ b/lib/widgets/viewer/entry_vertical_pager.dart @@ -9,7 +9,7 @@ import 'package:aves/theme/durations.dart'; import 'package:aves/widgets/common/magnifier/pan/scroll_physics.dart'; import 'package:aves/widgets/viewer/entry_horizontal_pager.dart'; import 'package:aves/widgets/viewer/info/info_page.dart'; -import 'package:aves/widgets/viewer/info/notifications.dart'; +import 'package:aves/widgets/viewer/notifications.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:screen_brightness/screen_brightness.dart'; diff --git a/lib/widgets/viewer/entry_viewer_stack.dart b/lib/widgets/viewer/entry_viewer_stack.dart index 3773c9e4e..4d7fbb395 100644 --- a/lib/widgets/viewer/entry_viewer_stack.dart +++ b/lib/widgets/viewer/entry_viewer_stack.dart @@ -1,9 +1,11 @@ import 'dart:math'; import 'package:aves/app_mode.dart'; +import 'package:aves/model/actions/move_type.dart'; import 'package:aves/model/device.dart'; import 'package:aves/model/entry.dart'; import 'package:aves/model/filters/filters.dart'; +import 'package:aves/model/filters/trash.dart'; import 'package:aves/model/highlight.dart'; import 'package:aves/model/settings/enums/enums.dart'; import 'package:aves/model/settings/settings.dart'; @@ -16,9 +18,9 @@ import 'package:aves/widgets/common/action_mixins/feedback.dart'; import 'package:aves/widgets/common/basic/insets.dart'; import 'package:aves/widgets/viewer/entry_vertical_pager.dart'; import 'package:aves/widgets/viewer/hero.dart'; -import 'package:aves/widgets/viewer/info/notifications.dart'; import 'package:aves/widgets/viewer/multipage/conductor.dart'; import 'package:aves/widgets/viewer/multipage/controller.dart'; +import 'package:aves/widgets/viewer/notifications.dart'; import 'package:aves/widgets/viewer/overlay/bottom.dart'; import 'package:aves/widgets/viewer/overlay/notifications.dart'; import 'package:aves/widgets/viewer/overlay/panorama.dart'; @@ -195,8 +197,33 @@ class _EntryViewerStackState extends State with FeedbackMixin, onNotification: (dynamic notification) { if (notification is FilterSelectedNotification) { _goToCollection(notification.filter); - } else if (notification is EntryRemovedNotification) { - _onEntryRemoved(context, notification.entry); + } else if (notification is EntryDeletedNotification) { + _onEntryRemoved(context, notification.entries); + } else if (notification is EntryMovedNotification) { + // only add or remove entries following user actions, + // instead of applying all collection source changes + final isBin = collection?.filters.contains(TrashFilter.instance) ?? false; + final entries = notification.entries; + switch (notification.moveType) { + case MoveType.move: + _onEntryRemoved(context, entries); + break; + case MoveType.toBin: + if (!isBin) { + _onEntryRemoved(context, entries); + } + break; + case MoveType.fromBin: + if (isBin) { + _onEntryRemoved(context, entries); + } else { + _onEntryRestored(entries); + } + break; + case MoveType.copy: + case MoveType.export: + break; + } } else if (notification is ToggleOverlayNotification) { _overlayVisible.value = notification.visible ?? !_overlayVisible.value; } else if (notification is ShowInfoNotification) { @@ -457,12 +484,28 @@ class _EntryViewerStackState extends State with FeedbackMixin, _updateEntry(); } - void _onEntryRemoved(BuildContext context, AvesEntry entry) { - // deleted or moved to another album + void _onEntryRestored(Set restoredEntries) { + if (restoredEntries.isEmpty) return; + + final _collection = collection; + if (_collection != null) { + _collection.refresh(); + final index = _collection.sortedEntries.indexOf(restoredEntries.first); + if (index != -1) { + _onHorizontalPageChanged(index); + } + _onCollectionChange(); + } + } + + // deleted or moved to another album + void _onEntryRemoved(BuildContext context, Set removedEntries) { + if (removedEntries.isEmpty) return; + if (hasCollection) { - final entries = collection!.sortedEntries; - entries.remove(entry); - if (entries.isNotEmpty) { + final collectionEntries = collection!.sortedEntries; + removedEntries.forEach(collectionEntries.remove); + if (collectionEntries.isNotEmpty) { _onCollectionChange(); return; } diff --git a/lib/widgets/viewer/info/info_page.dart b/lib/widgets/viewer/info/info_page.dart index 5dc154de2..67e0b0b6c 100644 --- a/lib/widgets/viewer/info/info_page.dart +++ b/lib/widgets/viewer/info/info_page.dart @@ -14,8 +14,8 @@ import 'package:aves/widgets/viewer/info/basic_section.dart'; import 'package:aves/widgets/viewer/info/info_app_bar.dart'; import 'package:aves/widgets/viewer/info/location_section.dart'; import 'package:aves/widgets/viewer/info/metadata/metadata_section.dart'; -import 'package:aves/widgets/viewer/info/notifications.dart'; import 'package:aves/widgets/viewer/multipage/conductor.dart'; +import 'package:aves/widgets/viewer/notifications.dart'; import 'package:aves/widgets/viewer/page_entry_builder.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; diff --git a/lib/widgets/viewer/info/notifications.dart b/lib/widgets/viewer/info/notifications.dart deleted file mode 100644 index 38e96d70e..000000000 --- a/lib/widgets/viewer/info/notifications.dart +++ /dev/null @@ -1,24 +0,0 @@ -import 'package:aves/model/entry.dart'; -import 'package:aves/model/filters/filters.dart'; -import 'package:flutter/widgets.dart'; - -@immutable -class ShowImageNotification extends Notification {} - -@immutable -class ShowInfoNotification extends Notification {} - -@immutable -class FilterSelectedNotification extends Notification { - final CollectionFilter filter; - - const FilterSelectedNotification(this.filter); -} - -// deleted or moved to another album -@immutable -class EntryRemovedNotification extends Notification { - final AvesEntry entry; - - const EntryRemovedNotification(this.entry); -} diff --git a/lib/widgets/viewer/notifications.dart b/lib/widgets/viewer/notifications.dart new file mode 100644 index 000000000..4f64182ef --- /dev/null +++ b/lib/widgets/viewer/notifications.dart @@ -0,0 +1,42 @@ +import 'package:aves/model/actions/move_type.dart'; +import 'package:aves/model/entry.dart'; +import 'package:aves/model/filters/filters.dart'; +import 'package:equatable/equatable.dart'; +import 'package:flutter/widgets.dart'; + +@immutable +class ShowImageNotification extends Notification {} + +@immutable +class ShowInfoNotification extends Notification {} + +@immutable +class FilterSelectedNotification extends Notification with EquatableMixin { + final CollectionFilter filter; + + @override + List get props => [filter]; + + const FilterSelectedNotification(this.filter); +} + +@immutable +class EntryDeletedNotification extends Notification with EquatableMixin { + final Set entries; + + @override + List get props => [entries]; + + const EntryDeletedNotification(this.entries); +} + +@immutable +class EntryMovedNotification extends Notification with EquatableMixin { + final MoveType moveType; + final Set entries; + + @override + List get props => [moveType, entries]; + + const EntryMovedNotification(this.moveType, this.entries); +}