#225 bin: restore action in snack bar following move

This commit is contained in:
Thibault Deckers 2022-04-18 18:49:25 +09:00
parent edce0cbcca
commit 8e0b1b495e
11 changed files with 197 additions and 104 deletions

View file

@ -62,19 +62,19 @@ class CollectionLens with ChangeNotifier {
break; break;
case MoveType.move: case MoveType.move:
case MoveType.fromBin: case MoveType.fromBin:
_refresh(); refresh();
break; break;
case MoveType.toBin: case MoveType.toBin:
_onEntryRemoved(e.entries); _onEntryRemoved(e.entries);
break; break;
} }
})); }));
_subscriptions.add(sourceEvents.on<EntryRefreshedEvent>().listen((e) => _refresh())); _subscriptions.add(sourceEvents.on<EntryRefreshedEvent>().listen((e) => refresh()));
_subscriptions.add(sourceEvents.on<FilterVisibilityChangedEvent>().listen((e) => _refresh())); _subscriptions.add(sourceEvents.on<FilterVisibilityChangedEvent>().listen((e) => refresh()));
_subscriptions.add(sourceEvents.on<CatalogMetadataChangedEvent>().listen((e) => _refresh())); _subscriptions.add(sourceEvents.on<CatalogMetadataChangedEvent>().listen((e) => refresh()));
_subscriptions.add(sourceEvents.on<AddressMetadataChangedEvent>().listen((e) { _subscriptions.add(sourceEvents.on<AddressMetadataChangedEvent>().listen((e) {
if (this.filters.any((filter) => filter is LocationFilter)) { if (this.filters.any((filter) => filter is LocationFilter)) {
_refresh(); refresh();
} }
})); }));
favourites.addListener(_onFavouritesChanged); favourites.addListener(_onFavouritesChanged);
@ -85,7 +85,7 @@ class CollectionLens with ChangeNotifier {
Settings.collectionGroupFactorKey, Settings.collectionGroupFactorKey,
].contains(event.key)) ].contains(event.key))
.listen((_) => _onSettingsChanged())); .listen((_) => _onSettingsChanged()));
_refresh(); refresh();
} }
@override @override
@ -171,7 +171,7 @@ class CollectionLens with ChangeNotifier {
} }
void _onFilterChanged() { void _onFilterChanged() {
_refresh(); refresh();
filterChangeNotifier.notifyListeners(); filterChangeNotifier.notifyListeners();
} }
@ -259,7 +259,7 @@ class CollectionLens with ChangeNotifier {
// metadata change should also trigger a full refresh // metadata change should also trigger a full refresh
// as dates impact sorting and sectioning // as dates impact sorting and sectioning
void _refresh() { void refresh() {
_applyFilters(); _applyFilters();
_applySort(); _applySort();
_applySection(); _applySection();
@ -267,7 +267,7 @@ class CollectionLens with ChangeNotifier {
void _onFavouritesChanged() { void _onFavouritesChanged() {
if (filters.any((filter) => filter is FavouriteFilter)) { if (filters.any((filter) => filter is FavouriteFilter)) {
_refresh(); refresh();
} }
} }
@ -292,7 +292,7 @@ class CollectionLens with ChangeNotifier {
} }
void _onEntryAdded(Set<AvesEntry>? entries) { void _onEntryAdded(Set<AvesEntry>? entries) {
_refresh(); refresh();
} }
void _onEntryRemoved(Set<AvesEntry> entries) { void _onEntryRemoved(Set<AvesEntry> entries) {

View file

@ -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_confirmation_dialog.dart';
import 'package:aves/widgets/dialogs/aves_selection_dialog.dart'; import 'package:aves/widgets/dialogs/aves_selection_dialog.dart';
import 'package:aves/widgets/filter_grids/album_pick.dart'; import 'package:aves/widgets/filter_grids/album_pick.dart';
import 'package:aves/widgets/viewer/notifications.dart';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
@ -36,6 +37,7 @@ mixin EntryStorageMixin on FeedbackMixin, PermissionAwareMixin, SizeAwareMixin {
BuildContext context, { BuildContext context, {
required MoveType moveType, required MoveType moveType,
required Set<AvesEntry> entries, required Set<AvesEntry> entries,
bool hideShowAction = false,
VoidCallback? onSuccess, VoidCallback? onSuccess,
}) async { }) async {
final todoCount = entries.length; final todoCount = entries.length;
@ -128,8 +130,9 @@ mixin EntryStorageMixin on FeedbackMixin, PermissionAwareMixin, SizeAwareMixin {
itemCount: todoCount, itemCount: todoCount,
onCancel: () => mediaFileService.cancelFileOp(opId), onCancel: () => mediaFileService.cancelFileOp(opId),
onDone: (processed) async { onDone: (processed) async {
final successOps = processed.where((e) => e.success).toSet(); final successOps = processed.where((v) => v.success).toSet();
final movedOps = successOps.where((e) => !e.skipped).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( await source.updateAfterMove(
todoEntries: entries, todoEntries: entries,
moveType: moveType, moveType: moveType,
@ -152,51 +155,34 @@ mixin EntryStorageMixin on FeedbackMixin, PermissionAwareMixin, SizeAwareMixin {
final appMode = context.read<ValueNotifier<AppMode>?>()?.value; final appMode = context.read<ValueNotifier<AppMode>?>()?.value;
SnackBarAction? action; SnackBarAction? action;
if (count > 0 && appMode == AppMode.main && !toBin) { if (count > 0 && appMode == AppMode.main) {
action = SnackBarAction( if (toBin) {
label: l10n.showButtonLabel, if (movedEntries.isNotEmpty) {
onPressed: () async { action = SnackBarAction(
final newUris = movedOps.map((v) => v.newFields['uri'] as String?).toSet(); // TODO TLAD [l10n] key for "RESTORE"
bool highlightTest(AvesEntry entry) => newUris.contains(entry.uri); label: l10n.entryActionRestore.toUpperCase(),
onPressed: () => move(
final collection = context.read<CollectionLens?>();
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(
context, context,
MaterialPageRoute( moveType: MoveType.fromBin,
settings: const RouteSettings(name: CollectionPage.routeName), entries: movedEntries,
builder: (context) => CollectionPage( hideShowAction: true,
source: source, ),
filters: targetFilters, );
highlightTest: highlightTest, }
), } else if (!hideShowAction) {
), action = SnackBarAction(
(route) => false, label: l10n.showButtonLabel,
)); onPressed: () => _showMovedItems(context, destinationAlbums, movedOps),
} else { );
// track in current page, without navigation }
await Future.delayed(Durations.highlightScrollInitDelay);
final targetEntry = collection.sortedEntries.firstWhereOrNull(highlightTest);
if (targetEntry != null) {
context.read<HighlightInfo>().trackItem(targetEntry, highlightItem: targetEntry);
}
}
},
);
} }
showFeedback( showFeedback(
context, context,
copy ? l10n.collectionCopySuccessFeedback(count) : l10n.collectionMoveSuccessFeedback(count), copy ? l10n.collectionCopySuccessFeedback(count) : l10n.collectionMoveSuccessFeedback(count),
action, action,
); );
EntryMovedNotification(moveType, movedEntries).dispatch(context);
onSuccess?.call(); onSuccess?.call();
} }
}, },
@ -280,6 +266,47 @@ mixin EntryStorageMixin on FeedbackMixin, PermissionAwareMixin, SizeAwareMixin {
} }
return true; return true;
} }
Future<void> _showMovedItems(
BuildContext context,
Set<String> destinationAlbums,
Set<MoveOpEvent> 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<CollectionLens?>();
if (collection == null || collection.filters.any((f) => f is AlbumFilter || f is TrashFilter)) {
final source = context.read<CollectionSource>();
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<HighlightInfo>().trackItem(targetEntry, highlightItem: targetEntry);
}
}
}
} }
class MoveUndatedConfirmationDialogDelegate extends ConfirmationDialogDelegate { class MoveUndatedConfirmationDialogDelegate extends ConfirmationDialogDelegate {

View file

@ -50,20 +50,34 @@ mixin FeedbackMixin {
// and space under the snack bar `margin` does not receive gestures // and space under the snack bar `margin` does not receive gestures
// (because it is used by the `Dismissible` wrapping the snack bar) // (because it is used by the `Dismissible` wrapping the snack bar)
// so we use `showOverlayNotification` instead // so we use `showOverlayNotification` instead
showOverlayNotification( OverlaySupportEntry? notificationOverlayEntry;
notificationOverlayEntry = showOverlayNotification(
(context) => SafeArea( (context) => SafeArea(
child: Padding( child: Padding(
padding: margin, padding: margin,
child: SnackBar( child: SnackBar(
content: snackBarContent, content: snackBarContent,
animation: const AlwaysStoppedAnimation<double>(1), animation: const AlwaysStoppedAnimation<double>(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, duration: duration,
dismissDirection: DismissDirection.horizontal, dismissDirection: DismissDirection.horizontal,
), ),
), ),
), ),
duration: duration, duration: duration,
// reuse the same key to dismiss previous snack bar when a new one is shown
key: const Key('snack'),
position: NotificationPosition.bottom, position: NotificationPosition.bottom,
context: context, context: context,
); );

View file

@ -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/theme.dart';
import 'package:aves/widgets/common/map/zoomed_bounds.dart'; import 'package:aves/widgets/common/map/zoomed_bounds.dart';
import 'package:aves/widgets/dialogs/aves_selection_dialog.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:flutter/material.dart';
import 'package:latlong2/latlong.dart'; import 'package:latlong2/latlong.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';

View file

@ -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/common/thumbnail/scroller.dart';
import 'package:aves/widgets/map/map_info_row.dart'; import 'package:aves/widgets/map/map_info_row.dart';
import 'package:aves/widgets/viewer/entry_viewer_page.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:collection/collection.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart'; import 'package:flutter/scheduler.dart';

View file

@ -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/printer.dart';
import 'package:aves/widgets/viewer/action/single_entry_editor.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/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/overlay/notifications.dart';
import 'package:aves/widgets/viewer/source_viewer_page.dart'; import 'package:aves/widgets/viewer/source_viewer_page.dart';
import 'package:aves/widgets/viewer/video/conductor.dart'; import 'package:aves/widgets/viewer/video/conductor.dart';
@ -205,7 +205,7 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix
if (source.initState != SourceInitializationState.none) { if (source.initState != SourceInitializationState.none) {
await source.removeEntries({entry.uri}, includeTrash: true); 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<void> _move(BuildContext context, {required MoveType moveType}) async { Future<void> _move(BuildContext context, {required MoveType moveType}) => move(
await move( context,
context, moveType: moveType,
moveType: moveType, entries: {entry},
entries: {entry}, );
onSuccess: {
MoveType.move,
MoveType.toBin,
MoveType.fromBin,
}.contains(moveType)
? () => EntryRemovedNotification(entry).dispatch(context)
: null,
);
}
Future<void> _rename(BuildContext context) async { Future<void> _rename(BuildContext context) async {
final newName = await showDialog<String>( final newName = await showDialog<String>(

View file

@ -9,7 +9,7 @@ import 'package:aves/theme/durations.dart';
import 'package:aves/widgets/common/magnifier/pan/scroll_physics.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/entry_horizontal_pager.dart';
import 'package:aves/widgets/viewer/info/info_page.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/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:screen_brightness/screen_brightness.dart'; import 'package:screen_brightness/screen_brightness.dart';

View file

@ -1,9 +1,11 @@
import 'dart:math'; import 'dart:math';
import 'package:aves/app_mode.dart'; import 'package:aves/app_mode.dart';
import 'package:aves/model/actions/move_type.dart';
import 'package:aves/model/device.dart'; import 'package:aves/model/device.dart';
import 'package:aves/model/entry.dart'; import 'package:aves/model/entry.dart';
import 'package:aves/model/filters/filters.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/highlight.dart';
import 'package:aves/model/settings/enums/enums.dart'; import 'package:aves/model/settings/enums/enums.dart';
import 'package:aves/model/settings/settings.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/common/basic/insets.dart';
import 'package:aves/widgets/viewer/entry_vertical_pager.dart'; import 'package:aves/widgets/viewer/entry_vertical_pager.dart';
import 'package:aves/widgets/viewer/hero.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/conductor.dart';
import 'package:aves/widgets/viewer/multipage/controller.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/bottom.dart';
import 'package:aves/widgets/viewer/overlay/notifications.dart'; import 'package:aves/widgets/viewer/overlay/notifications.dart';
import 'package:aves/widgets/viewer/overlay/panorama.dart'; import 'package:aves/widgets/viewer/overlay/panorama.dart';
@ -195,8 +197,33 @@ class _EntryViewerStackState extends State<EntryViewerStack> with FeedbackMixin,
onNotification: (dynamic notification) { onNotification: (dynamic notification) {
if (notification is FilterSelectedNotification) { if (notification is FilterSelectedNotification) {
_goToCollection(notification.filter); _goToCollection(notification.filter);
} else if (notification is EntryRemovedNotification) { } else if (notification is EntryDeletedNotification) {
_onEntryRemoved(context, notification.entry); _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) { } else if (notification is ToggleOverlayNotification) {
_overlayVisible.value = notification.visible ?? !_overlayVisible.value; _overlayVisible.value = notification.visible ?? !_overlayVisible.value;
} else if (notification is ShowInfoNotification) { } else if (notification is ShowInfoNotification) {
@ -457,12 +484,28 @@ class _EntryViewerStackState extends State<EntryViewerStack> with FeedbackMixin,
_updateEntry(); _updateEntry();
} }
void _onEntryRemoved(BuildContext context, AvesEntry entry) { void _onEntryRestored(Set<AvesEntry> restoredEntries) {
// deleted or moved to another album 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<AvesEntry> removedEntries) {
if (removedEntries.isEmpty) return;
if (hasCollection) { if (hasCollection) {
final entries = collection!.sortedEntries; final collectionEntries = collection!.sortedEntries;
entries.remove(entry); removedEntries.forEach(collectionEntries.remove);
if (entries.isNotEmpty) { if (collectionEntries.isNotEmpty) {
_onCollectionChange(); _onCollectionChange();
return; return;
} }

View file

@ -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/info_app_bar.dart';
import 'package:aves/widgets/viewer/info/location_section.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/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/multipage/conductor.dart';
import 'package:aves/widgets/viewer/notifications.dart';
import 'package:aves/widgets/viewer/page_entry_builder.dart'; import 'package:aves/widgets/viewer/page_entry_builder.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';

View file

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

View file

@ -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<Object?> get props => [filter];
const FilterSelectedNotification(this.filter);
}
@immutable
class EntryDeletedNotification extends Notification with EquatableMixin {
final Set<AvesEntry> entries;
@override
List<Object?> get props => [entries];
const EntryDeletedNotification(this.entries);
}
@immutable
class EntryMovedNotification extends Notification with EquatableMixin {
final MoveType moveType;
final Set<AvesEntry> entries;
@override
List<Object?> get props => [moveType, entries];
const EntryMovedNotification(this.moveType, this.entries);
}