import 'dart:async'; import 'dart:convert'; import 'package:aves/app_mode.dart'; import 'package:aves/model/entry/entry.dart'; import 'package:aves/model/entry/extensions/info.dart'; import 'package:aves/model/entry/extensions/metadata_edition.dart'; import 'package:aves/model/entry/extensions/multipage.dart'; import 'package:aves/model/entry/extensions/props.dart'; import 'package:aves/model/filters/filters.dart'; import 'package:aves/model/media/geotiff.dart'; import 'package:aves/model/settings/settings.dart'; import 'package:aves/model/source/collection_lens.dart'; import 'package:aves/ref/mime_types.dart'; import 'package:aves/services/common/services.dart'; import 'package:aves/widgets/common/action_mixins/entry_editor.dart'; import 'package:aves/widgets/common/action_mixins/feedback.dart'; import 'package:aves/widgets/common/action_mixins/permission_aware.dart'; 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/info/embedded/notifications.dart'; import 'package:aves_model/aves_model.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; class EntryInfoActionDelegate with FeedbackMixin, PermissionAwareMixin, EntryEditorMixin, SingleEntryEditorMixin { final StreamController> _eventStreamController = StreamController.broadcast(); Stream> get eventStream => _eventStreamController.stream; bool isVisible({ required AppMode appMode, required AvesEntry targetEntry, required EntryAction action, }) { final canWrite = appMode.canEditEntry && !settings.isReadOnly; switch (action) { // general case EntryAction.editDate: case EntryAction.editLocation: case EntryAction.editTitleDescription: case EntryAction.editRating: case EntryAction.editTags: case EntryAction.removeMetadata: return canWrite; case EntryAction.exportMetadata: return true; // GeoTIFF case EntryAction.showGeoTiffOnMap: return appMode.canNavigate && targetEntry.isGeotiff; // motion photo case EntryAction.convertMotionPhotoToStillImage: return canWrite && targetEntry.isMotionPhoto; case EntryAction.viewMotionPhotoVideo: return appMode.canNavigate && targetEntry.isMotionPhoto; default: return false; } } bool canApply(AvesEntry targetEntry, EntryAction action) { switch (action) { // general 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 EntryAction.showGeoTiffOnMap: return true; // motion photo case EntryAction.convertMotionPhotoToStillImage: return targetEntry.canEditXmp; case EntryAction.viewMotionPhotoVideo: return true; default: return false; } } Future onActionSelected(BuildContext context, AvesEntry targetEntry, CollectionLens? collection, EntryAction action) async { await reportService.log('$runtimeType handles $action'); _eventStreamController.add(ActionStartedEvent(action)); switch (action) { // general case EntryAction.editDate: await _editDate(context, targetEntry, collection); case EntryAction.editLocation: await _editLocation(context, targetEntry, collection); case EntryAction.editTitleDescription: await _editTitleDescription(context, targetEntry); case EntryAction.editRating: await _editRating(context, targetEntry); case EntryAction.editTags: await _editTags(context, targetEntry); case EntryAction.removeMetadata: await _removeMetadata(context, targetEntry); case EntryAction.exportMetadata: await _exportMetadata(context, targetEntry); // GeoTIFF case EntryAction.showGeoTiffOnMap: await _showGeoTiffOnMap(context, targetEntry, collection); // motion photo case EntryAction.convertMotionPhotoToStillImage: await _convertMotionPhotoToStillImage(context, targetEntry); case EntryAction.viewMotionPhotoVideo: OpenEmbeddedDataNotification.motionPhotoVideo().dispatch(context); // debug case EntryAction.debug: _goToDebug(context, targetEntry); default: break; } _eventStreamController.add(ActionEndedEvent(action)); } Future _editDate(BuildContext context, AvesEntry targetEntry, CollectionLens? collection) async { final modifier = await selectDateModifier(context, {targetEntry}, collection); if (modifier == null) return; await edit(context, targetEntry, () => targetEntry.editDate(modifier)); } Future _editLocation(BuildContext context, AvesEntry targetEntry, CollectionLens? collection) async { final locationByEntry = await selectLocation(context, {targetEntry}, collection); if (locationByEntry == null) return; if (locationByEntry.containsKey(targetEntry)) { await edit(context, targetEntry, () => targetEntry.editLocation(locationByEntry[targetEntry])); } } Future _editTitleDescription(BuildContext context, AvesEntry targetEntry) async { final modifier = await selectTitleDescriptionModifier(context, {targetEntry}); if (modifier == null) return; await edit(context, targetEntry, () => targetEntry.editTitleDescription(modifier)); } Future _editRating(BuildContext context, AvesEntry targetEntry) async { final rating = await selectRating(context, {targetEntry}); if (rating == null) return; await quickRate(context, targetEntry, rating); } Future quickRate(BuildContext context, AvesEntry targetEntry, int rating) async { if (targetEntry.rating == rating) return; await edit(context, targetEntry, () => targetEntry.editRating(rating)); } Future _editTags(BuildContext context, AvesEntry targetEntry) async { final tagsByEntry = await selectTags(context, {targetEntry}); if (tagsByEntry == null) return; final newTags = tagsByEntry[targetEntry] ?? targetEntry.tags; await _applyTags(context, targetEntry, newTags); } Future quickTag(BuildContext context, AvesEntry targetEntry, CollectionFilter filter) async { final newTags = { ...targetEntry.tags, ...await getTagsFromFilters({filter}, targetEntry), }; await _applyTags(context, targetEntry, newTags); } Future _applyTags(BuildContext context, AvesEntry targetEntry, Set newTags) async { final currentTags = targetEntry.tags; if (newTags.length == currentTags.length && newTags.every(currentTags.contains)) return; await edit(context, targetEntry, () => targetEntry.editTags(newTags)); } Future _removeMetadata(BuildContext context, AvesEntry targetEntry) async { final types = await selectMetadataToRemove(context, {targetEntry}); if (types == null) return; await edit(context, targetEntry, () => targetEntry.removeMetadata(types)); } Future _exportMetadata(BuildContext context, AvesEntry targetEntry) async { final lines = []; final padding = ' ' * 2; final titledDirectories = await targetEntry.getMetadataDirectories(context); titledDirectories.forEach((kv) { final title = kv.key; final dir = kv.value; lines.add('[$title]'); final dirContent = dir.allTags; final tags = dirContent.keys.toList()..sort(); tags.forEach((tag) { final value = dirContent[tag]; lines.add('$padding$tag: $value'); }); }); final metadataString = lines.join('\n'); final success = await storageService.createFile( '${targetEntry.filenameWithoutExtension}-metadata.txt', MimeTypes.plainText, Uint8List.fromList(utf8.encode(metadataString)), ); if (success != null) { if (success) { showFeedback(context, FeedbackType.info, context.l10n.genericSuccessFeedback); } else { showFeedback(context, FeedbackType.warn, context.l10n.genericFailureFeedback); } } } Future _convertMotionPhotoToStillImage(BuildContext context, AvesEntry targetEntry) async { final l10n = context.l10n; final confirmed = await showDialog( context: context, builder: (context) => AvesDialog( content: Text(l10n.genericDangerWarningDialogMessage), actions: [ const CancelButton(), TextButton( onPressed: () => Navigator.maybeOf(context)?.pop(true), child: Text(l10n.applyButtonLabel), ), ], ), routeSettings: const RouteSettings(name: AvesDialog.warningRouteName), ); if (confirmed == null || !confirmed) return; await edit(context, targetEntry, targetEntry.removeTrailerVideo); } Future _showGeoTiffOnMap(BuildContext context, AvesEntry targetEntry, CollectionLens? collection) async { final info = await metadataFetchService.getGeoTiffInfo(targetEntry); if (info == null) return; final mappedGeoTiff = MappedGeoTiff( info: info, entry: targetEntry, devicePixelRatio: View.of(context).devicePixelRatio, ); if (!mappedGeoTiff.canOverlay) return; final baseCollection = collection; if (baseCollection == null) return; final mapCollection = baseCollection.copyWith( listenToSource: true, fixedSelection: baseCollection.sortedEntries.where((entry) => entry.hasGps).where((entry) => entry != targetEntry).toList(), ); await Navigator.maybeOf(context)?.push( MaterialPageRoute( settings: const RouteSettings(name: MapPage.routeName), builder: (context) => MapPage( collection: mapCollection, overlayEntry: mappedGeoTiff, ), ), ); } void _goToDebug(BuildContext context, AvesEntry targetEntry) { Navigator.maybeOf(context)?.push( MaterialPageRoute( settings: const RouteSettings(name: ViewerDebugPage.routeName), builder: (context) => ViewerDebugPage(entry: targetEntry), ), ); } }