diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataEditHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataEditHandler.kt index fa67c8fb4..c37610dc3 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataEditHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataEditHandler.kt @@ -20,6 +20,7 @@ class MetadataEditHandler(private val activity: Activity) : MethodCallHandler { "rotate" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::rotate) } "flip" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::flip) } "editDate" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::editDate) } + "removeTypes" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::removeTypes) } else -> result.notImplemented() } } @@ -96,6 +97,34 @@ class MetadataEditHandler(private val activity: Activity) : MethodCallHandler { }) } + private fun removeTypes(call: MethodCall, result: MethodChannel.Result) { + val types = call.argument>("types") + val entryMap = call.argument("entry") + if (entryMap == null || types == null) { + result.error("removeTypes-args", "failed because of missing arguments", null) + return + } + + val uri = (entryMap["uri"] as String?)?.let { Uri.parse(it) } + val path = entryMap["path"] as String? + val mimeType = entryMap["mimeType"] as String? + if (uri == null || path == null || mimeType == null) { + result.error("removeTypes-args", "failed because entry fields are missing", null) + return + } + + val provider = getProvider(uri) + if (provider == null) { + result.error("removeTypes-provider", "failed to find provider for uri=$uri", null) + return + } + + provider.removeMetadataTypes(activity, path, uri, mimeType, types.toSet(), object : ImageOpCallback { + override fun onSuccess(fields: FieldMap) = result.success(fields) + override fun onFailure(throwable: Throwable) = result.error("removeTypes-failure", "failed to remove metadata", throwable.message) + }) + } + companion object { const val CHANNEL = "deckers.thibault/aves/metadata_edit" } diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/metadata/Metadata.kt b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/Metadata.kt index 890f4136c..a7d9364e5 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/metadata/Metadata.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/Metadata.kt @@ -32,6 +32,16 @@ object Metadata { const val DIR_MEDIA = "Media" // custom const val DIR_COVER_ART = "Cover" // custom + // types of metadata + const val TYPE_EXIF = "exif" + const val TYPE_ICC_PROFILE = "icc_profile" + const val TYPE_IPTC = "iptc" + const val TYPE_JFIF = "jfif" + const val TYPE_JPEG_ADOBE = "jpeg_adobe" + const val TYPE_JPEG_DUCKY = "jpeg_ducky" + const val TYPE_PHOTOSHOP_IRB = "photoshop_irb" + const val TYPE_XMP = "xmp" + // interpret EXIF code to angle (0, 90, 180 or 270 degrees) fun getRotationDegreesForExifCode(exifOrientation: Int): Int = when (exifOrientation) { ExifInterface.ORIENTATION_ROTATE_90, ExifInterface.ORIENTATION_TRANSVERSE -> 90 diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/metadata/PixyMetaHelper.kt b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/PixyMetaHelper.kt index 2a4d44790..f6d57680e 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/metadata/PixyMetaHelper.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/PixyMetaHelper.kt @@ -1,5 +1,13 @@ package deckers.thibault.aves.metadata +import deckers.thibault.aves.metadata.Metadata.TYPE_EXIF +import deckers.thibault.aves.metadata.Metadata.TYPE_ICC_PROFILE +import deckers.thibault.aves.metadata.Metadata.TYPE_IPTC +import deckers.thibault.aves.metadata.Metadata.TYPE_JFIF +import deckers.thibault.aves.metadata.Metadata.TYPE_JPEG_ADOBE +import deckers.thibault.aves.metadata.Metadata.TYPE_JPEG_DUCKY +import deckers.thibault.aves.metadata.Metadata.TYPE_PHOTOSHOP_IRB +import deckers.thibault.aves.metadata.Metadata.TYPE_XMP import pixy.meta.meta.Metadata import pixy.meta.meta.MetadataEntry import pixy.meta.meta.MetadataType @@ -54,4 +62,21 @@ object PixyMetaHelper { fun XMP.xmpDocString(): String = XMLUtils.serializeToString(xmpDocument) fun XMP.extendedXmpDocString(): String = XMLUtils.serializeToString(extendedXmpDocument) + + fun removeMetadata(input: InputStream, output: OutputStream, metadataTypes: Set) { + val types = metadataTypes.map(::toMetadataType).toTypedArray() + Metadata.removeMetadata(input, output, *types) + } + + private fun toMetadataType(typeString: String): MetadataType? = when (typeString) { + TYPE_EXIF -> MetadataType.EXIF + TYPE_ICC_PROFILE -> MetadataType.ICC_PROFILE + TYPE_IPTC -> MetadataType.IPTC + TYPE_JFIF -> MetadataType.JPG_JFIF + TYPE_JPEG_ADOBE -> MetadataType.JPG_ADOBE + TYPE_JPEG_DUCKY -> MetadataType.JPG_DUCKY + TYPE_PHOTOSHOP_IRB -> MetadataType.PHOTOSHOP_IRB + TYPE_XMP -> MetadataType.XMP + else -> null + } } \ No newline at end of file diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/ImageProvider.kt b/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/ImageProvider.kt index db85dc07d..a5f0ef5cb 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/ImageProvider.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/ImageProvider.kt @@ -14,19 +14,17 @@ import com.bumptech.glide.request.RequestOptions import com.commonsware.cwac.document.DocumentFileCompat import deckers.thibault.aves.decoder.MultiTrackImage import deckers.thibault.aves.decoder.TiffImage -import deckers.thibault.aves.metadata.ExifInterfaceHelper +import deckers.thibault.aves.metadata.* import deckers.thibault.aves.metadata.ExifInterfaceHelper.getSafeDateMillis -import deckers.thibault.aves.metadata.MultiPage -import deckers.thibault.aves.metadata.PixyMetaHelper import deckers.thibault.aves.metadata.PixyMetaHelper.extendedXmpDocString import deckers.thibault.aves.metadata.PixyMetaHelper.xmpDocString -import deckers.thibault.aves.metadata.XMP import deckers.thibault.aves.model.AvesEntry import deckers.thibault.aves.model.ExifOrientationOp import deckers.thibault.aves.model.FieldMap import deckers.thibault.aves.utils.* import deckers.thibault.aves.utils.MimeTypes.canEditExif import deckers.thibault.aves.utils.MimeTypes.canEditXmp +import deckers.thibault.aves.utils.MimeTypes.canRemoveMetadata import deckers.thibault.aves.utils.MimeTypes.extensionFor import deckers.thibault.aves.utils.MimeTypes.isVideo import deckers.thibault.aves.utils.StorageUtils.createDirectoryIfAbsent @@ -50,7 +48,7 @@ abstract class ImageProvider { callback.onFailure(UnsupportedOperationException()) } - open fun scanPostExifEdit(context: Context, path: String, uri: Uri, mimeType: String, newFields: HashMap, callback: ImageOpCallback) { + open fun scanPostMetadataEdit(context: Context, path: String, uri: Uri, mimeType: String, newFields: HashMap, callback: ImageOpCallback) { throw UnsupportedOperationException() } @@ -515,7 +513,14 @@ abstract class ImageProvider { } } - fun editOrientation(context: Context, path: String, uri: Uri, mimeType: String, op: ExifOrientationOp, callback: ImageOpCallback) { + fun editOrientation( + context: Context, + path: String, + uri: Uri, + mimeType: String, + op: ExifOrientationOp, + callback: ImageOpCallback, + ) { val newFields = HashMap() val success = editExif(context, path, uri, mimeType, callback) { exif -> @@ -538,7 +543,7 @@ abstract class ImageProvider { } if (success) { - scanPostExifEdit(context, path, uri, mimeType, newFields, callback) + scanPostMetadataEdit(context, path, uri, mimeType, newFields, callback) } } @@ -632,10 +637,62 @@ abstract class ImageProvider { } if (success) { - scanPostExifEdit(context, path, uri, mimeType, HashMap(), callback) + scanPostMetadataEdit(context, path, uri, mimeType, HashMap(), callback) } } + fun removeMetadataTypes( + context: Context, + path: String, + uri: Uri, + mimeType: String, + types: Set, + callback: ImageOpCallback, + ) { + if (!canRemoveMetadata(mimeType)) { + callback.onFailure(UnsupportedOperationException("unsupported mimeType=$mimeType")) + return + } + + val originalDocumentFile = getDocumentFile(context, path, uri) + if (originalDocumentFile == null) { + callback.onFailure(Exception("failed to get document file for path=$path, uri=$uri")) + return + } + + val originalFileSize = File(path).length() + val videoSize = MultiPage.getMotionPhotoOffset(context, uri, mimeType, originalFileSize)?.toInt() + val editableFile = File.createTempFile("aves", null).apply { + deleteOnExit() + try { + outputStream().use { output -> + // reopen input to read from start + originalDocumentFile.openInputStream().use { input -> + PixyMetaHelper.removeMetadata(input, output, types) + } + } + } catch (e: Exception) { + callback.onFailure(e) + return + } + } + + try { + // copy the edited temporary file back to the original + DocumentFileCompat.fromFile(editableFile).copyTo(originalDocumentFile) + + if (!types.contains(Metadata.TYPE_XMP) && !checkTrailerOffset(context, path, uri, mimeType, videoSize, editableFile, callback)) { + return + } + } catch (e: IOException) { + callback.onFailure(e) + return + } + + val newFields = HashMap() + scanPostMetadataEdit(context, path, uri, mimeType, newFields, callback) + } + interface ImageOpCallback { fun onSuccess(fields: FieldMap) fun onFailure(throwable: Throwable) diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/MediaStoreImageProvider.kt b/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/MediaStoreImageProvider.kt index df9c6249f..81aad7c89 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/MediaStoreImageProvider.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/MediaStoreImageProvider.kt @@ -378,7 +378,7 @@ class MediaStoreImageProvider : ImageProvider() { } } - override fun scanPostExifEdit(context: Context, path: String, uri: Uri, mimeType: String, newFields: HashMap, callback: ImageOpCallback) { + override fun scanPostMetadataEdit(context: Context, path: String, uri: Uri, mimeType: String, newFields: HashMap, callback: ImageOpCallback) { MediaScannerConnection.scanFile(context, arrayOf(path), arrayOf(mimeType)) { _, _ -> val projection = arrayOf( MediaStore.MediaColumns.DATE_MODIFIED, diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index cb197a981..44bae0d16 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -131,6 +131,8 @@ "entryInfoActionEditDate": "Edit date & time", "@entryInfoActionEditDate": {}, + "entryInfoActionRemoveMetadata": "Remove metadata", + "@entryInfoActionRemoveMetadata": {}, "filterFavouriteLabel": "Favourite", "@filterFavouriteLabel": {}, @@ -325,6 +327,11 @@ "editEntryDateDialogMinutes": "Minutes", "@editEntryDateDialogMinutes": {}, + "removeEntryMetadataDialogTitle": "Metadata Removal", + "@removeEntryMetadataDialogTitle": {}, + "removeEntryMetadataDialogMore": "More", + "@removeEntryMetadataDialogMore": {}, + "videoSpeedDialogLabel": "Playback speed", "@videoSpeedDialogLabel": {}, diff --git a/lib/l10n/app_ko.arb b/lib/l10n/app_ko.arb index 9e5c7453b..c3446634d 100644 --- a/lib/l10n/app_ko.arb +++ b/lib/l10n/app_ko.arb @@ -67,6 +67,7 @@ "videoActionSettings": "설정", "entryInfoActionEditDate": "날짜와 시간 수정", + "entryInfoActionRemoveMetadata": "메타데이터 삭제", "filterFavouriteLabel": "즐겨찾기", "filterLocationEmptyLabel": "장소 없음", @@ -149,6 +150,9 @@ "editEntryDateDialogHours": "시간", "editEntryDateDialogMinutes": "분", + "removeEntryMetadataDialogTitle": "메타데이터 삭제", + "removeEntryMetadataDialogMore": "더 보기", + "videoSpeedDialogLabel": "재생 배속", "videoStreamSelectionDialogVideo": "동영상", diff --git a/lib/model/actions/entry_info_actions.dart b/lib/model/actions/entry_info_actions.dart index 4b9808b35..52ddd4394 100644 --- a/lib/model/actions/entry_info_actions.dart +++ b/lib/model/actions/entry_info_actions.dart @@ -1,3 +1,4 @@ enum EntryInfoAction { editDate, + removeMetadata, } diff --git a/lib/model/entry.dart b/lib/model/entry.dart index 0ec3f87ae..53fb7d2cf 100644 --- a/lib/model/entry.dart +++ b/lib/model/entry.dart @@ -6,6 +6,7 @@ import 'package:aves/model/favourites.dart'; import 'package:aves/model/metadata/address.dart'; import 'package:aves/model/metadata/catalog.dart'; import 'package:aves/model/metadata/date_modifier.dart'; +import 'package:aves/model/metadata/enums.dart'; import 'package:aves/model/multipage.dart'; import 'package:aves/model/settings/settings.dart'; import 'package:aves/model/video/metadata.dart'; @@ -230,7 +231,6 @@ class AvesEntry { bool get canRotateAndFlip => canEdit && canEditExif; - // support for writing EXIF // as of androidx.exifinterface:exifinterface:1.3.3 bool get canEditExif { switch (mimeType.toLowerCase()) { @@ -244,6 +244,17 @@ class AvesEntry { } } + // as of latest PixyMeta + bool get canRemoveMetadata { + switch (mimeType.toLowerCase()) { + case MimeTypes.jpeg: + case MimeTypes.tiff: + return true; + default: + return false; + } + } + // Media Store size/rotation is inaccurate, e.g. a portrait FHD video is rotated according to its metadata, // so it should be registered as width=1920, height=1080, orientation=90, // but is incorrectly registered as width=1080, height=1920, orientation=0. @@ -613,6 +624,14 @@ class AvesEntry { return true; } + Future removeMetadata(Set types, {required bool persist}) async { + final newFields = await metadataEditService.removeTypes(this, types); + if (newFields.isEmpty) return false; + + await _applyNewFields(newFields, persist: persist); + return true; + } + Future delete() { final completer = Completer(); mediaFileService.delete([this]).listen( diff --git a/lib/model/metadata/enums.dart b/lib/model/metadata/enums.dart index 131cb1c0c..cad167b28 100644 --- a/lib/model/metadata/enums.dart +++ b/lib/model/metadata/enums.dart @@ -10,3 +10,67 @@ enum DateEditAction { shift, clear, } + +enum MetadataType { + // Exif: https://en.wikipedia.org/wiki/Exif + exif, + // ICC profile: https://en.wikipedia.org/wiki/ICC_profile + iccProfile, + // IPTC: https://en.wikipedia.org/wiki/IPTC_Information_Interchange_Model + iptc, + // JPEG APP0 / JFIF: https://en.wikipedia.org/wiki/JPEG_File_Interchange_Format + jfif, + // JPEG APP14 / Adobe: https://www.exiftool.org/TagNames/JPEG.html#Adobe + jpegAdobe, + // JPEG APP12 / Ducky: https://www.exiftool.org/TagNames/APP12.html#Ducky + jpegDucky, + // Photoshop IRB: https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/ + photoshopIrb, + // XMP: https://en.wikipedia.org/wiki/Extensible_Metadata_Platform + xmp, +} + +class MetadataTypes { + static const main = { + MetadataType.exif, + MetadataType.xmp, + }; + + static const common = { + MetadataType.exif, + MetadataType.xmp, + MetadataType.iccProfile, + MetadataType.iptc, + MetadataType.photoshopIrb, + }; + + static const jpeg = { + MetadataType.jfif, + MetadataType.jpegAdobe, + MetadataType.jpegDucky, + }; +} + +extension ExtraMetadataType on MetadataType { + // match `ExifInterface` directory names + String getText() { + switch (this) { + case MetadataType.exif: + return 'Exif'; + case MetadataType.iccProfile: + return 'ICC Profile'; + case MetadataType.iptc: + return 'IPTC'; + case MetadataType.jfif: + return 'JFIF'; + case MetadataType.jpegAdobe: + return 'Adobe JPEG'; + case MetadataType.jpegDucky: + return 'Ducky'; + case MetadataType.photoshopIrb: + return 'Photoshop'; + case MetadataType.xmp: + return 'XMP'; + } + } +} diff --git a/lib/services/metadata/metadata_edit_service.dart b/lib/services/metadata/metadata_edit_service.dart index 276114d7a..91976b3f1 100644 --- a/lib/services/metadata/metadata_edit_service.dart +++ b/lib/services/metadata/metadata_edit_service.dart @@ -12,6 +12,8 @@ abstract class MetadataEditService { Future> flip(AvesEntry entry); Future> editDate(AvesEntry entry, DateModifier modifier); + + Future> removeTypes(AvesEntry entry, Set types); } class PlatformMetadataEditService implements MetadataEditService { @@ -77,6 +79,20 @@ class PlatformMetadataEditService implements MetadataEditService { return {}; } + @override + Future> removeTypes(AvesEntry entry, Set types) async { + try { + final result = await platform.invokeMethod('removeTypes', { + 'entry': _toPlatformEntryMap(entry), + 'types': types.map(_toPlatformMetadataType).toList(), + }); + if (result != null) return (result as Map).cast(); + } on PlatformException catch (e, stack) { + await reportService.recordError(e, stack); + } + return {}; + } + String _toExifInterfaceTag(MetadataField field) { switch (field) { case MetadataField.exifDate: @@ -89,4 +105,25 @@ class PlatformMetadataEditService implements MetadataEditService { return 'GPSDateStamp'; } } + + String _toPlatformMetadataType(MetadataType type) { + switch (type) { + case MetadataType.exif: + return 'exif'; + case MetadataType.iccProfile: + return 'icc_profile'; + case MetadataType.iptc: + return 'iptc'; + case MetadataType.jfif: + return 'jfif'; + case MetadataType.jpegAdobe: + return 'jpeg_adobe'; + case MetadataType.jpegDucky: + return 'jpeg_ducky'; + case MetadataType.photoshopIrb: + return 'photoshop_irb'; + case MetadataType.xmp: + return 'xmp'; + } + } } diff --git a/lib/widgets/common/identity/highlight_title.dart b/lib/widgets/common/identity/highlight_title.dart index 07ccd9b98..7993734e5 100644 --- a/lib/widgets/common/identity/highlight_title.dart +++ b/lib/widgets/common/identity/highlight_title.dart @@ -23,16 +23,18 @@ class HighlightTitle extends StatelessWidget { static const disabledColor = Colors.grey; + static const shadows = [ + Shadow( + color: Colors.black, + offset: Offset(1, 1), + blurRadius: 2, + ) + ]; + @override Widget build(BuildContext context) { final style = TextStyle( - shadows: const [ - Shadow( - color: Colors.black, - offset: Offset(1, 1), - blurRadius: 2, - ) - ], + shadows: shadows, fontSize: fontSize, letterSpacing: 1.0, fontFeatures: const [FontFeature.enable('smcp')], diff --git a/lib/widgets/dialogs/edit_entry_date_dialog.dart b/lib/widgets/dialogs/edit_entry_date_dialog.dart index 93387017d..159e5d9d5 100644 --- a/lib/widgets/dialogs/edit_entry_date_dialog.dart +++ b/lib/widgets/dialogs/edit_entry_date_dialog.dart @@ -71,7 +71,7 @@ class _EditEntryDateDialogState extends State { child: IconButton( icon: const Icon(AIcons.edit), onPressed: _action == DateEditAction.set ? _editDate : null, - tooltip: context.l10n.changeTooltip, + tooltip: l10n.changeTooltip, ), ), ], @@ -92,7 +92,7 @@ class _EditEntryDateDialogState extends State { child: IconButton( icon: const Icon(AIcons.edit), onPressed: _action == DateEditAction.shift ? _editShift : null, - tooltip: context.l10n.changeTooltip, + tooltip: l10n.changeTooltip, ), ), ], @@ -114,7 +114,7 @@ class _EditEntryDateDialogState extends State { ), child: AvesDialog( context: context, - title: context.l10n.editEntryDateDialogTitle, + title: l10n.editEntryDateDialogTitle, scrollableContent: [ setTile, shiftTile, @@ -156,7 +156,7 @@ class _EditEntryDateDialogState extends State { ), TextButton( onPressed: () => _submit(context), - child: Text(context.l10n.applyButtonLabel), + child: Text(l10n.applyButtonLabel), ), ], ), diff --git a/lib/widgets/dialogs/remove_entry_metadata_dialog.dart b/lib/widgets/dialogs/remove_entry_metadata_dialog.dart new file mode 100644 index 000000000..cd09f589c --- /dev/null +++ b/lib/widgets/dialogs/remove_entry_metadata_dialog.dart @@ -0,0 +1,128 @@ +import 'package:aves/model/entry.dart'; +import 'package:aves/model/metadata/enums.dart'; +import 'package:aves/ref/brand_colors.dart'; +import 'package:aves/ref/mime_types.dart'; +import 'package:aves/utils/color_utils.dart'; +import 'package:aves/widgets/common/extensions/build_context.dart'; +import 'package:aves/widgets/common/fx/highlight_decoration.dart'; +import 'package:aves/widgets/common/identity/highlight_title.dart'; +import 'package:collection/collection.dart'; +import 'package:flutter/material.dart'; + +import 'aves_dialog.dart'; + +class RemoveEntryMetadataDialog extends StatefulWidget { + final AvesEntry entry; + + const RemoveEntryMetadataDialog({ + Key? key, + required this.entry, + }) : super(key: key); + + @override + _RemoveEntryMetadataDialogState createState() => _RemoveEntryMetadataDialogState(); +} + +class _RemoveEntryMetadataDialogState extends State { + late final List _mainOptions, _moreOptions; + final Set _types = {}; + bool _showMore = false; + final ValueNotifier _isValidNotifier = ValueNotifier(false); + + AvesEntry get entry => widget.entry; + + @override + void initState() { + super.initState(); + final byMain = groupBy([ + ...MetadataTypes.common, + if (entry.mimeType == MimeTypes.jpeg) ...MetadataTypes.jpeg, + ], MetadataTypes.main.contains); + _mainOptions = (byMain[true] ?? [])..sort(_compareTypeText); + _moreOptions = (byMain[false] ?? [])..sort(_compareTypeText); + _validate(); + } + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + return AvesDialog( + context: context, + title: l10n.removeEntryMetadataDialogTitle, + scrollableContent: [ + ..._mainOptions.map(_toTile), + if (_moreOptions.isNotEmpty) + Padding( + padding: const EdgeInsets.only(bottom: 1), + child: ExpansionPanelList( + expansionCallback: (index, isExpanded) { + setState(() => _showMore = !isExpanded); + }, + expandedHeaderPadding: EdgeInsets.zero, + elevation: 0, + children: [ + ExpansionPanel( + headerBuilder: (context, isExpanded) => ListTile( + title: Text(l10n.removeEntryMetadataDialogMore), + ), + body: Column( + children: _moreOptions.map(_toTile).toList(), + ), + isExpanded: _showMore, + canTapOnHeader: true, + backgroundColor: Theme.of(context).dialogBackgroundColor, + ), + ], + ), + ), + ], + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: Text(MaterialLocalizations.of(context).cancelButtonLabel), + ), + ValueListenableBuilder( + valueListenable: _isValidNotifier, + builder: (context, isValid, child) { + return TextButton( + onPressed: isValid ? () => _submit(context) : null, + child: Text(context.l10n.applyButtonLabel), + ); + }, + ), + ], + ); + } + + int _compareTypeText(MetadataType a, MetadataType b) => a.getText().compareTo(b.getText()); + + Widget _toTile(MetadataType type) { + final text = type.getText(); + return SwitchListTile( + value: _types.contains(type), + onChanged: (selected) { + selected ? _types.add(type) : _types.remove(type); + _validate(); + setState(() {}); + }, + title: Align( + alignment: Alignment.centerLeft, + child: DecoratedBox( + decoration: HighlightDecoration( + color: BrandColors.get(text) ?? stringToColor(text), + ), + child: Text( + text, + style: const TextStyle( + shadows: HighlightTitle.shadows, + ), + ), + ), + ), + ); + } + + void _validate() => _isValidNotifier.value = _types.isNotEmpty; + + void _submit(BuildContext context) => Navigator.pop(context, _types); +} diff --git a/lib/widgets/viewer/info/info_app_bar.dart b/lib/widgets/viewer/info/info_app_bar.dart index c79b061ec..0881cf9a4 100644 --- a/lib/widgets/viewer/info/info_app_bar.dart +++ b/lib/widgets/viewer/info/info_app_bar.dart @@ -1,6 +1,8 @@ import 'package:aves/model/actions/entry_info_actions.dart'; import 'package:aves/model/entry.dart'; import 'package:aves/model/metadata/date_modifier.dart'; +import 'package:aves/model/metadata/enums.dart'; +import 'package:aves/model/source/collection_source.dart'; import 'package:aves/theme/durations.dart'; import 'package:aves/theme/icons.dart'; import 'package:aves/widgets/common/action_mixins/feedback.dart'; @@ -9,10 +11,12 @@ import 'package:aves/widgets/common/app_bar_title.dart'; import 'package:aves/widgets/common/basic/menu.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/dialogs/edit_entry_date_dialog.dart'; +import 'package:aves/widgets/dialogs/remove_entry_metadata_dialog.dart'; import 'package:aves/widgets/viewer/info/info_search.dart'; import 'package:aves/widgets/viewer/info/metadata/metadata_section.dart'; import 'package:flutter/material.dart'; import 'package:flutter/scheduler.dart'; +import 'package:provider/provider.dart'; class InfoAppBar extends StatelessWidget with FeedbackMixin, PermissionAwareMixin { final AvesEntry entry; @@ -55,6 +59,11 @@ class InfoAppBar extends StatelessWidget with FeedbackMixin, PermissionAwareMixi enabled: entry.canEditExif, child: MenuRow(text: context.l10n.entryInfoActionEditDate, icon: const Icon(AIcons.date)), ), + PopupMenuItem( + value: EntryInfoAction.removeMetadata, + enabled: entry.canRemoveMetadata, + child: MenuRow(text: context.l10n.entryInfoActionRemoveMetadata, icon: const Icon(AIcons.clear)), + ), ]; }, onSelected: (action) { @@ -85,6 +94,9 @@ class InfoAppBar extends StatelessWidget with FeedbackMixin, PermissionAwareMixi case EntryInfoAction.editDate: await _showDateEditDialog(context); break; + case EntryInfoAction.removeMetadata: + await _showMetadataRemovalDialog(context); + break; } } @@ -105,4 +117,23 @@ class InfoAppBar extends StatelessWidget with FeedbackMixin, PermissionAwareMixi showFeedback(context, context.l10n.genericFailureFeedback); } } + + Future _showMetadataRemovalDialog(BuildContext context) async { + final types = await showDialog>( + context: context, + builder: (context) => RemoveEntryMetadataDialog(entry: entry), + ); + if (types == null) return; + + if (!await checkStoragePermission(context, {entry})) return; + + // TODO TLAD [meta edit] handle viewer mode + final success = await entry.removeMetadata(types, persist: true); + if (success) { + await context.read().refreshMetadata({entry}); + showFeedback(context, context.l10n.genericSuccessFeedback); + } else { + showFeedback(context, context.l10n.genericFailureFeedback); + } + } }