#390 viewer: metadata editing actions available as quick actions

This commit is contained in:
Thibault Deckers 2022-11-22 13:23:02 +01:00
parent 093b967e26
commit 1e624aebae
14 changed files with 404 additions and 417 deletions

View file

@ -6,6 +6,7 @@ All notable changes to this project will be documented in this file.
### Added ### Added
- Viewer: Info page editing actions available as quick actions
- Info: export metadata to text file - Info: export metadata to text file
- Accessibility: apply bold font system setting - Accessibility: apply bold font system setting
- `libre` app flavor (no mobile service maps, no Crashlytics) - `libre` app flavor (no mobile service maps, no Crashlytics)

View file

@ -38,6 +38,19 @@ enum EntryAction {
setAs, setAs,
// platform // platform
rotateScreen, rotateScreen,
// metadata
editDate,
editLocation,
editTitleDescription,
editRating,
editTags,
removeMetadata,
exportMetadata,
// metadata / GeoTIFF
showGeoTiffOnMap,
// metadata / motion photo
convertMotionPhotoToStillImage,
viewMotionPhotoVideo,
// debug // debug
debug, debug,
} }
@ -99,6 +112,22 @@ class EntryActions {
EntryAction.videoSelectStreams, EntryAction.videoSelectStreams,
EntryAction.videoSettings, EntryAction.videoSettings,
]; ];
static const commonMetadataActions = [
EntryAction.editDate,
EntryAction.editLocation,
EntryAction.editTitleDescription,
EntryAction.editRating,
EntryAction.editTags,
EntryAction.removeMetadata,
EntryAction.exportMetadata,
];
static const formatSpecificMetadataActions = [
EntryAction.showGeoTiffOnMap,
EntryAction.convertMotionPhotoToStillImage,
EntryAction.viewMotionPhotoVideo,
];
} }
extension ExtraEntryAction on EntryAction { extension ExtraEntryAction on EntryAction {
@ -170,6 +199,29 @@ extension ExtraEntryAction on EntryAction {
// platform // platform
case EntryAction.rotateScreen: case EntryAction.rotateScreen:
return context.l10n.entryActionRotateScreen; return context.l10n.entryActionRotateScreen;
// metadata
case EntryAction.editDate:
return context.l10n.entryInfoActionEditDate;
case EntryAction.editLocation:
return context.l10n.entryInfoActionEditLocation;
case EntryAction.editTitleDescription:
return context.l10n.entryInfoActionEditTitleDescription;
case EntryAction.editRating:
return context.l10n.entryInfoActionEditRating;
case EntryAction.editTags:
return context.l10n.entryInfoActionEditTags;
case EntryAction.removeMetadata:
return context.l10n.entryInfoActionRemoveMetadata;
case EntryAction.exportMetadata:
return context.l10n.entryInfoActionExportMetadata;
// metadata / GeoTIFF
case EntryAction.showGeoTiffOnMap:
return context.l10n.entryActionShowGeoTiffOnMap;
// metadata / motion photo
case EntryAction.convertMotionPhotoToStillImage:
return context.l10n.entryActionConvertMotionPhotoToStillImage;
case EntryAction.viewMotionPhotoVideo:
return context.l10n.entryActionViewMotionPhotoVideo;
// debug // debug
case EntryAction.debug: case EntryAction.debug:
return 'Debug'; return 'Debug';
@ -258,6 +310,29 @@ extension ExtraEntryAction on EntryAction {
// platform // platform
case EntryAction.rotateScreen: case EntryAction.rotateScreen:
return AIcons.rotateScreen; return AIcons.rotateScreen;
// metadata
case EntryAction.editDate:
return AIcons.date;
case EntryAction.editLocation:
return AIcons.location;
case EntryAction.editTitleDescription:
return AIcons.description;
case EntryAction.editRating:
return AIcons.editRating;
case EntryAction.editTags:
return AIcons.editTags;
case EntryAction.removeMetadata:
return AIcons.clear;
case EntryAction.exportMetadata:
return AIcons.fileExport;
// metadata / GeoTIFF
case EntryAction.showGeoTiffOnMap:
return AIcons.map;
// metadata / motion photo
case EntryAction.convertMotionPhotoToStillImage:
return AIcons.convertToStillImage;
case EntryAction.viewMotionPhotoVideo:
return AIcons.openVideo;
// debug // debug
case EntryAction.debug: case EntryAction.debug:
return AIcons.debug; return AIcons.debug;

View file

@ -1,118 +0,0 @@
import 'package:aves/theme/colors.dart';
import 'package:aves/theme/icons.dart';
import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:flutter/widgets.dart';
enum EntryInfoAction {
// general
editDate,
editLocation,
editTitleDescription,
editRating,
editTags,
removeMetadata,
exportMetadata,
// GeoTIFF
showGeoTiffOnMap,
// motion photo
convertMotionPhotoToStillImage,
viewMotionPhotoVideo,
// debug
debug,
}
class EntryInfoActions {
static const common = [
EntryInfoAction.editDate,
EntryInfoAction.editLocation,
EntryInfoAction.editTitleDescription,
EntryInfoAction.editRating,
EntryInfoAction.editTags,
EntryInfoAction.removeMetadata,
EntryInfoAction.exportMetadata,
];
static const formatSpecific = [
EntryInfoAction.showGeoTiffOnMap,
EntryInfoAction.convertMotionPhotoToStillImage,
EntryInfoAction.viewMotionPhotoVideo,
];
}
extension ExtraEntryInfoAction on EntryInfoAction {
String getText(BuildContext context) {
switch (this) {
// general
case EntryInfoAction.editDate:
return context.l10n.entryInfoActionEditDate;
case EntryInfoAction.editLocation:
return context.l10n.entryInfoActionEditLocation;
case EntryInfoAction.editTitleDescription:
return context.l10n.entryInfoActionEditTitleDescription;
case EntryInfoAction.editRating:
return context.l10n.entryInfoActionEditRating;
case EntryInfoAction.editTags:
return context.l10n.entryInfoActionEditTags;
case EntryInfoAction.removeMetadata:
return context.l10n.entryInfoActionRemoveMetadata;
case EntryInfoAction.exportMetadata:
return context.l10n.entryInfoActionExportMetadata;
// GeoTIFF
case EntryInfoAction.showGeoTiffOnMap:
return context.l10n.entryActionShowGeoTiffOnMap;
// motion photo
case EntryInfoAction.convertMotionPhotoToStillImage:
return context.l10n.entryActionConvertMotionPhotoToStillImage;
case EntryInfoAction.viewMotionPhotoVideo:
return context.l10n.entryActionViewMotionPhotoVideo;
// debug
case EntryInfoAction.debug:
return 'Debug';
}
}
Widget getIcon() {
final child = Icon(_getIconData());
switch (this) {
case EntryInfoAction.debug:
return ShaderMask(
shaderCallback: AvesColorsData.debugGradient.createShader,
blendMode: BlendMode.srcIn,
child: child,
);
default:
return child;
}
}
IconData _getIconData() {
switch (this) {
// general
case EntryInfoAction.editDate:
return AIcons.date;
case EntryInfoAction.editLocation:
return AIcons.location;
case EntryInfoAction.editTitleDescription:
return AIcons.description;
case EntryInfoAction.editRating:
return AIcons.editRating;
case EntryInfoAction.editTags:
return AIcons.editTags;
case EntryInfoAction.removeMetadata:
return AIcons.clear;
case EntryInfoAction.exportMetadata:
return AIcons.fileExport;
// GeoTIFF
case EntryInfoAction.showGeoTiffOnMap:
return AIcons.map;
// motion photo
case EntryInfoAction.convertMotionPhotoToStillImage:
return AIcons.convertToStillImage;
case EntryInfoAction.viewMotionPhotoVideo:
return AIcons.openVideo;
// debug
case EntryInfoAction.debug:
return AIcons.debug;
}
}
}

View file

@ -30,6 +30,7 @@ class ViewerActionEditorPage extends StatelessWidget {
EntryAction.videoSetSpeed, EntryAction.videoSetSpeed,
EntryAction.videoSelectStreams, EntryAction.videoSelectStreams,
], ],
EntryActions.commonMetadataActions,
]; ];
@override @override

View file

@ -10,6 +10,7 @@ import 'package:aves/model/entry_metadata_edition.dart';
import 'package:aves/model/filters/album.dart'; import 'package:aves/model/filters/album.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';
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/common/image_op_events.dart'; import 'package:aves/services/common/image_op_events.dart';
import 'package:aves/services/common/services.dart'; import 'package:aves/services/common/services.dart';
@ -29,79 +30,191 @@ import 'package:aves/widgets/dialogs/aves_dialog.dart';
import 'package:aves/widgets/dialogs/entry_editors/rename_entry_dialog.dart'; import 'package:aves/widgets/dialogs/entry_editors/rename_entry_dialog.dart';
import 'package:aves/widgets/dialogs/export_entry_dialog.dart'; import 'package:aves/widgets/dialogs/export_entry_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/action/entry_info_action_delegate.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/multipage/conductor.dart';
import 'package:aves/widgets/viewer/notifications.dart'; import 'package:aves/widgets/viewer/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';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart'; import 'package:flutter/scheduler.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:tuple/tuple.dart'; import 'package:tuple/tuple.dart';
class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMixin, SingleEntryEditorMixin, EntryStorageMixin { class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMixin, SingleEntryEditorMixin, EntryStorageMixin {
@override final AvesEntry mainEntry, pageEntry;
final AvesEntry entry; final CollectionLens? collection;
final EntryInfoActionDelegate _metadataActionDelegate = EntryInfoActionDelegate();
EntryActionDelegate(this.entry); EntryActionDelegate(this.mainEntry, this.pageEntry, this.collection);
bool isVisible(EntryAction action) {
if (mainEntry.trashed) {
switch (action) {
case EntryAction.delete:
case EntryAction.restore:
return true;
case EntryAction.debug:
return kDebugMode;
default:
return false;
}
} else {
final targetEntry = EntryActions.pageActions.contains(action) ? pageEntry : mainEntry;
switch (action) {
case EntryAction.toggleFavourite:
return collection != null;
case EntryAction.delete:
case EntryAction.rename:
case EntryAction.copy:
case EntryAction.move:
return targetEntry.canEdit;
case EntryAction.rotateCCW:
case EntryAction.rotateCW:
return targetEntry.canRotate;
case EntryAction.flip:
return targetEntry.canFlip;
case EntryAction.convert:
case EntryAction.print:
return !targetEntry.isVideo && device.canPrint;
case EntryAction.openMap:
return targetEntry.hasGps;
case EntryAction.viewSource:
return targetEntry.isSvg;
case EntryAction.videoCaptureFrame:
case EntryAction.videoToggleMute:
case EntryAction.videoSelectStreams:
case EntryAction.videoSetSpeed:
case EntryAction.videoSettings:
case EntryAction.videoTogglePlay:
case EntryAction.videoReplay10:
case EntryAction.videoSkip10:
return targetEntry.isVideo;
case EntryAction.rotateScreen:
return settings.isRotationLocked;
case EntryAction.addShortcut:
return device.canPinShortcut;
case EntryAction.info:
case EntryAction.copyToClipboard:
case EntryAction.edit:
case EntryAction.open:
case EntryAction.setAs:
case EntryAction.share:
return true;
case EntryAction.restore:
return false;
case EntryAction.editDate:
case EntryAction.editLocation:
case EntryAction.editTitleDescription:
case EntryAction.editRating:
case EntryAction.editTags:
case EntryAction.removeMetadata:
case EntryAction.exportMetadata:
case EntryAction.showGeoTiffOnMap:
case EntryAction.convertMotionPhotoToStillImage:
case EntryAction.viewMotionPhotoVideo:
return _metadataActionDelegate.isVisible(targetEntry, action);
case EntryAction.debug:
return kDebugMode;
}
}
}
bool canApply(EntryAction action) {
final targetEntry = EntryActions.pageActions.contains(action) ? pageEntry : mainEntry;
switch (action) {
case EntryAction.rotateCCW:
case EntryAction.rotateCW:
return targetEntry.canRotate;
case EntryAction.flip:
return targetEntry.canFlip;
case EntryAction.editDate:
case EntryAction.editLocation:
case EntryAction.editTitleDescription:
case EntryAction.editRating:
case EntryAction.editTags:
case EntryAction.removeMetadata:
case EntryAction.exportMetadata:
case EntryAction.showGeoTiffOnMap:
case EntryAction.convertMotionPhotoToStillImage:
case EntryAction.viewMotionPhotoVideo:
return _metadataActionDelegate.canApply(targetEntry, action);
default:
return true;
}
}
void onActionSelected(BuildContext context, EntryAction action) { void onActionSelected(BuildContext context, EntryAction action) {
var targetEntry = mainEntry;
if (mainEntry.isMultiPage && (mainEntry.isBurst || EntryActions.pageActions.contains(action))) {
final multiPageController = context.read<MultiPageConductor>().getController(mainEntry);
if (multiPageController != null) {
final multiPageInfo = multiPageController.info;
final pageEntry = multiPageInfo?.getPageEntryByIndex(multiPageController.page);
if (pageEntry != null) {
targetEntry = pageEntry;
}
}
}
switch (action) { switch (action) {
case EntryAction.info: case EntryAction.info:
ShowInfoNotification().dispatch(context); ShowInfoNotification().dispatch(context);
break; break;
case EntryAction.addShortcut: case EntryAction.addShortcut:
_addShortcut(context); _addShortcut(context, targetEntry);
break; break;
case EntryAction.copyToClipboard: case EntryAction.copyToClipboard:
androidAppService.copyToClipboard(entry.uri, entry.bestTitle).then((success) { androidAppService.copyToClipboard(targetEntry.uri, targetEntry.bestTitle).then((success) {
showFeedback(context, success ? context.l10n.genericSuccessFeedback : context.l10n.genericFailureFeedback); showFeedback(context, success ? context.l10n.genericSuccessFeedback : context.l10n.genericFailureFeedback);
}); });
break; break;
case EntryAction.delete: case EntryAction.delete:
_delete(context); _delete(context, targetEntry);
break; break;
case EntryAction.restore: case EntryAction.restore:
_move(context, moveType: MoveType.fromBin); _move(context, targetEntry, moveType: MoveType.fromBin);
break; break;
case EntryAction.convert: case EntryAction.convert:
_convert(context); _convert(context, targetEntry);
break; break;
case EntryAction.print: case EntryAction.print:
EntryPrinter(entry).print(context); EntryPrinter(targetEntry).print(context);
break; break;
case EntryAction.rename: case EntryAction.rename:
_rename(context); _rename(context, targetEntry);
break; break;
case EntryAction.copy: case EntryAction.copy:
_move(context, moveType: MoveType.copy); _move(context, targetEntry, moveType: MoveType.copy);
break; break;
case EntryAction.move: case EntryAction.move:
_move(context, moveType: MoveType.move); _move(context, targetEntry, moveType: MoveType.move);
break; break;
case EntryAction.share: case EntryAction.share:
androidAppService.shareEntries({entry}).then((success) { androidAppService.shareEntries({targetEntry}).then((success) {
if (!success) showNoMatchingAppDialog(context); if (!success) showNoMatchingAppDialog(context);
}); });
break; break;
case EntryAction.toggleFavourite: case EntryAction.toggleFavourite:
entry.toggleFavourite(); targetEntry.toggleFavourite();
break; break;
// raster // raster
case EntryAction.rotateCCW: case EntryAction.rotateCCW:
_rotate(context, clockwise: false); _rotate(context, targetEntry, clockwise: false);
break; break;
case EntryAction.rotateCW: case EntryAction.rotateCW:
_rotate(context, clockwise: true); _rotate(context, targetEntry, clockwise: true);
break; break;
case EntryAction.flip: case EntryAction.flip:
_flip(context); _flip(context, targetEntry);
break; break;
// vector // vector
case EntryAction.viewSource: case EntryAction.viewSource:
_goToSourceViewer(context); _goToSourceViewer(context, targetEntry);
break; break;
// video // video
case EntryAction.videoCaptureFrame: case EntryAction.videoCaptureFrame:
@ -112,28 +225,28 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix
case EntryAction.videoTogglePlay: case EntryAction.videoTogglePlay:
case EntryAction.videoReplay10: case EntryAction.videoReplay10:
case EntryAction.videoSkip10: case EntryAction.videoSkip10:
final controller = context.read<VideoConductor>().getController(entry); final controller = context.read<VideoConductor>().getController(targetEntry);
if (controller != null) { if (controller != null) {
VideoActionNotification(controller: controller, action: action).dispatch(context); VideoActionNotification(controller: controller, action: action).dispatch(context);
} }
break; break;
case EntryAction.edit: case EntryAction.edit:
androidAppService.edit(entry.uri, entry.mimeType).then((success) { androidAppService.edit(targetEntry.uri, targetEntry.mimeType).then((success) {
if (!success) showNoMatchingAppDialog(context); if (!success) showNoMatchingAppDialog(context);
}); });
break; break;
case EntryAction.open: case EntryAction.open:
androidAppService.open(entry.uri, entry.mimeTypeAnySubtype).then((success) { androidAppService.open(targetEntry.uri, targetEntry.mimeTypeAnySubtype).then((success) {
if (!success) showNoMatchingAppDialog(context); if (!success) showNoMatchingAppDialog(context);
}); });
break; break;
case EntryAction.openMap: case EntryAction.openMap:
androidAppService.openMap(entry.latLng!).then((success) { androidAppService.openMap(targetEntry.latLng!).then((success) {
if (!success) showNoMatchingAppDialog(context); if (!success) showNoMatchingAppDialog(context);
}); });
break; break;
case EntryAction.setAs: case EntryAction.setAs:
androidAppService.setAs(entry.uri, entry.mimeType).then((success) { androidAppService.setAs(targetEntry.uri, targetEntry.mimeType).then((success) {
if (!success) showNoMatchingAppDialog(context); if (!success) showNoMatchingAppDialog(context);
}); });
break; break;
@ -141,18 +254,31 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix
case EntryAction.rotateScreen: case EntryAction.rotateScreen:
_rotateScreen(context); _rotateScreen(context);
break; break;
// metadata
case EntryAction.editDate:
case EntryAction.editLocation:
case EntryAction.editTitleDescription:
case EntryAction.editRating:
case EntryAction.editTags:
case EntryAction.removeMetadata:
case EntryAction.exportMetadata:
case EntryAction.showGeoTiffOnMap:
case EntryAction.convertMotionPhotoToStillImage:
case EntryAction.viewMotionPhotoVideo:
_metadataActionDelegate.onActionSelected(context, targetEntry, collection, action);
break;
// debug // debug
case EntryAction.debug: case EntryAction.debug:
_goToDebug(context); _goToDebug(context, targetEntry);
break; break;
} }
} }
Future<void> _addShortcut(BuildContext context) async { Future<void> _addShortcut(BuildContext context, AvesEntry targetEntry) async {
final result = await showDialog<Tuple2<AvesEntry?, String>>( final result = await showDialog<Tuple2<AvesEntry?, String>>(
context: context, context: context,
builder: (context) => AddShortcutDialog( builder: (context) => AddShortcutDialog(
defaultName: entry.bestTitle ?? '', defaultName: targetEntry.bestTitle ?? '',
), ),
); );
if (result == null) return; if (result == null) return;
@ -160,18 +286,18 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix
final name = result.item2; final name = result.item2;
if (name.isEmpty) return; if (name.isEmpty) return;
await androidAppService.pinToHomeScreen(name, entry, uri: entry.uri); await androidAppService.pinToHomeScreen(name, targetEntry, uri: targetEntry.uri);
if (!device.showPinShortcutFeedback) { if (!device.showPinShortcutFeedback) {
showFeedback(context, context.l10n.genericSuccessFeedback); showFeedback(context, context.l10n.genericSuccessFeedback);
} }
} }
Future<void> _flip(BuildContext context) async { Future<void> _flip(BuildContext context, AvesEntry targetEntry) async {
await edit(context, entry.flip); await edit(context, targetEntry, targetEntry.flip);
} }
Future<void> _rotate(BuildContext context, {required bool clockwise}) async { Future<void> _rotate(BuildContext context, AvesEntry targetEntry, {required bool clockwise}) async {
await edit(context, () => entry.rotate(clockwise: clockwise)); await edit(context, targetEntry, () => targetEntry.rotate(clockwise: clockwise));
} }
Future<void> _rotateScreen(BuildContext context) async { Future<void> _rotateScreen(BuildContext context) async {
@ -185,9 +311,9 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix
} }
} }
Future<void> _delete(BuildContext context) async { Future<void> _delete(BuildContext context, AvesEntry targetEntry) async {
if (settings.enableBin && !entry.trashed) { if (settings.enableBin && !targetEntry.trashed) {
await _move(context, moveType: MoveType.toBin); await _move(context, targetEntry, moveType: MoveType.toBin);
return; return;
} }
@ -199,23 +325,23 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix
confirmationButtonLabel: l10n.deleteButtonLabel, confirmationButtonLabel: l10n.deleteButtonLabel,
)) return; )) return;
if (!await checkStoragePermission(context, {entry})) return; if (!await checkStoragePermission(context, {targetEntry})) return;
if (!await entry.delete()) { if (!await targetEntry.delete()) {
showFeedback(context, l10n.genericFailureFeedback); showFeedback(context, l10n.genericFailureFeedback);
} else { } else {
final source = context.read<CollectionSource>(); final source = context.read<CollectionSource>();
if (source.initState != SourceInitializationState.none) { if (source.initState != SourceInitializationState.none) {
await source.removeEntries({entry.uri}, includeTrash: true); await source.removeEntries({targetEntry.uri}, includeTrash: true);
} }
EntryDeletedNotification({entry}).dispatch(context); EntryDeletedNotification({targetEntry}).dispatch(context);
} }
} }
Future<void> _convert(BuildContext context) async { Future<void> _convert(BuildContext context, AvesEntry targetEntry) async {
final options = await showDialog<EntryExportOptions>( final options = await showDialog<EntryExportOptions>(
context: context, context: context,
builder: (context) => ExportEntryDialog(entry: entry), builder: (context) => ExportEntryDialog(entry: targetEntry),
); );
if (options == null) return; if (options == null) return;
@ -223,13 +349,13 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix
if (destinationAlbum == null) return; if (destinationAlbum == null) return;
if (!await checkStoragePermissionForAlbums(context, {destinationAlbum})) return; if (!await checkStoragePermissionForAlbums(context, {destinationAlbum})) return;
if (!await checkFreeSpaceForMove(context, {entry}, destinationAlbum, MoveType.export)) return; if (!await checkFreeSpaceForMove(context, {targetEntry}, destinationAlbum, MoveType.export)) return;
final selection = <AvesEntry>{}; final selection = <AvesEntry>{};
if (entry.isMultiPage) { if (targetEntry.isMultiPage) {
final multiPageInfo = await entry.getMultiPageInfo(); final multiPageInfo = await targetEntry.getMultiPageInfo();
if (multiPageInfo != null) { if (multiPageInfo != null) {
if (entry.isMotionPhoto) { if (targetEntry.isMotionPhoto) {
await multiPageInfo.extractMotionPhotoVideo(); await multiPageInfo.extractMotionPhotoVideo();
} }
if (multiPageInfo.pageCount > 1) { if (multiPageInfo.pageCount > 1) {
@ -237,7 +363,7 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix
} }
} }
} else { } else {
selection.add(entry); selection.add(targetEntry);
} }
final selectionCount = selection.length; final selectionCount = selection.length;
@ -304,32 +430,32 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix
); );
} }
Future<void> _move(BuildContext context, {required MoveType moveType}) => move( Future<void> _move(BuildContext context, AvesEntry targetEntry, {required MoveType moveType}) => move(
context, context,
moveType: moveType, moveType: moveType,
entries: {entry}, entries: {targetEntry},
); );
Future<void> _rename(BuildContext context) async { Future<void> _rename(BuildContext context, AvesEntry targetEntry) async {
final newName = await showDialog<String>( final newName = await showDialog<String>(
context: context, context: context,
builder: (context) => RenameEntryDialog(entry: entry), builder: (context) => RenameEntryDialog(entry: targetEntry),
); );
if (newName == null || newName.isEmpty || newName == entry.filenameWithoutExtension) return; if (newName == null || newName.isEmpty || newName == targetEntry.filenameWithoutExtension) return;
// wait for the dialog to hide as applying the change may block the UI // wait for the dialog to hide as applying the change may block the UI
await Future.delayed(Durations.dialogTransitionAnimation * timeDilation); await Future.delayed(Durations.dialogTransitionAnimation * timeDilation);
await rename( await rename(
context, context,
entriesToNewName: {entry: '$newName${entry.extension}'}, entriesToNewName: {targetEntry: '$newName${targetEntry.extension}'},
persist: _isMainMode(context), persist: _isMainMode(context),
onSuccess: entry.metadataChangeNotifier.notify, onSuccess: targetEntry.metadataChangeNotifier.notify,
); );
} }
bool _isMainMode(BuildContext context) => context.read<ValueNotifier<AppMode>>().value == AppMode.main; bool _isMainMode(BuildContext context) => context.read<ValueNotifier<AppMode>>().value == AppMode.main;
void _goToSourceViewer(BuildContext context) { void _goToSourceViewer(BuildContext context, AvesEntry targetEntry) {
Navigator.push( Navigator.push(
context, context,
MaterialPageRoute( MaterialPageRoute(
@ -337,9 +463,9 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix
builder: (context) => SourceViewerPage( builder: (context) => SourceViewerPage(
loader: () async { loader: () async {
final data = await mediaFetchService.getSvg( final data = await mediaFetchService.getSvg(
entry.uri, targetEntry.uri,
entry.mimeType, targetEntry.mimeType,
sizeBytes: entry.sizeBytes, sizeBytes: targetEntry.sizeBytes,
); );
return utf8.decode(data); return utf8.decode(data);
}, },
@ -348,12 +474,12 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix
); );
} }
void _goToDebug(BuildContext context) { void _goToDebug(BuildContext context, AvesEntry targetEntry) {
Navigator.push( Navigator.push(
context, context,
MaterialPageRoute( MaterialPageRoute(
settings: const RouteSettings(name: ViewerDebugPage.routeName), settings: const RouteSettings(name: ViewerDebugPage.routeName),
builder: (context) => ViewerDebugPage(entry: entry), builder: (context) => ViewerDebugPage(entry: targetEntry),
), ),
); );
} }

View file

@ -1,7 +1,7 @@
import 'dart:async'; import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import 'package:aves/model/actions/entry_info_actions.dart'; import 'package:aves/model/actions/entry_actions.dart';
import 'package:aves/model/actions/events.dart'; import 'package:aves/model/actions/events.dart';
import 'package:aves/model/entry.dart'; import 'package:aves/model/entry.dart';
import 'package:aves/model/entry_info.dart'; import 'package:aves/model/entry_info.dart';
@ -17,171 +17,160 @@ 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/map/map_page.dart'; import 'package:aves/widgets/map/map_page.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/embedded/notifications.dart'; import 'package:aves/widgets/viewer/embedded/notifications.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
class EntryInfoActionDelegate with FeedbackMixin, PermissionAwareMixin, EntryEditorMixin, SingleEntryEditorMixin { class EntryInfoActionDelegate with FeedbackMixin, PermissionAwareMixin, EntryEditorMixin, SingleEntryEditorMixin {
@override final StreamController<ActionEvent<EntryAction>> _eventStreamController = StreamController.broadcast();
final AvesEntry entry;
final CollectionLens? collection;
final StreamController<ActionEvent<EntryInfoAction>> _eventStreamController = StreamController.broadcast(); Stream<ActionEvent<EntryAction>> get eventStream => _eventStreamController.stream;
Stream<ActionEvent<EntryInfoAction>> get eventStream => _eventStreamController.stream; bool isVisible(AvesEntry targetEntry, EntryAction action) {
EntryInfoActionDelegate(this.entry, this.collection);
bool isVisible(EntryInfoAction action) {
switch (action) { switch (action) {
// general // general
case EntryInfoAction.editDate: case EntryAction.editDate:
case EntryInfoAction.editLocation: case EntryAction.editLocation:
case EntryInfoAction.editTitleDescription: case EntryAction.editTitleDescription:
case EntryInfoAction.editRating: case EntryAction.editRating:
case EntryInfoAction.editTags: case EntryAction.editTags:
case EntryInfoAction.removeMetadata: case EntryAction.removeMetadata:
case EntryInfoAction.exportMetadata: case EntryAction.exportMetadata:
return true; return true;
// GeoTIFF // GeoTIFF
case EntryInfoAction.showGeoTiffOnMap: case EntryAction.showGeoTiffOnMap:
return entry.isGeotiff; return targetEntry.isGeotiff;
// motion photo // motion photo
case EntryInfoAction.convertMotionPhotoToStillImage: case EntryAction.convertMotionPhotoToStillImage:
case EntryInfoAction.viewMotionPhotoVideo: case EntryAction.viewMotionPhotoVideo:
return entry.isMotionPhoto; return targetEntry.isMotionPhoto;
// debug default:
case EntryInfoAction.debug: return false;
return kDebugMode;
} }
} }
bool canApply(EntryInfoAction action) { bool canApply(AvesEntry targetEntry, EntryAction action) {
switch (action) { switch (action) {
// general // general
case EntryInfoAction.editDate: case EntryAction.editDate:
return entry.canEditDate; return targetEntry.canEditDate;
case EntryInfoAction.editLocation: case EntryAction.editLocation:
return entry.canEditLocation; return targetEntry.canEditLocation;
case EntryInfoAction.editTitleDescription: case EntryAction.editTitleDescription:
return entry.canEditTitleDescription; return targetEntry.canEditTitleDescription;
case EntryInfoAction.editRating: case EntryAction.editRating:
return entry.canEditRating; return targetEntry.canEditRating;
case EntryInfoAction.editTags: case EntryAction.editTags:
return entry.canEditTags; return targetEntry.canEditTags;
case EntryInfoAction.removeMetadata: case EntryAction.removeMetadata:
return entry.canRemoveMetadata; return targetEntry.canRemoveMetadata;
case EntryInfoAction.exportMetadata: case EntryAction.exportMetadata:
return true; return true;
// GeoTIFF // GeoTIFF
case EntryInfoAction.showGeoTiffOnMap: case EntryAction.showGeoTiffOnMap:
return true; return true;
// motion photo // motion photo
case EntryInfoAction.convertMotionPhotoToStillImage: case EntryAction.convertMotionPhotoToStillImage:
return entry.canEditXmp; return targetEntry.canEditXmp;
case EntryInfoAction.viewMotionPhotoVideo: case EntryAction.viewMotionPhotoVideo:
return true;
// debug
case EntryInfoAction.debug:
return true; return true;
default:
return false;
} }
} }
void onActionSelected(BuildContext context, EntryInfoAction action) async { void onActionSelected(BuildContext context, AvesEntry targetEntry, CollectionLens? collection, EntryAction action) async {
_eventStreamController.add(ActionStartedEvent(action)); _eventStreamController.add(ActionStartedEvent(action));
switch (action) { switch (action) {
// general // general
case EntryInfoAction.editDate: case EntryAction.editDate:
await _editDate(context); await _editDate(context, targetEntry, collection);
break; break;
case EntryInfoAction.editLocation: case EntryAction.editLocation:
await _editLocation(context); await _editLocation(context, targetEntry, collection);
break; break;
case EntryInfoAction.editTitleDescription: case EntryAction.editTitleDescription:
await _editTitleDescription(context); await _editTitleDescription(context, targetEntry);
break; break;
case EntryInfoAction.editRating: case EntryAction.editRating:
await _editRating(context); await _editRating(context, targetEntry);
break; break;
case EntryInfoAction.editTags: case EntryAction.editTags:
await _editTags(context); await _editTags(context, targetEntry);
break; break;
case EntryInfoAction.removeMetadata: case EntryAction.removeMetadata:
await _removeMetadata(context); await _removeMetadata(context, targetEntry);
break; break;
case EntryInfoAction.exportMetadata: case EntryAction.exportMetadata:
await _exportMetadata(context); await _exportMetadata(context, targetEntry);
break; break;
// GeoTIFF // GeoTIFF
case EntryInfoAction.showGeoTiffOnMap: case EntryAction.showGeoTiffOnMap:
await _showGeoTiffOnMap(context); await _showGeoTiffOnMap(context, targetEntry, collection);
break; break;
// motion photo // motion photo
case EntryInfoAction.convertMotionPhotoToStillImage: case EntryAction.convertMotionPhotoToStillImage:
await _convertMotionPhotoToStillImage(context); await _convertMotionPhotoToStillImage(context, targetEntry);
break; break;
case EntryInfoAction.viewMotionPhotoVideo: case EntryAction.viewMotionPhotoVideo:
OpenEmbeddedDataNotification.motionPhotoVideo().dispatch(context); OpenEmbeddedDataNotification.motionPhotoVideo().dispatch(context);
break; break;
// debug default:
case EntryInfoAction.debug:
_goToDebug(context);
break; break;
} }
_eventStreamController.add(ActionEndedEvent(action)); _eventStreamController.add(ActionEndedEvent(action));
} }
Future<void> _editDate(BuildContext context) async { Future<void> _editDate(BuildContext context, AvesEntry targetEntry, CollectionLens? collection) async {
final modifier = await selectDateModifier(context, {entry}, collection); final modifier = await selectDateModifier(context, {targetEntry}, collection);
if (modifier == null) return; if (modifier == null) return;
await edit(context, () => entry.editDate(modifier)); await edit(context, targetEntry, () => targetEntry.editDate(modifier));
} }
Future<void> _editLocation(BuildContext context) async { Future<void> _editLocation(BuildContext context, AvesEntry targetEntry, CollectionLens? collection) async {
final location = await selectLocation(context, {entry}, collection); final location = await selectLocation(context, {targetEntry}, collection);
if (location == null) return; if (location == null) return;
await edit(context, () => entry.editLocation(location)); await edit(context, targetEntry, () => targetEntry.editLocation(location));
} }
Future<void> _editTitleDescription(BuildContext context) async { Future<void> _editTitleDescription(BuildContext context, AvesEntry targetEntry) async {
final modifier = await selectTitleDescriptionModifier(context, {entry}); final modifier = await selectTitleDescriptionModifier(context, {targetEntry});
if (modifier == null) return; if (modifier == null) return;
await edit(context, () => entry.editTitleDescription(modifier)); await edit(context, targetEntry, () => targetEntry.editTitleDescription(modifier));
} }
Future<void> _editRating(BuildContext context) async { Future<void> _editRating(BuildContext context, AvesEntry targetEntry) async {
final rating = await selectRating(context, {entry}); final rating = await selectRating(context, {targetEntry});
if (rating == null) return; if (rating == null) return;
await edit(context, () => entry.editRating(rating)); await edit(context, targetEntry, () => targetEntry.editRating(rating));
} }
Future<void> _editTags(BuildContext context) async { Future<void> _editTags(BuildContext context, AvesEntry targetEntry) async {
final newTagsByEntry = await selectTags(context, {entry}); final newTagsByEntry = await selectTags(context, {targetEntry});
if (newTagsByEntry == null) return; if (newTagsByEntry == null) return;
final newTags = newTagsByEntry[entry] ?? entry.tags; final newTags = newTagsByEntry[targetEntry] ?? targetEntry.tags;
final currentTags = entry.tags; final currentTags = targetEntry.tags;
if (newTags.length == currentTags.length && newTags.every(currentTags.contains)) return; if (newTags.length == currentTags.length && newTags.every(currentTags.contains)) return;
await edit(context, () => entry.editTags(newTags)); await edit(context, targetEntry, () => targetEntry.editTags(newTags));
} }
Future<void> _removeMetadata(BuildContext context) async { Future<void> _removeMetadata(BuildContext context, AvesEntry targetEntry) async {
final types = await selectMetadataToRemove(context, {entry}); final types = await selectMetadataToRemove(context, {targetEntry});
if (types == null) return; if (types == null) return;
await edit(context, () => entry.removeMetadata(types)); await edit(context, targetEntry, () => targetEntry.removeMetadata(types));
} }
Future<void> _exportMetadata(BuildContext context) async { Future<void> _exportMetadata(BuildContext context, AvesEntry targetEntry) async {
final lines = <String>[]; final lines = <String>[];
final padding = ' ' * 2; final padding = ' ' * 2;
final titledDirectories = await entry.getMetadataDirectories(context); final titledDirectories = await targetEntry.getMetadataDirectories(context);
titledDirectories.forEach((kv) { titledDirectories.forEach((kv) {
final title = kv.key; final title = kv.key;
final dir = kv.value; final dir = kv.value;
@ -197,7 +186,7 @@ class EntryInfoActionDelegate with FeedbackMixin, PermissionAwareMixin, EntryEdi
final metadataString = lines.join('\n'); final metadataString = lines.join('\n');
final success = await storageService.createFile( final success = await storageService.createFile(
'${entry.filenameWithoutExtension}-metadata.txt', '${targetEntry.filenameWithoutExtension}-metadata.txt',
MimeTypes.plainText, MimeTypes.plainText,
Uint8List.fromList(utf8.encode(metadataString)), Uint8List.fromList(utf8.encode(metadataString)),
); );
@ -210,7 +199,7 @@ class EntryInfoActionDelegate with FeedbackMixin, PermissionAwareMixin, EntryEdi
} }
} }
Future<void> _convertMotionPhotoToStillImage(BuildContext context) async { Future<void> _convertMotionPhotoToStillImage(BuildContext context, AvesEntry targetEntry) async {
final confirmed = await showDialog<bool>( final confirmed = await showDialog<bool>(
context: context, context: context,
builder: (context) { builder: (context) {
@ -231,16 +220,16 @@ class EntryInfoActionDelegate with FeedbackMixin, PermissionAwareMixin, EntryEdi
); );
if (confirmed == null || !confirmed) return; if (confirmed == null || !confirmed) return;
await edit(context, entry.removeTrailerVideo); await edit(context, targetEntry, targetEntry.removeTrailerVideo);
} }
Future<void> _showGeoTiffOnMap(BuildContext context) async { Future<void> _showGeoTiffOnMap(BuildContext context, AvesEntry targetEntry, CollectionLens? collection) async {
final info = await metadataFetchService.getGeoTiffInfo(entry); final info = await metadataFetchService.getGeoTiffInfo(targetEntry);
if (info == null) return; if (info == null) return;
final mappedGeoTiff = MappedGeoTiff( final mappedGeoTiff = MappedGeoTiff(
info: info, info: info,
entry: entry, entry: targetEntry,
); );
if (!mappedGeoTiff.canOverlay) return; if (!mappedGeoTiff.canOverlay) return;
@ -255,7 +244,7 @@ class EntryInfoActionDelegate with FeedbackMixin, PermissionAwareMixin, EntryEdi
return MapPage( return MapPage(
collection: baseCollection.copyWith( collection: baseCollection.copyWith(
listenToSource: true, listenToSource: true,
fixedSelection: baseCollection.sortedEntries.where((entry) => entry.hasGps).where((entry) => entry != this.entry).toList(), fixedSelection: baseCollection.sortedEntries.where((entry) => entry.hasGps).where((entry) => entry != targetEntry).toList(),
), ),
overlayEntry: mappedGeoTiff, overlayEntry: mappedGeoTiff,
); );
@ -263,14 +252,4 @@ class EntryInfoActionDelegate with FeedbackMixin, PermissionAwareMixin, EntryEdi
), ),
); );
} }
void _goToDebug(BuildContext context) {
Navigator.push(
context,
MaterialPageRoute(
settings: const RouteSettings(name: ViewerDebugPage.routeName),
builder: (context) => ViewerDebugPage(entry: entry),
),
);
}
} }

View file

@ -12,12 +12,10 @@ import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
mixin SingleEntryEditorMixin on FeedbackMixin, PermissionAwareMixin { mixin SingleEntryEditorMixin on FeedbackMixin, PermissionAwareMixin {
AvesEntry get entry;
bool _isMainMode(BuildContext context) => context.read<ValueNotifier<AppMode>>().value == AppMode.main; bool _isMainMode(BuildContext context) => context.read<ValueNotifier<AppMode>>().value == AppMode.main;
Future<void> edit(BuildContext context, Future<Set<EntryDataType>> Function() apply) async { Future<void> edit(BuildContext context, AvesEntry targetEntry, Future<Set<EntryDataType>> Function() apply) async {
if (!await checkStoragePermission(context, {entry})) return; if (!await checkStoragePermission(context, {targetEntry})) return;
// check before applying, because it relies on provider // check before applying, because it relies on provider
// but the widget tree may be disposed if the user navigated away // but the widget tree may be disposed if the user navigated away
@ -32,10 +30,10 @@ mixin SingleEntryEditorMixin on FeedbackMixin, PermissionAwareMixin {
try { try {
if (success) { if (success) {
if (isMainMode && source != null) { if (isMainMode && source != null) {
Set<String> obsoleteTags = entry.tags; Set<String> obsoleteTags = targetEntry.tags;
String? obsoleteCountryCode = entry.addressDetails?.countryCode; String? obsoleteCountryCode = targetEntry.addressDetails?.countryCode;
await source.refreshEntry(entry, dataTypes); await source.refreshEntry(targetEntry, dataTypes);
// invalidate filters derived from values before edition // invalidate filters derived from values before edition
// this invalidation must happen after the source is refreshed, // this invalidation must happen after the source is refreshed,
@ -47,7 +45,7 @@ mixin SingleEntryEditorMixin on FeedbackMixin, PermissionAwareMixin {
source.invalidateTagFilterSummary(tags: obsoleteTags); source.invalidateTagFilterSummary(tags: obsoleteTags);
} }
} else { } else {
await entry.refresh(background: false, persist: false, dataTypes: dataTypes, geocoderLocale: settings.appliedLocale); await targetEntry.refresh(background: false, persist: false, dataTypes: dataTypes, geocoderLocale: settings.appliedLocale);
} }
showFeedback(context, l10n.genericSuccessFeedback); showFeedback(context, l10n.genericSuccessFeedback);
} else { } else {

View file

@ -440,7 +440,7 @@ class _EntryViewerStackState extends State<EntryViewerStack> with EntryViewContr
ViewerBottomOverlay( ViewerBottomOverlay(
entries: entries, entries: entries,
index: _currentEntryIndex, index: _currentEntryIndex,
hasCollection: hasCollection, collection: collection,
animationController: _overlayAnimationController, animationController: _overlayAnimationController,
viewInsets: _frozenViewInsets, viewInsets: _frozenViewInsets,
viewPadding: _frozenViewPadding, viewPadding: _frozenViewPadding,

View file

@ -1,6 +1,6 @@
import 'package:aves/app_mode.dart'; import 'package:aves/app_mode.dart';
import 'package:aves/image_providers/app_icon_image_provider.dart'; import 'package:aves/image_providers/app_icon_image_provider.dart';
import 'package:aves/model/actions/entry_info_actions.dart'; import 'package:aves/model/actions/entry_actions.dart';
import 'package:aves/model/entry.dart'; import 'package:aves/model/entry.dart';
import 'package:aves/model/favourites.dart'; import 'package:aves/model/favourites.dart';
import 'package:aves/model/filters/album.dart'; import 'package:aves/model/filters/album.dart';
@ -29,7 +29,7 @@ class BasicSection extends StatelessWidget {
final AvesEntry entry; final AvesEntry entry;
final CollectionLens? collection; final CollectionLens? collection;
final EntryInfoActionDelegate actionDelegate; final EntryInfoActionDelegate actionDelegate;
final ValueNotifier<EntryInfoAction?> isEditingMetadataNotifier; final ValueNotifier<EntryAction?> isEditingMetadataNotifier;
final FilterCallback onFilter; final FilterCallback onFilter;
const BasicSection({ const BasicSection({
@ -100,9 +100,9 @@ class BasicSection extends StatelessWidget {
Widget _buildEditButtons(BuildContext context) { Widget _buildEditButtons(BuildContext context) {
final children = [ final children = [
EntryInfoAction.editRating, EntryAction.editRating,
EntryInfoAction.editTags, EntryAction.editTags,
].where(actionDelegate.canApply).map((v) => _buildEditMetadataButton(context, v)).toList(); ].where((v) => actionDelegate.canApply(entry, v)).map((v) => _buildEditMetadataButton(context, v)).toList();
return children.isEmpty return children.isEmpty
? const SizedBox() ? const SizedBox()
@ -121,8 +121,8 @@ class BasicSection extends StatelessWidget {
); );
} }
Widget _buildEditMetadataButton(BuildContext context, EntryInfoAction action) { Widget _buildEditMetadataButton(BuildContext context, EntryAction action) {
return ValueListenableBuilder<EntryInfoAction?>( return ValueListenableBuilder<EntryAction?>(
valueListenable: isEditingMetadataNotifier, valueListenable: isEditingMetadataNotifier,
builder: (context, editingAction, child) { builder: (context, editingAction, child) {
final isEditing = editingAction != null; final isEditing = editingAction != null;
@ -138,7 +138,7 @@ class BasicSection extends StatelessWidget {
), ),
child: IconButton( child: IconButton(
icon: action.getIcon(), icon: action.getIcon(),
onPressed: isEditing ? null : () => actionDelegate.onActionSelected(context, action), onPressed: isEditing ? null : () => actionDelegate.onActionSelected(context, entry, collection, action),
tooltip: action.getText(context), tooltip: action.getText(context),
), ),
), ),

View file

@ -1,5 +1,6 @@
import 'package:aves/model/actions/entry_info_actions.dart'; import 'package:aves/model/actions/entry_actions.dart';
import 'package:aves/model/entry.dart'; import 'package:aves/model/entry.dart';
import 'package:aves/model/source/collection_lens.dart';
import 'package:aves/theme/durations.dart'; import 'package:aves/theme/durations.dart';
import 'package:aves/theme/icons.dart'; import 'package:aves/theme/icons.dart';
import 'package:aves/widgets/common/app_bar/app_bar_title.dart'; import 'package:aves/widgets/common/app_bar/app_bar_title.dart';
@ -15,6 +16,7 @@ import 'package:flutter/scheduler.dart';
class InfoAppBar extends StatelessWidget { class InfoAppBar extends StatelessWidget {
final AvesEntry entry; final AvesEntry entry;
final CollectionLens? collection;
final EntryInfoActionDelegate actionDelegate; final EntryInfoActionDelegate actionDelegate;
final ValueNotifier<Map<String, MetadataDirectory>> metadataNotifier; final ValueNotifier<Map<String, MetadataDirectory>> metadataNotifier;
final VoidCallback onBackPressed; final VoidCallback onBackPressed;
@ -22,6 +24,7 @@ class InfoAppBar extends StatelessWidget {
const InfoAppBar({ const InfoAppBar({
super.key, super.key,
required this.entry, required this.entry,
required this.collection,
required this.actionDelegate, required this.actionDelegate,
required this.metadataNotifier, required this.metadataNotifier,
required this.onBackPressed, required this.onBackPressed,
@ -29,8 +32,8 @@ class InfoAppBar extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final commonActions = EntryInfoActions.common.where(actionDelegate.isVisible); final commonActions = EntryActions.commonMetadataActions.where((v) => actionDelegate.isVisible(entry, v));
final formatSpecificActions = EntryInfoActions.formatSpecific.where(actionDelegate.isVisible); final formatSpecificActions = EntryActions.formatSpecificMetadataActions.where((v) => actionDelegate.isVisible(entry, v));
return SliverAppBar( return SliverAppBar(
leading: IconButton( leading: IconButton(
@ -54,22 +57,22 @@ class InfoAppBar extends StatelessWidget {
), ),
if (entry.canEdit) if (entry.canEdit)
MenuIconTheme( MenuIconTheme(
child: PopupMenuButton<EntryInfoAction>( child: PopupMenuButton<EntryAction>(
itemBuilder: (context) => [ itemBuilder: (context) => [
...commonActions.map((action) => _toMenuItem(context, action, enabled: actionDelegate.canApply(action))), ...commonActions.map((action) => _toMenuItem(context, action, enabled: actionDelegate.canApply(entry, action))),
if (formatSpecificActions.isNotEmpty) ...[ if (formatSpecificActions.isNotEmpty) ...[
const PopupMenuDivider(), const PopupMenuDivider(),
...formatSpecificActions.map((action) => _toMenuItem(context, action, enabled: actionDelegate.canApply(action))), ...formatSpecificActions.map((action) => _toMenuItem(context, action, enabled: actionDelegate.canApply(entry, action))),
], ],
if (!kReleaseMode) ...[ if (!kReleaseMode) ...[
const PopupMenuDivider(), const PopupMenuDivider(),
_toMenuItem(context, EntryInfoAction.debug, enabled: true), _toMenuItem(context, EntryAction.debug, enabled: true),
] ]
], ],
onSelected: (action) async { onSelected: (action) async {
// wait for the popup menu to hide before proceeding with the action // wait for the popup menu to hide before proceeding with the action
await Future.delayed(Durations.popupMenuAnimation * timeDilation); await Future.delayed(Durations.popupMenuAnimation * timeDilation);
actionDelegate.onActionSelected(context, action); actionDelegate.onActionSelected(context, entry, collection, action);
}, },
), ),
), ),
@ -78,7 +81,7 @@ class InfoAppBar extends StatelessWidget {
); );
} }
PopupMenuItem<EntryInfoAction> _toMenuItem(BuildContext context, EntryInfoAction action, {required bool enabled}) { PopupMenuItem<EntryAction> _toMenuItem(BuildContext context, EntryAction action, {required bool enabled}) {
return PopupMenuItem( return PopupMenuItem(
value: action, value: action,
enabled: enabled, enabled: enabled,

View file

@ -1,6 +1,6 @@
import 'dart:async'; import 'dart:async';
import 'package:aves/model/actions/entry_info_actions.dart'; import 'package:aves/model/actions/entry_actions.dart';
import 'package:aves/model/actions/events.dart'; import 'package:aves/model/actions/events.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';
@ -150,7 +150,7 @@ class _InfoPageContentState extends State<_InfoPageContent> {
final List<StreamSubscription> _subscriptions = []; final List<StreamSubscription> _subscriptions = [];
late EntryInfoActionDelegate _actionDelegate; late EntryInfoActionDelegate _actionDelegate;
final ValueNotifier<Map<String, MetadataDirectory>> _metadataNotifier = ValueNotifier({}); final ValueNotifier<Map<String, MetadataDirectory>> _metadataNotifier = ValueNotifier({});
final ValueNotifier<EntryInfoAction?> _isEditingMetadataNotifier = ValueNotifier(null); final ValueNotifier<EntryAction?> _isEditingMetadataNotifier = ValueNotifier(null);
static const horizontalPadding = EdgeInsets.symmetric(horizontal: 8); static const horizontalPadding = EdgeInsets.symmetric(horizontal: 8);
@ -181,7 +181,7 @@ class _InfoPageContentState extends State<_InfoPageContent> {
} }
void _registerWidget(_InfoPageContent widget) { void _registerWidget(_InfoPageContent widget) {
_actionDelegate = EntryInfoActionDelegate(widget.entry, collection); _actionDelegate = EntryInfoActionDelegate();
_subscriptions.add(_actionDelegate.eventStream.listen(_onActionDelegateEvent)); _subscriptions.add(_actionDelegate.eventStream.listen(_onActionDelegateEvent));
} }
@ -242,6 +242,7 @@ class _InfoPageContentState extends State<_InfoPageContent> {
slivers: [ slivers: [
InfoAppBar( InfoAppBar(
entry: entry, entry: entry,
collection: collection,
actionDelegate: _actionDelegate, actionDelegate: _actionDelegate,
metadataNotifier: _metadataNotifier, metadataNotifier: _metadataNotifier,
onBackPressed: widget.goToViewer, onBackPressed: widget.goToViewer,
@ -260,7 +261,7 @@ class _InfoPageContentState extends State<_InfoPageContent> {
); );
} }
void _onActionDelegateEvent(ActionEvent<EntryInfoAction> event) { void _onActionDelegateEvent(ActionEvent<EntryAction> event) {
Future.delayed(Durations.dialogTransitionAnimation).then((_) { Future.delayed(Durations.dialogTransitionAnimation).then((_) {
if (event is ActionStartedEvent) { if (event is ActionStartedEvent) {
_isEditingMetadataNotifier.value = event.action; _isEditingMetadataNotifier.value = event.action;

View file

@ -3,6 +3,7 @@ import 'dart:math';
import 'package:aves/app_mode.dart'; import 'package:aves/app_mode.dart';
import 'package:aves/model/entry.dart'; import 'package:aves/model/entry.dart';
import 'package:aves/model/settings/settings.dart'; import 'package:aves/model/settings/settings.dart';
import 'package:aves/model/source/collection_lens.dart';
import 'package:aves/widgets/common/extensions/media_query.dart'; import 'package:aves/widgets/common/extensions/media_query.dart';
import 'package:aves/widgets/viewer/multipage/controller.dart'; import 'package:aves/widgets/viewer/multipage/controller.dart';
import 'package:aves/widgets/viewer/overlay/multipage.dart'; import 'package:aves/widgets/viewer/overlay/multipage.dart';
@ -17,7 +18,7 @@ import 'package:tuple/tuple.dart';
class ViewerBottomOverlay extends StatefulWidget { class ViewerBottomOverlay extends StatefulWidget {
final List<AvesEntry> entries; final List<AvesEntry> entries;
final int index; final int index;
final bool hasCollection; final CollectionLens? collection;
final AnimationController animationController; final AnimationController animationController;
final EdgeInsets? viewInsets, viewPadding; final EdgeInsets? viewInsets, viewPadding;
final MultiPageController? multiPageController; final MultiPageController? multiPageController;
@ -26,7 +27,7 @@ class ViewerBottomOverlay extends StatefulWidget {
super.key, super.key,
required this.entries, required this.entries,
required this.index, required this.index,
required this.hasCollection, required this.collection,
required this.animationController, required this.animationController,
this.viewInsets, this.viewInsets,
this.viewPadding, this.viewPadding,
@ -65,7 +66,7 @@ class _ViewerBottomOverlayState extends State<ViewerBottomOverlay> {
index: widget.index, index: widget.index,
mainEntry: mainEntry, mainEntry: mainEntry,
pageEntry: pageEntry ?? mainEntry, pageEntry: pageEntry ?? mainEntry,
hasCollection: widget.hasCollection, collection: widget.collection,
viewInsets: widget.viewInsets, viewInsets: widget.viewInsets,
viewPadding: widget.viewPadding, viewPadding: widget.viewPadding,
multiPageController: multiPageController, multiPageController: multiPageController,
@ -96,7 +97,7 @@ class _BottomOverlayContent extends StatefulWidget {
final List<AvesEntry> entries; final List<AvesEntry> entries;
final int index; final int index;
final AvesEntry mainEntry, pageEntry; final AvesEntry mainEntry, pageEntry;
final bool hasCollection; final CollectionLens? collection;
final EdgeInsets? viewInsets, viewPadding; final EdgeInsets? viewInsets, viewPadding;
final MultiPageController? multiPageController; final MultiPageController? multiPageController;
final AnimationController animationController; final AnimationController animationController;
@ -106,7 +107,7 @@ class _BottomOverlayContent extends StatefulWidget {
required this.index, required this.index,
required this.mainEntry, required this.mainEntry,
required this.pageEntry, required this.pageEntry,
required this.hasCollection, required this.collection,
required this.viewInsets, required this.viewInsets,
required this.viewPadding, required this.viewPadding,
required this.multiPageController, required this.multiPageController,
@ -167,8 +168,8 @@ class _BottomOverlayContentState extends State<_BottomOverlayContent> {
: ViewerButtons( : ViewerButtons(
mainEntry: mainEntry, mainEntry: mainEntry,
pageEntry: pageEntry, pageEntry: pageEntry,
collection: widget.collection,
scale: _buttonScale, scale: _buttonScale,
canToggleFavourite: widget.hasCollection,
), ),
); );

View file

@ -1,7 +1,7 @@
import 'package:aves/model/actions/entry_actions.dart'; import 'package:aves/model/actions/entry_actions.dart';
import 'package:aves/model/device.dart';
import 'package:aves/model/entry.dart'; import 'package:aves/model/entry.dart';
import 'package:aves/model/settings/settings.dart'; import 'package:aves/model/settings/settings.dart';
import 'package:aves/model/source/collection_lens.dart';
import 'package:aves/theme/durations.dart'; import 'package:aves/theme/durations.dart';
import 'package:aves/theme/icons.dart'; import 'package:aves/theme/icons.dart';
import 'package:aves/widgets/common/app_bar/favourite_toggler.dart'; import 'package:aves/widgets/common/app_bar/favourite_toggler.dart';
@ -9,7 +9,6 @@ import 'package:aves/widgets/common/basic/menu.dart';
import 'package:aves/widgets/common/basic/popup_menu_button.dart'; import 'package:aves/widgets/common/basic/popup_menu_button.dart';
import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:aves/widgets/viewer/action/entry_action_delegate.dart'; import 'package:aves/widgets/viewer/action/entry_action_delegate.dart';
import 'package:aves/widgets/viewer/multipage/conductor.dart';
import 'package:aves/widgets/viewer/notifications.dart'; import 'package:aves/widgets/viewer/notifications.dart';
import 'package:aves/widgets/viewer/overlay/common.dart'; import 'package:aves/widgets/viewer/overlay/common.dart';
import 'package:aves/widgets/viewer/overlay/video/mute_toggler.dart'; import 'package:aves/widgets/viewer/overlay/video/mute_toggler.dart';
@ -23,10 +22,9 @@ import 'package:flutter/scheduler.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
class ViewerButtons extends StatelessWidget { class ViewerButtons extends StatelessWidget {
final AvesEntry mainEntry; final AvesEntry mainEntry, pageEntry;
final AvesEntry pageEntry; final CollectionLens? collection;
final Animation<double> scale; final Animation<double> scale;
final bool canToggleFavourite;
static const double outerPadding = 8; static const double outerPadding = 8;
static const double innerPadding = 8; static const double innerPadding = 8;
@ -39,75 +37,15 @@ class ViewerButtons extends StatelessWidget {
super.key, super.key,
required this.mainEntry, required this.mainEntry,
required this.pageEntry, required this.pageEntry,
required this.collection,
required this.scale, required this.scale,
required this.canToggleFavourite,
}); });
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final actionDelegate = EntryActionDelegate(mainEntry, pageEntry, collection);
final trashed = mainEntry.trashed; final trashed = mainEntry.trashed;
bool _isVisible(EntryAction action) {
if (trashed) {
switch (action) {
case EntryAction.delete:
case EntryAction.restore:
return true;
case EntryAction.debug:
return kDebugMode;
default:
return false;
}
} else {
final targetEntry = EntryActions.pageActions.contains(action) ? pageEntry : mainEntry;
switch (action) {
case EntryAction.toggleFavourite:
return canToggleFavourite;
case EntryAction.delete:
case EntryAction.rename:
case EntryAction.copy:
case EntryAction.move:
return targetEntry.canEdit;
case EntryAction.rotateCCW:
case EntryAction.rotateCW:
return targetEntry.canRotate;
case EntryAction.flip:
return targetEntry.canFlip;
case EntryAction.convert:
case EntryAction.print:
return !targetEntry.isVideo && device.canPrint;
case EntryAction.openMap:
return targetEntry.hasGps;
case EntryAction.viewSource:
return targetEntry.isSvg;
case EntryAction.videoCaptureFrame:
case EntryAction.videoToggleMute:
case EntryAction.videoSelectStreams:
case EntryAction.videoSetSpeed:
case EntryAction.videoSettings:
case EntryAction.videoTogglePlay:
case EntryAction.videoReplay10:
case EntryAction.videoSkip10:
return targetEntry.isVideo;
case EntryAction.rotateScreen:
return settings.isRotationLocked;
case EntryAction.addShortcut:
return device.canPinShortcut;
case EntryAction.info:
case EntryAction.copyToClipboard:
case EntryAction.edit:
case EntryAction.open:
case EntryAction.setAs:
case EntryAction.share:
return true;
case EntryAction.restore:
return false;
case EntryAction.debug:
return kDebugMode;
}
}
}
return SafeArea( return SafeArea(
top: false, top: false,
bottom: false, bottom: false,
@ -118,10 +56,10 @@ class ViewerButtons extends StatelessWidget {
return Selector<Settings, bool>( return Selector<Settings, bool>(
selector: (context, s) => s.isRotationLocked, selector: (context, s) => s.isRotationLocked,
builder: (context, s, child) { builder: (context, s, child) {
final quickActions = (trashed ? EntryActions.trashed : settings.viewerQuickActions).where(_isVisible).take(availableCount - 1).toList(); final quickActions = (trashed ? EntryActions.trashed : settings.viewerQuickActions).where(actionDelegate.isVisible).where(actionDelegate.canApply).take(availableCount - 1).toList();
final topLevelActions = EntryActions.topLevel.where((action) => !quickActions.contains(action)).where(_isVisible).toList(); final topLevelActions = EntryActions.topLevel.where((action) => !quickActions.contains(action)).where(actionDelegate.isVisible).toList();
final exportActions = EntryActions.export.where((action) => !quickActions.contains(action)).where(_isVisible).toList(); final exportActions = EntryActions.export.where((action) => !quickActions.contains(action)).where(actionDelegate.isVisible).toList();
final videoActions = EntryActions.video.where((action) => !quickActions.contains(action)).where(_isVisible).toList(); final videoActions = EntryActions.video.where((action) => !quickActions.contains(action)).where(actionDelegate.isVisible).toList();
return ViewerButtonRowContent( return ViewerButtonRowContent(
quickActions: quickActions, quickActions: quickActions,
topLevelActions: topLevelActions, topLevelActions: topLevelActions,
@ -130,6 +68,7 @@ class ViewerButtons extends StatelessWidget {
scale: scale, scale: scale,
mainEntry: mainEntry, mainEntry: mainEntry,
pageEntry: pageEntry, pageEntry: pageEntry,
collection: collection,
); );
}, },
); );
@ -143,6 +82,7 @@ class ViewerButtonRowContent extends StatelessWidget {
final List<EntryAction> quickActions, topLevelActions, exportActions, videoActions; final List<EntryAction> quickActions, topLevelActions, exportActions, videoActions;
final Animation<double> scale; final Animation<double> scale;
final AvesEntry mainEntry, pageEntry; final AvesEntry mainEntry, pageEntry;
final CollectionLens? collection;
final ValueNotifier<String?> _popupExpandedNotifier = ValueNotifier(null); final ValueNotifier<String?> _popupExpandedNotifier = ValueNotifier(null);
AvesEntry get favouriteTargetEntry => mainEntry.isBurst ? pageEntry : mainEntry; AvesEntry get favouriteTargetEntry => mainEntry.isBurst ? pageEntry : mainEntry;
@ -158,6 +98,7 @@ class ViewerButtonRowContent extends StatelessWidget {
required this.scale, required this.scale,
required this.mainEntry, required this.mainEntry,
required this.pageEntry, required this.pageEntry,
required this.collection,
}); });
@override @override
@ -358,17 +299,7 @@ class ViewerButtonRowContent extends StatelessWidget {
} }
PopupMenuItem<EntryAction> _buildRotateAndFlipMenuItems(BuildContext context) { PopupMenuItem<EntryAction> _buildRotateAndFlipMenuItems(BuildContext context) {
bool canApply(EntryAction action) { final actionDelegate = EntryActionDelegate(mainEntry, pageEntry, collection);
switch (action) {
case EntryAction.rotateCCW:
case EntryAction.rotateCW:
return pageEntry.canRotate;
case EntryAction.flip:
return pageEntry.canFlip;
default:
return true;
}
}
Widget buildDivider() => const SizedBox( Widget buildDivider() => const SizedBox(
height: 16, height: 16,
@ -386,7 +317,7 @@ class ViewerButtonRowContent extends StatelessWidget {
clipBehavior: Clip.antiAlias, clipBehavior: Clip.antiAlias,
child: PopupMenuItem( child: PopupMenuItem(
value: action, value: action,
enabled: canApply(action), enabled: actionDelegate.canApply(action),
child: Tooltip( child: Tooltip(
message: action.getText(context), message: action.getText(context),
child: Center(child: action.getIcon()), child: Center(child: action.getIcon()),
@ -423,17 +354,6 @@ class ViewerButtonRowContent extends StatelessWidget {
} }
void _onActionSelected(BuildContext context, EntryAction action) { void _onActionSelected(BuildContext context, EntryAction action) {
var targetEntry = mainEntry; EntryActionDelegate(mainEntry, pageEntry, collection).onActionSelected(context, action);
if (mainEntry.isMultiPage && (mainEntry.isBurst || EntryActions.pageActions.contains(action))) {
final multiPageController = context.read<MultiPageConductor>().getController(mainEntry);
if (multiPageController != null) {
final multiPageInfo = multiPageController.info;
final pageEntry = multiPageInfo?.getPageEntryByIndex(multiPageController.page);
if (pageEntry != null) {
targetEntry = pageEntry;
}
}
}
EntryActionDelegate(targetEntry).onActionSelected(context, action);
} }
} }

View file

@ -211,7 +211,7 @@ class _EntryEditorState extends State<EntryEditor> with EntryViewControllerMixin
ViewerBottomOverlay( ViewerBottomOverlay(
entries: [widget.entry], entries: [widget.entry],
index: 0, index: 0,
hasCollection: false, collection: null,
animationController: _overlayAnimationController, animationController: _overlayAnimationController,
viewInsets: _frozenViewInsets, viewInsets: _frozenViewInsets,
viewPadding: _frozenViewPadding, viewPadding: _frozenViewPadding,