#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;
case MoveType.move:
case MoveType.fromBin:
_refresh();
refresh();
break;
case MoveType.toBin:
_onEntryRemoved(e.entries);
break;
}
}));
_subscriptions.add(sourceEvents.on<EntryRefreshedEvent>().listen((e) => _refresh()));
_subscriptions.add(sourceEvents.on<FilterVisibilityChangedEvent>().listen((e) => _refresh()));
_subscriptions.add(sourceEvents.on<CatalogMetadataChangedEvent>().listen((e) => _refresh()));
_subscriptions.add(sourceEvents.on<EntryRefreshedEvent>().listen((e) => refresh()));
_subscriptions.add(sourceEvents.on<FilterVisibilityChangedEvent>().listen((e) => refresh()));
_subscriptions.add(sourceEvents.on<CatalogMetadataChangedEvent>().listen((e) => refresh()));
_subscriptions.add(sourceEvents.on<AddressMetadataChangedEvent>().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<AvesEntry>? entries) {
_refresh();
refresh();
}
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_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<AvesEntry> 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<ValueNotifier<AppMode>?>()?.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<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(
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<HighlightInfo>().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<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 {

View file

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

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/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';

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/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';

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/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<void> _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<void> _move(BuildContext context, {required MoveType moveType}) => move(
context,
moveType: moveType,
entries: {entry},
);
Future<void> _rename(BuildContext context) async {
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/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';

View file

@ -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<EntryViewerStack> 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<EntryViewerStack> with FeedbackMixin,
_updateEntry();
}
void _onEntryRemoved(BuildContext context, AvesEntry entry) {
// deleted or moved to another album
void _onEntryRestored(Set<AvesEntry> 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<AvesEntry> 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;
}

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/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';

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