#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
- Viewer: Info page editing actions available as quick actions
- Info: export metadata to text file
- Accessibility: apply bold font system setting
- `libre` app flavor (no mobile service maps, no Crashlytics)

View file

@ -38,6 +38,19 @@ enum EntryAction {
setAs,
// platform
rotateScreen,
// metadata
editDate,
editLocation,
editTitleDescription,
editRating,
editTags,
removeMetadata,
exportMetadata,
// metadata / GeoTIFF
showGeoTiffOnMap,
// metadata / motion photo
convertMotionPhotoToStillImage,
viewMotionPhotoVideo,
// debug
debug,
}
@ -99,6 +112,22 @@ class EntryActions {
EntryAction.videoSelectStreams,
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 {
@ -170,6 +199,29 @@ extension ExtraEntryAction on EntryAction {
// platform
case EntryAction.rotateScreen:
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
case EntryAction.debug:
return 'Debug';
@ -258,6 +310,29 @@ extension ExtraEntryAction on EntryAction {
// platform
case EntryAction.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
case EntryAction.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.videoSelectStreams,
],
EntryActions.commonMetadataActions,
];
@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/settings/enums/enums.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/services/common/image_op_events.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/export_entry_dialog.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/single_entry_editor.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/source_viewer_page.dart';
import 'package:aves/widgets/viewer/video/conductor.dart';
import 'package:collection/collection.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
import 'package:provider/provider.dart';
import 'package:tuple/tuple.dart';
class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMixin, SingleEntryEditorMixin, EntryStorageMixin {
@override
final AvesEntry entry;
final AvesEntry mainEntry, pageEntry;
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) {
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) {
case EntryAction.info:
ShowInfoNotification().dispatch(context);
break;
case EntryAction.addShortcut:
_addShortcut(context);
_addShortcut(context, targetEntry);
break;
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);
});
break;
case EntryAction.delete:
_delete(context);
_delete(context, targetEntry);
break;
case EntryAction.restore:
_move(context, moveType: MoveType.fromBin);
_move(context, targetEntry, moveType: MoveType.fromBin);
break;
case EntryAction.convert:
_convert(context);
_convert(context, targetEntry);
break;
case EntryAction.print:
EntryPrinter(entry).print(context);
EntryPrinter(targetEntry).print(context);
break;
case EntryAction.rename:
_rename(context);
_rename(context, targetEntry);
break;
case EntryAction.copy:
_move(context, moveType: MoveType.copy);
_move(context, targetEntry, moveType: MoveType.copy);
break;
case EntryAction.move:
_move(context, moveType: MoveType.move);
_move(context, targetEntry, moveType: MoveType.move);
break;
case EntryAction.share:
androidAppService.shareEntries({entry}).then((success) {
androidAppService.shareEntries({targetEntry}).then((success) {
if (!success) showNoMatchingAppDialog(context);
});
break;
case EntryAction.toggleFavourite:
entry.toggleFavourite();
targetEntry.toggleFavourite();
break;
// raster
case EntryAction.rotateCCW:
_rotate(context, clockwise: false);
_rotate(context, targetEntry, clockwise: false);
break;
case EntryAction.rotateCW:
_rotate(context, clockwise: true);
_rotate(context, targetEntry, clockwise: true);
break;
case EntryAction.flip:
_flip(context);
_flip(context, targetEntry);
break;
// vector
case EntryAction.viewSource:
_goToSourceViewer(context);
_goToSourceViewer(context, targetEntry);
break;
// video
case EntryAction.videoCaptureFrame:
@ -112,28 +225,28 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix
case EntryAction.videoTogglePlay:
case EntryAction.videoReplay10:
case EntryAction.videoSkip10:
final controller = context.read<VideoConductor>().getController(entry);
final controller = context.read<VideoConductor>().getController(targetEntry);
if (controller != null) {
VideoActionNotification(controller: controller, action: action).dispatch(context);
}
break;
case EntryAction.edit:
androidAppService.edit(entry.uri, entry.mimeType).then((success) {
androidAppService.edit(targetEntry.uri, targetEntry.mimeType).then((success) {
if (!success) showNoMatchingAppDialog(context);
});
break;
case EntryAction.open:
androidAppService.open(entry.uri, entry.mimeTypeAnySubtype).then((success) {
androidAppService.open(targetEntry.uri, targetEntry.mimeTypeAnySubtype).then((success) {
if (!success) showNoMatchingAppDialog(context);
});
break;
case EntryAction.openMap:
androidAppService.openMap(entry.latLng!).then((success) {
androidAppService.openMap(targetEntry.latLng!).then((success) {
if (!success) showNoMatchingAppDialog(context);
});
break;
case EntryAction.setAs:
androidAppService.setAs(entry.uri, entry.mimeType).then((success) {
androidAppService.setAs(targetEntry.uri, targetEntry.mimeType).then((success) {
if (!success) showNoMatchingAppDialog(context);
});
break;
@ -141,18 +254,31 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix
case EntryAction.rotateScreen:
_rotateScreen(context);
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
case EntryAction.debug:
_goToDebug(context);
_goToDebug(context, targetEntry);
break;
}
}
Future<void> _addShortcut(BuildContext context) async {
Future<void> _addShortcut(BuildContext context, AvesEntry targetEntry) async {
final result = await showDialog<Tuple2<AvesEntry?, String>>(
context: context,
builder: (context) => AddShortcutDialog(
defaultName: entry.bestTitle ?? '',
defaultName: targetEntry.bestTitle ?? '',
),
);
if (result == null) return;
@ -160,18 +286,18 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix
final name = result.item2;
if (name.isEmpty) return;
await androidAppService.pinToHomeScreen(name, entry, uri: entry.uri);
await androidAppService.pinToHomeScreen(name, targetEntry, uri: targetEntry.uri);
if (!device.showPinShortcutFeedback) {
showFeedback(context, context.l10n.genericSuccessFeedback);
}
}
Future<void> _flip(BuildContext context) async {
await edit(context, entry.flip);
Future<void> _flip(BuildContext context, AvesEntry targetEntry) async {
await edit(context, targetEntry, targetEntry.flip);
}
Future<void> _rotate(BuildContext context, {required bool clockwise}) async {
await edit(context, () => entry.rotate(clockwise: clockwise));
Future<void> _rotate(BuildContext context, AvesEntry targetEntry, {required bool clockwise}) async {
await edit(context, targetEntry, () => targetEntry.rotate(clockwise: clockwise));
}
Future<void> _rotateScreen(BuildContext context) async {
@ -185,9 +311,9 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix
}
}
Future<void> _delete(BuildContext context) async {
if (settings.enableBin && !entry.trashed) {
await _move(context, moveType: MoveType.toBin);
Future<void> _delete(BuildContext context, AvesEntry targetEntry) async {
if (settings.enableBin && !targetEntry.trashed) {
await _move(context, targetEntry, moveType: MoveType.toBin);
return;
}
@ -199,23 +325,23 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix
confirmationButtonLabel: l10n.deleteButtonLabel,
)) 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);
} else {
final source = context.read<CollectionSource>();
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>(
context: context,
builder: (context) => ExportEntryDialog(entry: entry),
builder: (context) => ExportEntryDialog(entry: targetEntry),
);
if (options == null) return;
@ -223,13 +349,13 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix
if (destinationAlbum == null) 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>{};
if (entry.isMultiPage) {
final multiPageInfo = await entry.getMultiPageInfo();
if (targetEntry.isMultiPage) {
final multiPageInfo = await targetEntry.getMultiPageInfo();
if (multiPageInfo != null) {
if (entry.isMotionPhoto) {
if (targetEntry.isMotionPhoto) {
await multiPageInfo.extractMotionPhotoVideo();
}
if (multiPageInfo.pageCount > 1) {
@ -237,7 +363,7 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix
}
}
} else {
selection.add(entry);
selection.add(targetEntry);
}
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,
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>(
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
await Future.delayed(Durations.dialogTransitionAnimation * timeDilation);
await rename(
context,
entriesToNewName: {entry: '$newName${entry.extension}'},
entriesToNewName: {targetEntry: '$newName${targetEntry.extension}'},
persist: _isMainMode(context),
onSuccess: entry.metadataChangeNotifier.notify,
onSuccess: targetEntry.metadataChangeNotifier.notify,
);
}
bool _isMainMode(BuildContext context) => context.read<ValueNotifier<AppMode>>().value == AppMode.main;
void _goToSourceViewer(BuildContext context) {
void _goToSourceViewer(BuildContext context, AvesEntry targetEntry) {
Navigator.push(
context,
MaterialPageRoute(
@ -337,9 +463,9 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix
builder: (context) => SourceViewerPage(
loader: () async {
final data = await mediaFetchService.getSvg(
entry.uri,
entry.mimeType,
sizeBytes: entry.sizeBytes,
targetEntry.uri,
targetEntry.mimeType,
sizeBytes: targetEntry.sizeBytes,
);
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(
context,
MaterialPageRoute(
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: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/entry.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/map/map_page.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:flutter/foundation.dart';
import 'package:flutter/material.dart';
class EntryInfoActionDelegate with FeedbackMixin, PermissionAwareMixin, EntryEditorMixin, SingleEntryEditorMixin {
@override
final AvesEntry entry;
final CollectionLens? collection;
final StreamController<ActionEvent<EntryAction>> _eventStreamController = StreamController.broadcast();
final StreamController<ActionEvent<EntryInfoAction>> _eventStreamController = StreamController.broadcast();
Stream<ActionEvent<EntryAction>> get eventStream => _eventStreamController.stream;
Stream<ActionEvent<EntryInfoAction>> get eventStream => _eventStreamController.stream;
EntryInfoActionDelegate(this.entry, this.collection);
bool isVisible(EntryInfoAction action) {
bool isVisible(AvesEntry targetEntry, EntryAction action) {
switch (action) {
// general
case EntryInfoAction.editDate:
case EntryInfoAction.editLocation:
case EntryInfoAction.editTitleDescription:
case EntryInfoAction.editRating:
case EntryInfoAction.editTags:
case EntryInfoAction.removeMetadata:
case EntryInfoAction.exportMetadata:
case EntryAction.editDate:
case EntryAction.editLocation:
case EntryAction.editTitleDescription:
case EntryAction.editRating:
case EntryAction.editTags:
case EntryAction.removeMetadata:
case EntryAction.exportMetadata:
return true;
// GeoTIFF
case EntryInfoAction.showGeoTiffOnMap:
return entry.isGeotiff;
case EntryAction.showGeoTiffOnMap:
return targetEntry.isGeotiff;
// motion photo
case EntryInfoAction.convertMotionPhotoToStillImage:
case EntryInfoAction.viewMotionPhotoVideo:
return entry.isMotionPhoto;
// debug
case EntryInfoAction.debug:
return kDebugMode;
case EntryAction.convertMotionPhotoToStillImage:
case EntryAction.viewMotionPhotoVideo:
return targetEntry.isMotionPhoto;
default:
return false;
}
}
bool canApply(EntryInfoAction action) {
bool canApply(AvesEntry targetEntry, EntryAction action) {
switch (action) {
// general
case EntryInfoAction.editDate:
return entry.canEditDate;
case EntryInfoAction.editLocation:
return entry.canEditLocation;
case EntryInfoAction.editTitleDescription:
return entry.canEditTitleDescription;
case EntryInfoAction.editRating:
return entry.canEditRating;
case EntryInfoAction.editTags:
return entry.canEditTags;
case EntryInfoAction.removeMetadata:
return entry.canRemoveMetadata;
case EntryInfoAction.exportMetadata:
case EntryAction.editDate:
return targetEntry.canEditDate;
case EntryAction.editLocation:
return targetEntry.canEditLocation;
case EntryAction.editTitleDescription:
return targetEntry.canEditTitleDescription;
case EntryAction.editRating:
return targetEntry.canEditRating;
case EntryAction.editTags:
return targetEntry.canEditTags;
case EntryAction.removeMetadata:
return targetEntry.canRemoveMetadata;
case EntryAction.exportMetadata:
return true;
// GeoTIFF
case EntryInfoAction.showGeoTiffOnMap:
case EntryAction.showGeoTiffOnMap:
return true;
// motion photo
case EntryInfoAction.convertMotionPhotoToStillImage:
return entry.canEditXmp;
case EntryInfoAction.viewMotionPhotoVideo:
return true;
// debug
case EntryInfoAction.debug:
case EntryAction.convertMotionPhotoToStillImage:
return targetEntry.canEditXmp;
case EntryAction.viewMotionPhotoVideo:
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));
switch (action) {
// general
case EntryInfoAction.editDate:
await _editDate(context);
case EntryAction.editDate:
await _editDate(context, targetEntry, collection);
break;
case EntryInfoAction.editLocation:
await _editLocation(context);
case EntryAction.editLocation:
await _editLocation(context, targetEntry, collection);
break;
case EntryInfoAction.editTitleDescription:
await _editTitleDescription(context);
case EntryAction.editTitleDescription:
await _editTitleDescription(context, targetEntry);
break;
case EntryInfoAction.editRating:
await _editRating(context);
case EntryAction.editRating:
await _editRating(context, targetEntry);
break;
case EntryInfoAction.editTags:
await _editTags(context);
case EntryAction.editTags:
await _editTags(context, targetEntry);
break;
case EntryInfoAction.removeMetadata:
await _removeMetadata(context);
case EntryAction.removeMetadata:
await _removeMetadata(context, targetEntry);
break;
case EntryInfoAction.exportMetadata:
await _exportMetadata(context);
case EntryAction.exportMetadata:
await _exportMetadata(context, targetEntry);
break;
// GeoTIFF
case EntryInfoAction.showGeoTiffOnMap:
await _showGeoTiffOnMap(context);
case EntryAction.showGeoTiffOnMap:
await _showGeoTiffOnMap(context, targetEntry, collection);
break;
// motion photo
case EntryInfoAction.convertMotionPhotoToStillImage:
await _convertMotionPhotoToStillImage(context);
case EntryAction.convertMotionPhotoToStillImage:
await _convertMotionPhotoToStillImage(context, targetEntry);
break;
case EntryInfoAction.viewMotionPhotoVideo:
case EntryAction.viewMotionPhotoVideo:
OpenEmbeddedDataNotification.motionPhotoVideo().dispatch(context);
break;
// debug
case EntryInfoAction.debug:
_goToDebug(context);
default:
break;
}
_eventStreamController.add(ActionEndedEvent(action));
}
Future<void> _editDate(BuildContext context) async {
final modifier = await selectDateModifier(context, {entry}, collection);
Future<void> _editDate(BuildContext context, AvesEntry targetEntry, CollectionLens? collection) async {
final modifier = await selectDateModifier(context, {targetEntry}, collection);
if (modifier == null) return;
await edit(context, () => entry.editDate(modifier));
await edit(context, targetEntry, () => targetEntry.editDate(modifier));
}
Future<void> _editLocation(BuildContext context) async {
final location = await selectLocation(context, {entry}, collection);
Future<void> _editLocation(BuildContext context, AvesEntry targetEntry, CollectionLens? collection) async {
final location = await selectLocation(context, {targetEntry}, collection);
if (location == null) return;
await edit(context, () => entry.editLocation(location));
await edit(context, targetEntry, () => targetEntry.editLocation(location));
}
Future<void> _editTitleDescription(BuildContext context) async {
final modifier = await selectTitleDescriptionModifier(context, {entry});
Future<void> _editTitleDescription(BuildContext context, AvesEntry targetEntry) async {
final modifier = await selectTitleDescriptionModifier(context, {targetEntry});
if (modifier == null) return;
await edit(context, () => entry.editTitleDescription(modifier));
await edit(context, targetEntry, () => targetEntry.editTitleDescription(modifier));
}
Future<void> _editRating(BuildContext context) async {
final rating = await selectRating(context, {entry});
Future<void> _editRating(BuildContext context, AvesEntry targetEntry) async {
final rating = await selectRating(context, {targetEntry});
if (rating == null) return;
await edit(context, () => entry.editRating(rating));
await edit(context, targetEntry, () => targetEntry.editRating(rating));
}
Future<void> _editTags(BuildContext context) async {
final newTagsByEntry = await selectTags(context, {entry});
Future<void> _editTags(BuildContext context, AvesEntry targetEntry) async {
final newTagsByEntry = await selectTags(context, {targetEntry});
if (newTagsByEntry == null) return;
final newTags = newTagsByEntry[entry] ?? entry.tags;
final currentTags = entry.tags;
final newTags = newTagsByEntry[targetEntry] ?? targetEntry.tags;
final currentTags = targetEntry.tags;
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 {
final types = await selectMetadataToRemove(context, {entry});
Future<void> _removeMetadata(BuildContext context, AvesEntry targetEntry) async {
final types = await selectMetadataToRemove(context, {targetEntry});
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 padding = ' ' * 2;
final titledDirectories = await entry.getMetadataDirectories(context);
final titledDirectories = await targetEntry.getMetadataDirectories(context);
titledDirectories.forEach((kv) {
final title = kv.key;
final dir = kv.value;
@ -197,7 +186,7 @@ class EntryInfoActionDelegate with FeedbackMixin, PermissionAwareMixin, EntryEdi
final metadataString = lines.join('\n');
final success = await storageService.createFile(
'${entry.filenameWithoutExtension}-metadata.txt',
'${targetEntry.filenameWithoutExtension}-metadata.txt',
MimeTypes.plainText,
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>(
context: context,
builder: (context) {
@ -231,16 +220,16 @@ class EntryInfoActionDelegate with FeedbackMixin, PermissionAwareMixin, EntryEdi
);
if (confirmed == null || !confirmed) return;
await edit(context, entry.removeTrailerVideo);
await edit(context, targetEntry, targetEntry.removeTrailerVideo);
}
Future<void> _showGeoTiffOnMap(BuildContext context) async {
final info = await metadataFetchService.getGeoTiffInfo(entry);
Future<void> _showGeoTiffOnMap(BuildContext context, AvesEntry targetEntry, CollectionLens? collection) async {
final info = await metadataFetchService.getGeoTiffInfo(targetEntry);
if (info == null) return;
final mappedGeoTiff = MappedGeoTiff(
info: info,
entry: entry,
entry: targetEntry,
);
if (!mappedGeoTiff.canOverlay) return;
@ -255,7 +244,7 @@ class EntryInfoActionDelegate with FeedbackMixin, PermissionAwareMixin, EntryEdi
return MapPage(
collection: baseCollection.copyWith(
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,
);
@ -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';
mixin SingleEntryEditorMixin on FeedbackMixin, PermissionAwareMixin {
AvesEntry get entry;
bool _isMainMode(BuildContext context) => context.read<ValueNotifier<AppMode>>().value == AppMode.main;
Future<void> edit(BuildContext context, Future<Set<EntryDataType>> Function() apply) async {
if (!await checkStoragePermission(context, {entry})) return;
Future<void> edit(BuildContext context, AvesEntry targetEntry, Future<Set<EntryDataType>> Function() apply) async {
if (!await checkStoragePermission(context, {targetEntry})) return;
// check before applying, because it relies on provider
// but the widget tree may be disposed if the user navigated away
@ -32,10 +30,10 @@ mixin SingleEntryEditorMixin on FeedbackMixin, PermissionAwareMixin {
try {
if (success) {
if (isMainMode && source != null) {
Set<String> obsoleteTags = entry.tags;
String? obsoleteCountryCode = entry.addressDetails?.countryCode;
Set<String> obsoleteTags = targetEntry.tags;
String? obsoleteCountryCode = targetEntry.addressDetails?.countryCode;
await source.refreshEntry(entry, dataTypes);
await source.refreshEntry(targetEntry, dataTypes);
// invalidate filters derived from values before edition
// this invalidation must happen after the source is refreshed,
@ -47,7 +45,7 @@ mixin SingleEntryEditorMixin on FeedbackMixin, PermissionAwareMixin {
source.invalidateTagFilterSummary(tags: obsoleteTags);
}
} 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);
} else {

View file

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

View file

@ -1,6 +1,6 @@
import 'package:aves/app_mode.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/favourites.dart';
import 'package:aves/model/filters/album.dart';
@ -29,7 +29,7 @@ class BasicSection extends StatelessWidget {
final AvesEntry entry;
final CollectionLens? collection;
final EntryInfoActionDelegate actionDelegate;
final ValueNotifier<EntryInfoAction?> isEditingMetadataNotifier;
final ValueNotifier<EntryAction?> isEditingMetadataNotifier;
final FilterCallback onFilter;
const BasicSection({
@ -100,9 +100,9 @@ class BasicSection extends StatelessWidget {
Widget _buildEditButtons(BuildContext context) {
final children = [
EntryInfoAction.editRating,
EntryInfoAction.editTags,
].where(actionDelegate.canApply).map((v) => _buildEditMetadataButton(context, v)).toList();
EntryAction.editRating,
EntryAction.editTags,
].where((v) => actionDelegate.canApply(entry, v)).map((v) => _buildEditMetadataButton(context, v)).toList();
return children.isEmpty
? const SizedBox()
@ -121,8 +121,8 @@ class BasicSection extends StatelessWidget {
);
}
Widget _buildEditMetadataButton(BuildContext context, EntryInfoAction action) {
return ValueListenableBuilder<EntryInfoAction?>(
Widget _buildEditMetadataButton(BuildContext context, EntryAction action) {
return ValueListenableBuilder<EntryAction?>(
valueListenable: isEditingMetadataNotifier,
builder: (context, editingAction, child) {
final isEditing = editingAction != null;
@ -138,7 +138,7 @@ class BasicSection extends StatelessWidget {
),
child: IconButton(
icon: action.getIcon(),
onPressed: isEditing ? null : () => actionDelegate.onActionSelected(context, action),
onPressed: isEditing ? null : () => actionDelegate.onActionSelected(context, entry, collection, action),
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/source/collection_lens.dart';
import 'package:aves/theme/durations.dart';
import 'package:aves/theme/icons.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 {
final AvesEntry entry;
final CollectionLens? collection;
final EntryInfoActionDelegate actionDelegate;
final ValueNotifier<Map<String, MetadataDirectory>> metadataNotifier;
final VoidCallback onBackPressed;
@ -22,6 +24,7 @@ class InfoAppBar extends StatelessWidget {
const InfoAppBar({
super.key,
required this.entry,
required this.collection,
required this.actionDelegate,
required this.metadataNotifier,
required this.onBackPressed,
@ -29,8 +32,8 @@ class InfoAppBar extends StatelessWidget {
@override
Widget build(BuildContext context) {
final commonActions = EntryInfoActions.common.where(actionDelegate.isVisible);
final formatSpecificActions = EntryInfoActions.formatSpecific.where(actionDelegate.isVisible);
final commonActions = EntryActions.commonMetadataActions.where((v) => actionDelegate.isVisible(entry, v));
final formatSpecificActions = EntryActions.formatSpecificMetadataActions.where((v) => actionDelegate.isVisible(entry, v));
return SliverAppBar(
leading: IconButton(
@ -54,22 +57,22 @@ class InfoAppBar extends StatelessWidget {
),
if (entry.canEdit)
MenuIconTheme(
child: PopupMenuButton<EntryInfoAction>(
child: PopupMenuButton<EntryAction>(
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) ...[
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) ...[
const PopupMenuDivider(),
_toMenuItem(context, EntryInfoAction.debug, enabled: true),
_toMenuItem(context, EntryAction.debug, enabled: true),
]
],
onSelected: (action) async {
// wait for the popup menu to hide before proceeding with the action
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(
value: action,
enabled: enabled,

View file

@ -1,6 +1,6 @@
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/entry.dart';
import 'package:aves/model/filters/filters.dart';
@ -150,7 +150,7 @@ class _InfoPageContentState extends State<_InfoPageContent> {
final List<StreamSubscription> _subscriptions = [];
late EntryInfoActionDelegate _actionDelegate;
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);
@ -181,7 +181,7 @@ class _InfoPageContentState extends State<_InfoPageContent> {
}
void _registerWidget(_InfoPageContent widget) {
_actionDelegate = EntryInfoActionDelegate(widget.entry, collection);
_actionDelegate = EntryInfoActionDelegate();
_subscriptions.add(_actionDelegate.eventStream.listen(_onActionDelegateEvent));
}
@ -242,6 +242,7 @@ class _InfoPageContentState extends State<_InfoPageContent> {
slivers: [
InfoAppBar(
entry: entry,
collection: collection,
actionDelegate: _actionDelegate,
metadataNotifier: _metadataNotifier,
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((_) {
if (event is ActionStartedEvent) {
_isEditingMetadataNotifier.value = event.action;

View file

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

View file

@ -1,7 +1,7 @@
import 'package:aves/model/actions/entry_actions.dart';
import 'package:aves/model/device.dart';
import 'package:aves/model/entry.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/icons.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/extensions/build_context.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/overlay/common.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';
class ViewerButtons extends StatelessWidget {
final AvesEntry mainEntry;
final AvesEntry pageEntry;
final AvesEntry mainEntry, pageEntry;
final CollectionLens? collection;
final Animation<double> scale;
final bool canToggleFavourite;
static const double outerPadding = 8;
static const double innerPadding = 8;
@ -39,75 +37,15 @@ class ViewerButtons extends StatelessWidget {
super.key,
required this.mainEntry,
required this.pageEntry,
required this.collection,
required this.scale,
required this.canToggleFavourite,
});
@override
Widget build(BuildContext context) {
final actionDelegate = EntryActionDelegate(mainEntry, pageEntry, collection);
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(
top: false,
bottom: false,
@ -118,10 +56,10 @@ class ViewerButtons extends StatelessWidget {
return Selector<Settings, bool>(
selector: (context, s) => s.isRotationLocked,
builder: (context, s, child) {
final quickActions = (trashed ? EntryActions.trashed : settings.viewerQuickActions).where(_isVisible).take(availableCount - 1).toList();
final topLevelActions = EntryActions.topLevel.where((action) => !quickActions.contains(action)).where(_isVisible).toList();
final exportActions = EntryActions.export.where((action) => !quickActions.contains(action)).where(_isVisible).toList();
final videoActions = EntryActions.video.where((action) => !quickActions.contains(action)).where(_isVisible).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(actionDelegate.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(actionDelegate.isVisible).toList();
return ViewerButtonRowContent(
quickActions: quickActions,
topLevelActions: topLevelActions,
@ -130,6 +68,7 @@ class ViewerButtons extends StatelessWidget {
scale: scale,
mainEntry: mainEntry,
pageEntry: pageEntry,
collection: collection,
);
},
);
@ -143,6 +82,7 @@ class ViewerButtonRowContent extends StatelessWidget {
final List<EntryAction> quickActions, topLevelActions, exportActions, videoActions;
final Animation<double> scale;
final AvesEntry mainEntry, pageEntry;
final CollectionLens? collection;
final ValueNotifier<String?> _popupExpandedNotifier = ValueNotifier(null);
AvesEntry get favouriteTargetEntry => mainEntry.isBurst ? pageEntry : mainEntry;
@ -158,6 +98,7 @@ class ViewerButtonRowContent extends StatelessWidget {
required this.scale,
required this.mainEntry,
required this.pageEntry,
required this.collection,
});
@override
@ -358,17 +299,7 @@ class ViewerButtonRowContent extends StatelessWidget {
}
PopupMenuItem<EntryAction> _buildRotateAndFlipMenuItems(BuildContext context) {
bool canApply(EntryAction action) {
switch (action) {
case EntryAction.rotateCCW:
case EntryAction.rotateCW:
return pageEntry.canRotate;
case EntryAction.flip:
return pageEntry.canFlip;
default:
return true;
}
}
final actionDelegate = EntryActionDelegate(mainEntry, pageEntry, collection);
Widget buildDivider() => const SizedBox(
height: 16,
@ -386,7 +317,7 @@ class ViewerButtonRowContent extends StatelessWidget {
clipBehavior: Clip.antiAlias,
child: PopupMenuItem(
value: action,
enabled: canApply(action),
enabled: actionDelegate.canApply(action),
child: Tooltip(
message: action.getText(context),
child: Center(child: action.getIcon()),
@ -423,17 +354,6 @@ class ViewerButtonRowContent extends StatelessWidget {
}
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;
}
}
}
EntryActionDelegate(targetEntry).onActionSelected(context, action);
EntryActionDelegate(mainEntry, pageEntry, collection).onActionSelected(context, action);
}
}

View file

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