info: remove metadata
This commit is contained in:
parent
4ae828710d
commit
bb145a9603
15 changed files with 435 additions and 21 deletions
|
@ -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<List<String>>("types")
|
||||
val entryMap = call.argument<FieldMap>("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"
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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<String>) {
|
||||
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
|
||||
}
|
||||
}
|
|
@ -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<String, Any?>, callback: ImageOpCallback) {
|
||||
open fun scanPostMetadataEdit(context: Context, path: String, uri: Uri, mimeType: String, newFields: HashMap<String, Any?>, 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<String, Any?>()
|
||||
|
||||
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<String, Any?>(), callback)
|
||||
scanPostMetadataEdit(context, path, uri, mimeType, HashMap<String, Any?>(), callback)
|
||||
}
|
||||
}
|
||||
|
||||
fun removeMetadataTypes(
|
||||
context: Context,
|
||||
path: String,
|
||||
uri: Uri,
|
||||
mimeType: String,
|
||||
types: Set<String>,
|
||||
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<String, Any?>()
|
||||
scanPostMetadataEdit(context, path, uri, mimeType, newFields, callback)
|
||||
}
|
||||
|
||||
interface ImageOpCallback {
|
||||
fun onSuccess(fields: FieldMap)
|
||||
fun onFailure(throwable: Throwable)
|
||||
|
|
|
@ -378,7 +378,7 @@ class MediaStoreImageProvider : ImageProvider() {
|
|||
}
|
||||
}
|
||||
|
||||
override fun scanPostExifEdit(context: Context, path: String, uri: Uri, mimeType: String, newFields: HashMap<String, Any?>, callback: ImageOpCallback) {
|
||||
override fun scanPostMetadataEdit(context: Context, path: String, uri: Uri, mimeType: String, newFields: HashMap<String, Any?>, callback: ImageOpCallback) {
|
||||
MediaScannerConnection.scanFile(context, arrayOf(path), arrayOf(mimeType)) { _, _ ->
|
||||
val projection = arrayOf(
|
||||
MediaStore.MediaColumns.DATE_MODIFIED,
|
||||
|
|
|
@ -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": {},
|
||||
|
||||
|
|
|
@ -67,6 +67,7 @@
|
|||
"videoActionSettings": "설정",
|
||||
|
||||
"entryInfoActionEditDate": "날짜와 시간 수정",
|
||||
"entryInfoActionRemoveMetadata": "메타데이터 삭제",
|
||||
|
||||
"filterFavouriteLabel": "즐겨찾기",
|
||||
"filterLocationEmptyLabel": "장소 없음",
|
||||
|
@ -149,6 +150,9 @@
|
|||
"editEntryDateDialogHours": "시간",
|
||||
"editEntryDateDialogMinutes": "분",
|
||||
|
||||
"removeEntryMetadataDialogTitle": "메타데이터 삭제",
|
||||
"removeEntryMetadataDialogMore": "더 보기",
|
||||
|
||||
"videoSpeedDialogLabel": "재생 배속",
|
||||
|
||||
"videoStreamSelectionDialogVideo": "동영상",
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
enum EntryInfoAction {
|
||||
editDate,
|
||||
removeMetadata,
|
||||
}
|
||||
|
|
|
@ -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<bool> removeMetadata(Set<MetadataType> 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<bool> delete() {
|
||||
final completer = Completer<bool>();
|
||||
mediaFileService.delete([this]).listen(
|
||||
|
|
|
@ -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';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -12,6 +12,8 @@ abstract class MetadataEditService {
|
|||
Future<Map<String, dynamic>> flip(AvesEntry entry);
|
||||
|
||||
Future<Map<String, dynamic>> editDate(AvesEntry entry, DateModifier modifier);
|
||||
|
||||
Future<Map<String, dynamic>> removeTypes(AvesEntry entry, Set<MetadataType> types);
|
||||
}
|
||||
|
||||
class PlatformMetadataEditService implements MetadataEditService {
|
||||
|
@ -77,6 +79,20 @@ class PlatformMetadataEditService implements MetadataEditService {
|
|||
return {};
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Map<String, dynamic>> removeTypes(AvesEntry entry, Set<MetadataType> types) async {
|
||||
try {
|
||||
final result = await platform.invokeMethod('removeTypes', <String, dynamic>{
|
||||
'entry': _toPlatformEntryMap(entry),
|
||||
'types': types.map(_toPlatformMetadataType).toList(),
|
||||
});
|
||||
if (result != null) return (result as Map).cast<String, dynamic>();
|
||||
} 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';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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')],
|
||||
|
|
|
@ -71,7 +71,7 @@ class _EditEntryDateDialogState extends State<EditEntryDateDialog> {
|
|||
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<EditEntryDateDialog> {
|
|||
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<EditEntryDateDialog> {
|
|||
),
|
||||
child: AvesDialog(
|
||||
context: context,
|
||||
title: context.l10n.editEntryDateDialogTitle,
|
||||
title: l10n.editEntryDateDialogTitle,
|
||||
scrollableContent: [
|
||||
setTile,
|
||||
shiftTile,
|
||||
|
@ -156,7 +156,7 @@ class _EditEntryDateDialogState extends State<EditEntryDateDialog> {
|
|||
),
|
||||
TextButton(
|
||||
onPressed: () => _submit(context),
|
||||
child: Text(context.l10n.applyButtonLabel),
|
||||
child: Text(l10n.applyButtonLabel),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
128
lib/widgets/dialogs/remove_entry_metadata_dialog.dart
Normal file
128
lib/widgets/dialogs/remove_entry_metadata_dialog.dart
Normal file
|
@ -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<RemoveEntryMetadataDialog> {
|
||||
late final List<MetadataType> _mainOptions, _moreOptions;
|
||||
final Set<MetadataType> _types = {};
|
||||
bool _showMore = false;
|
||||
final ValueNotifier<bool> _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<bool>(
|
||||
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);
|
||||
}
|
|
@ -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<void> _showMetadataRemovalDialog(BuildContext context) async {
|
||||
final types = await showDialog<Set<MetadataType>>(
|
||||
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<CollectionSource>().refreshMetadata({entry});
|
||||
showFeedback(context, context.l10n.genericSuccessFeedback);
|
||||
} else {
|
||||
showFeedback(context, context.l10n.genericFailureFeedback);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue