From f899f563e8701ac007e378944a5c3cce3e815b5f Mon Sep 17 00:00:00 2001 From: Thibault Deckers Date: Mon, 7 Dec 2020 13:07:20 +0900 Subject: [PATCH] info: show XMP history and some other structs via cards --- .../aves/channel/calls/DebugHandler.kt | 67 ++++++ .../aves/channel/calls/MetadataHandler.kt | 2 + lib/model/filters/query.dart | 2 +- lib/ref/mime_types.dart | 68 +++--- lib/services/android_debug_service.dart | 15 ++ lib/theme/durations.dart | 1 + lib/theme/icons.dart | 6 +- lib/utils/constants.dart | 4 +- .../common/basic/multi_cross_fader.dart | 57 +++++ .../common/identity/aves_expansion_tile.dart | 1 - .../common/identity/highlight_title.dart | 2 +- lib/widgets/fullscreen/debug/metadata.dart | 38 ++- .../info/metadata/metadata_section.dart | 61 ++--- .../info/metadata/xmp_namespaces.dart | 218 ++++++++++++++++++ .../fullscreen/info/metadata/xmp_structs.dart | 135 +++++++++++ .../fullscreen/info/metadata/xmp_tile.dart | 94 ++------ lib/widgets/settings/settings_page.dart | 15 -- 17 files changed, 619 insertions(+), 167 deletions(-) create mode 100644 lib/widgets/common/basic/multi_cross_fader.dart create mode 100644 lib/widgets/fullscreen/info/metadata/xmp_namespaces.dart create mode 100644 lib/widgets/fullscreen/info/metadata/xmp_structs.dart diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/DebugHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/DebugHandler.kt index dcdb1fecc..a528bfb98 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/DebugHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/DebugHandler.kt @@ -14,6 +14,7 @@ import com.drew.imaging.ImageMetadataReader import com.drew.metadata.file.FileTypeDirectory import deckers.thibault.aves.metadata.ExifInterfaceHelper import deckers.thibault.aves.metadata.MediaMetadataRetrieverHelper +import deckers.thibault.aves.model.provider.FieldMap import deckers.thibault.aves.utils.LogUtils import deckers.thibault.aves.utils.MimeTypes.isImage import deckers.thibault.aves.utils.MimeTypes.isSupportedByExifInterface @@ -25,6 +26,7 @@ import io.flutter.plugin.common.MethodChannel import io.flutter.plugin.common.MethodChannel.MethodCallHandler import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.launch +import org.beyka.tiffbitmapfactory.TiffBitmapFactory import java.io.IOException import java.util.* @@ -38,6 +40,7 @@ class DebugHandler(private val context: Context) : MethodCallHandler { "getExifInterfaceMetadata" -> GlobalScope.launch { getExifInterfaceMetadata(call, Coresult(result)) } "getMediaMetadataRetrieverMetadata" -> GlobalScope.launch { getMediaMetadataRetrieverMetadata(call, Coresult(result)) } "getMetadataExtractorSummary" -> GlobalScope.launch { getMetadataExtractorSummary(call, Coresult(result)) } + "getTiffStructure" -> GlobalScope.launch { getTiffStructure(call, Coresult(result)) } else -> result.notImplemented() } } @@ -226,6 +229,70 @@ class DebugHandler(private val context: Context) : MethodCallHandler { result.success(metadataMap) } + private fun getTiffStructure(call: MethodCall, result: MethodChannel.Result) { + val uri = call.argument("uri")?.let { Uri.parse(it) } + if (uri == null) { + result.error("getTiffStructure-args", "failed because of missing arguments", null) + return + } + + try { + val metadataMap = HashMap() + var dirCount: Int? = null + context.contentResolver.openFileDescriptor(uri, "r")?.use { descriptor -> + val options = TiffBitmapFactory.Options().apply { + inJustDecodeBounds = true + } + TiffBitmapFactory.decodeFileDescriptor(descriptor.fd, options) + metadataMap["0"] = tiffOptionsToMap(options) + dirCount = options.outDirectoryCount + } + if (dirCount != null) { + for (i in 1 until dirCount!!) { + context.contentResolver.openFileDescriptor(uri, "r")?.use { descriptor -> + val options = TiffBitmapFactory.Options().apply { + inJustDecodeBounds = true + inDirectoryNumber = i + } + TiffBitmapFactory.decodeFileDescriptor(descriptor.fd, options) + metadataMap["$i"] = tiffOptionsToMap(options) + } + } + } + result.success(metadataMap) + } catch (e: Exception) { + result.error("getTiffStructure-read", "failed to read tiff", e.message) + } + } + + private fun tiffOptionsToMap(options: TiffBitmapFactory.Options): FieldMap = hashMapOf( + "Author" to options.outAuthor, + "BitsPerSample" to options.outBitsPerSample.toString(), + "CompressionScheme" to options.outCompressionScheme?.toString(), + "Copyright" to options.outCopyright, + "CurDirectoryNumber" to options.outCurDirectoryNumber.toString(), + "Datetime" to options.outDatetime, + "DirectoryCount" to options.outDirectoryCount.toString(), + "FillOrder" to options.outFillOrder?.toString(), + "Height" to options.outHeight.toString(), + "HostComputer" to options.outHostComputer, + "ImageDescription" to options.outImageDescription, + "ImageOrientation" to options.outImageOrientation?.toString(), + "NumberOfStrips" to options.outNumberOfStrips.toString(), + "Photometric" to options.outPhotometric?.toString(), + "PlanarConfig" to options.outPlanarConfig?.toString(), + "ResolutionUnit" to options.outResolutionUnit?.toString(), + "RowPerStrip" to options.outRowPerStrip.toString(), + "SamplePerPixel" to options.outSamplePerPixel.toString(), + "Software" to options.outSoftware, + "StripSize" to options.outStripSize.toString(), + "TileHeight" to options.outTileHeight.toString(), + "TileWidth" to options.outTileWidth.toString(), + "Width" to options.outWidth.toString(), + "XResolution" to options.outXResolution.toString(), + "YResolution" to options.outYResolution.toString(), + ) + companion object { private val LOG_TAG = LogUtils.createTag(DebugHandler::class.java) const val CHANNEL = "deckers.thibault/aves/debug" diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataHandler.kt index ab50472b2..83fcecd10 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataHandler.kt @@ -138,6 +138,8 @@ class MetadataHandler(private val context: Context) : MethodCallHandler { } catch (e: XMPException) { Log.w(LOG_TAG, "failed to read XMP directory for uri=$uri", e) } + // remove this stat as it is not actual XMP data + dirMap.remove(dir.getTagName(XmpDirectory.TAG_XMP_VALUE_COUNT)) } } } diff --git a/lib/model/filters/query.dart b/lib/model/filters/query.dart index b23b27f38..23794cdd0 100644 --- a/lib/model/filters/query.dart +++ b/lib/model/filters/query.dart @@ -26,7 +26,7 @@ class QueryFilter extends CollectionFilter { // allow untrimmed queries wrapped with `"..."` final matches = exactRegex.allMatches(upQuery); if (matches.length == 1) { - upQuery = matches.elementAt(0).group(1); + upQuery = matches.first.group(1); } _filter = not ? (entry) => !entry.search(upQuery) : (entry) => entry.search(upQuery); diff --git a/lib/ref/mime_types.dart b/lib/ref/mime_types.dart index 21af7155f..fe95d5309 100644 --- a/lib/ref/mime_types.dart +++ b/lib/ref/mime_types.dart @@ -1,43 +1,43 @@ class MimeTypes { - static const String anyImage = 'image/*'; + static const anyImage = 'image/*'; - static const String gif = 'image/gif'; - static const String heic = 'image/heic'; - static const String heif = 'image/heif'; - static const String jpeg = 'image/jpeg'; - static const String png = 'image/png'; - static const String svg = 'image/svg+xml'; - static const String webp = 'image/webp'; + static const gif = 'image/gif'; + static const heic = 'image/heic'; + static const heif = 'image/heif'; + static const jpeg = 'image/jpeg'; + static const png = 'image/png'; + static const svg = 'image/svg+xml'; + static const webp = 'image/webp'; - static const String tiff = 'image/tiff'; - static const String psd = 'image/vnd.adobe.photoshop'; + static const tiff = 'image/tiff'; + static const psd = 'image/vnd.adobe.photoshop'; - static const String arw = 'image/x-sony-arw'; - static const String cr2 = 'image/x-canon-cr2'; - static const String crw = 'image/x-canon-crw'; - static const String dcr = 'image/x-kodak-dcr'; - static const String dng = 'image/x-adobe-dng'; - static const String erf = 'image/x-epson-erf'; - static const String k25 = 'image/x-kodak-k25'; - static const String kdc = 'image/x-kodak-kdc'; - static const String mrw = 'image/x-minolta-mrw'; - static const String nef = 'image/x-nikon-nef'; - static const String nrw = 'image/x-nikon-nrw'; - static const String orf = 'image/x-olympus-orf'; - static const String pef = 'image/x-pentax-pef'; - static const String raf = 'image/x-fuji-raf'; - static const String raw = 'image/x-panasonic-raw'; - static const String rw2 = 'image/x-panasonic-rw2'; - static const String sr2 = 'image/x-sony-sr2'; - static const String srf = 'image/x-sony-srf'; - static const String srw = 'image/x-samsung-srw'; - static const String x3f = 'image/x-sigma-x3f'; + static const arw = 'image/x-sony-arw'; + static const cr2 = 'image/x-canon-cr2'; + static const crw = 'image/x-canon-crw'; + static const dcr = 'image/x-kodak-dcr'; + static const dng = 'image/x-adobe-dng'; + static const erf = 'image/x-epson-erf'; + static const k25 = 'image/x-kodak-k25'; + static const kdc = 'image/x-kodak-kdc'; + static const mrw = 'image/x-minolta-mrw'; + static const nef = 'image/x-nikon-nef'; + static const nrw = 'image/x-nikon-nrw'; + static const orf = 'image/x-olympus-orf'; + static const pef = 'image/x-pentax-pef'; + static const raf = 'image/x-fuji-raf'; + static const raw = 'image/x-panasonic-raw'; + static const rw2 = 'image/x-panasonic-rw2'; + static const sr2 = 'image/x-sony-sr2'; + static const srf = 'image/x-sony-srf'; + static const srw = 'image/x-samsung-srw'; + static const x3f = 'image/x-sigma-x3f'; - static const String anyVideo = 'video/*'; + static const anyVideo = 'video/*'; - static const String avi = 'video/avi'; - static const String mp2t = 'video/mp2t'; // .m2ts - static const String mp4 = 'video/mp4'; + static const avi = 'video/avi'; + static const mp2t = 'video/mp2t'; // .m2ts + static const mp4 = 'video/mp4'; // groups static const List rawImages = [arw, cr2, crw, dcr, dng, erf, k25, kdc, mrw, nef, nrw, orf, pef, raf, raw, rw2, sr2, srf, srw, x3f]; diff --git a/lib/services/android_debug_service.dart b/lib/services/android_debug_service.dart index c1bd79575..31392df08 100644 --- a/lib/services/android_debug_service.dart +++ b/lib/services/android_debug_service.dart @@ -1,4 +1,5 @@ import 'package:aves/model/image_entry.dart'; +import 'package:aves/ref/mime_types.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; @@ -94,4 +95,18 @@ class AndroidDebugService { } return {}; } + + static Future getTiffStructure(ImageEntry entry) async { + if (entry.mimeType != MimeTypes.tiff) return {}; + + try { + final result = await platform.invokeMethod('getTiffStructure', { + 'uri': entry.uri, + }) as Map; + return result; + } on PlatformException catch (e) { + debugPrint('getTiffStructure failed with code=${e.code}, exception=${e.message}, details=${e.details}'); + } + return {}; + } } diff --git a/lib/theme/durations.dart b/lib/theme/durations.dart index 0481bf563..4dc281fdc 100644 --- a/lib/theme/durations.dart +++ b/lib/theme/durations.dart @@ -36,6 +36,7 @@ class Durations { // info static const mapStyleSwitchAnimation = Duration(milliseconds: 300); + static const xmpStructArrayCardTransition = Duration(milliseconds: 300); // delays & refresh intervals static const opToastDisplay = Duration(seconds: 2); diff --git a/lib/theme/icons.dart b/lib/theme/icons.dart index 755e14a37..a50c1c169 100644 --- a/lib/theme/icons.dart +++ b/lib/theme/icons.dart @@ -26,11 +26,9 @@ class AIcons { // actions static const IconData addShortcut = Icons.add_to_home_screen_outlined; static const IconData clear = Icons.clear_outlined; - static const IconData collapse = Icons.expand_less_outlined; static const IconData createAlbum = Icons.add_circle_outline; static const IconData debug = Icons.whatshot_outlined; static const IconData delete = Icons.delete_outlined; - static const IconData expand = Icons.expand_more_outlined; static const IconData flip = Icons.flip_outlined; static const IconData favourite = Icons.favorite_border; static const IconData favouriteActive = Icons.favorite; @@ -52,6 +50,10 @@ class AIcons { static const IconData stats = Icons.pie_chart_outlined; static const IconData zoomIn = Icons.add_outlined; static const IconData zoomOut = Icons.remove_outlined; + static const IconData collapse = Icons.expand_less_outlined; + static const IconData expand = Icons.expand_more_outlined; + static const IconData previous = Icons.chevron_left_outlined; + static const IconData next = Icons.chevron_right_outlined; // albums static const IconData album = Icons.photo_album_outlined; diff --git a/lib/utils/constants.dart b/lib/utils/constants.dart index 59d567b65..cf0c59894 100644 --- a/lib/utils/constants.dart +++ b/lib/utils/constants.dart @@ -18,8 +18,8 @@ class Constants { offset: Offset(0.5, 1.0), ); - static const String overlayUnknown = '—'; // em dash - static const String infoUnknown = 'unknown'; + static const overlayUnknown = '—'; // em dash + static const infoUnknown = 'unknown'; static final pointNemo = LatLng(-48.876667, -123.393333); diff --git a/lib/widgets/common/basic/multi_cross_fader.dart b/lib/widgets/common/basic/multi_cross_fader.dart new file mode 100644 index 000000000..5c14dd458 --- /dev/null +++ b/lib/widgets/common/basic/multi_cross_fader.dart @@ -0,0 +1,57 @@ +import 'package:flutter/material.dart'; + +class MultiCrossFader extends StatefulWidget { + final Duration duration; + final Curve fadeCurve, sizeCurve; + final AlignmentGeometry alignment; + final Widget child; + + const MultiCrossFader({ + @required this.duration, + this.fadeCurve = Curves.linear, + this.sizeCurve = Curves.linear, + this.alignment = Alignment.topCenter, + @required this.child, + }); + + @override + _MultiCrossFaderState createState() => _MultiCrossFaderState(); +} + +class _MultiCrossFaderState extends State { + Widget _first, _second; + CrossFadeState _fadeState = CrossFadeState.showFirst; + + @override + void initState() { + super.initState(); + _first = widget.child; + _second = SizedBox(); + } + + @override + void didUpdateWidget(MultiCrossFader oldWidget) { + super.didUpdateWidget(oldWidget); + if (_first == oldWidget.child) { + _second = widget.child; + _fadeState = CrossFadeState.showSecond; + } else { + _first = widget.child; + _fadeState = CrossFadeState.showFirst; + } + } + + @override + Widget build(BuildContext context) { + return AnimatedCrossFade( + firstChild: _first, + secondChild: _second, + firstCurve: widget.fadeCurve, + secondCurve: widget.fadeCurve, + sizeCurve: widget.sizeCurve, + alignment: widget.alignment, + crossFadeState: _fadeState, + duration: widget.duration, + ); + } +} diff --git a/lib/widgets/common/identity/aves_expansion_tile.dart b/lib/widgets/common/identity/aves_expansion_tile.dart index 7ae1393c6..554d02e9b 100644 --- a/lib/widgets/common/identity/aves_expansion_tile.dart +++ b/lib/widgets/common/identity/aves_expansion_tile.dart @@ -30,7 +30,6 @@ class AvesExpansionTile extends StatelessWidget { title: HighlightTitle( title, color: color, - fontSize: 18, enabled: enabled, ), expandable: enabled, diff --git a/lib/widgets/common/identity/highlight_title.dart b/lib/widgets/common/identity/highlight_title.dart index c2433a944..377668dd3 100644 --- a/lib/widgets/common/identity/highlight_title.dart +++ b/lib/widgets/common/identity/highlight_title.dart @@ -11,7 +11,7 @@ class HighlightTitle extends StatelessWidget { const HighlightTitle( this.title, { this.color, - this.fontSize = 20, + this.fontSize = 18, this.enabled = true, this.selectable = false, }) : assert(title != null); diff --git a/lib/widgets/fullscreen/debug/metadata.dart b/lib/widgets/fullscreen/debug/metadata.dart index 3cf5bc723..fe655bdff 100644 --- a/lib/widgets/fullscreen/debug/metadata.dart +++ b/lib/widgets/fullscreen/debug/metadata.dart @@ -2,6 +2,7 @@ import 'dart:collection'; import 'dart:typed_data'; import 'package:aves/model/image_entry.dart'; +import 'package:aves/ref/mime_types.dart'; import 'package:aves/services/android_debug_service.dart'; import 'package:aves/utils/constants.dart'; import 'package:aves/widgets/common/identity/aves_expansion_tile.dart'; @@ -18,7 +19,7 @@ class MetadataTab extends StatefulWidget { } class _MetadataTabState extends State { - Future _bitmapFactoryLoader, _contentResolverMetadataLoader, _exifInterfaceMetadataLoader, _mediaMetadataLoader, _metadataExtractorLoader; + Future _bitmapFactoryLoader, _contentResolverMetadataLoader, _exifInterfaceMetadataLoader, _mediaMetadataLoader, _metadataExtractorLoader, _tiffStructureLoader; // MediaStore timestamp keys static const secondTimestampKeys = ['date_added', 'date_modified', 'date_expires', 'isPlayed']; @@ -38,15 +39,14 @@ class _MetadataTabState extends State { _exifInterfaceMetadataLoader = AndroidDebugService.getExifInterfaceMetadata(entry); _mediaMetadataLoader = AndroidDebugService.getMediaMetadataRetrieverMetadata(entry); _metadataExtractorLoader = AndroidDebugService.getMetadataExtractorSummary(entry); + _tiffStructureLoader = AndroidDebugService.getTiffStructure(entry); setState(() {}); } @override Widget build(BuildContext context) { - Widget builder(BuildContext context, AsyncSnapshot snapshot, String title) { - if (snapshot.hasError) return Text(snapshot.error.toString()); - if (snapshot.connectionState != ConnectionState.done) return SizedBox.shrink(); - final data = SplayTreeMap.of(snapshot.data.map((k, v) { + Widget builderFromSnapshotData(BuildContext context, Map snapshotData, String title) { + final data = SplayTreeMap.of(snapshotData.map((k, v) { final key = k.toString(); var value = v?.toString() ?? 'null'; if ([...secondTimestampKeys, ...millisecondTimestampKeys].contains(key) && v is num && v != 0) { @@ -76,29 +76,47 @@ class _MetadataTabState extends State { ); } + Widget builderFromSnapshot(BuildContext context, AsyncSnapshot snapshot, String title) { + if (snapshot.hasError) return Text(snapshot.error.toString()); + if (snapshot.connectionState != ConnectionState.done) return SizedBox.shrink(); + return builderFromSnapshotData(context, snapshot.data, title); + } + return ListView( padding: EdgeInsets.all(8), children: [ FutureBuilder( future: _bitmapFactoryLoader, - builder: (context, snapshot) => builder(context, snapshot, 'Bitmap Factory'), + builder: (context, snapshot) => builderFromSnapshot(context, snapshot, 'Bitmap Factory'), ), FutureBuilder( future: _contentResolverMetadataLoader, - builder: (context, snapshot) => builder(context, snapshot, 'Content Resolver'), + builder: (context, snapshot) => builderFromSnapshot(context, snapshot, 'Content Resolver'), ), FutureBuilder( future: _exifInterfaceMetadataLoader, - builder: (context, snapshot) => builder(context, snapshot, 'Exif Interface'), + builder: (context, snapshot) => builderFromSnapshot(context, snapshot, 'Exif Interface'), ), FutureBuilder( future: _mediaMetadataLoader, - builder: (context, snapshot) => builder(context, snapshot, 'Media Metadata Retriever'), + builder: (context, snapshot) => builderFromSnapshot(context, snapshot, 'Media Metadata Retriever'), ), FutureBuilder( future: _metadataExtractorLoader, - builder: (context, snapshot) => builder(context, snapshot, 'Metadata Extractor'), + builder: (context, snapshot) => builderFromSnapshot(context, snapshot, 'Metadata Extractor'), ), + if (entry.mimeType == MimeTypes.tiff) + FutureBuilder( + future: _tiffStructureLoader, + builder: (context, snapshot) { + if (snapshot.hasError) return Text(snapshot.error.toString()); + if (snapshot.connectionState != ConnectionState.done) return SizedBox.shrink(); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: snapshot.data.entries.map((kv) => builderFromSnapshotData(context, kv.value as Map, 'TIFF ${kv.key}')).toList(), + ); + }, + ), ], ); } diff --git a/lib/widgets/fullscreen/info/metadata/metadata_section.dart b/lib/widgets/fullscreen/info/metadata/metadata_section.dart index 438fb3807..ef9121ccd 100644 --- a/lib/widgets/fullscreen/info/metadata/metadata_section.dart +++ b/lib/widgets/fullscreen/info/metadata/metadata_section.dart @@ -86,37 +86,42 @@ class _MetadataSectionSliverState extends State with Auto // warning: placing the `AnimationLimiter` as a parent to the `ScrollView` // triggers dispose & reinitialization of other sections, including heavy widgets like maps return SliverToBoxAdapter( - child: AnimatedBuilder( - animation: _loadedMetadataUri, - builder: (context, child) { - Widget content; - if (_metadata.isEmpty) { - content = SizedBox.shrink(); - } else { - content = Column( - children: AnimationConfiguration.toStaggeredList( - duration: Durations.staggeredAnimation, - delay: Durations.staggeredAnimationDelay, - childAnimationBuilder: (child) => SlideAnimation( - verticalOffset: 50.0, - child: FadeInAnimation( - child: child, + child: NotificationListener( + // cancel notification bubbling so that the info page + // does not misinterpret content scrolling for page scrolling + onNotification: (notification) => true, + child: AnimatedBuilder( + animation: _loadedMetadataUri, + builder: (context, child) { + Widget content; + if (_metadata.isEmpty) { + content = SizedBox.shrink(); + } else { + content = Column( + children: AnimationConfiguration.toStaggeredList( + duration: Durations.staggeredAnimation, + delay: Durations.staggeredAnimationDelay, + childAnimationBuilder: (child) => SlideAnimation( + verticalOffset: 50.0, + child: FadeInAnimation( + child: child, + ), ), + children: [ + SectionRow(AIcons.info), + ..._metadata.entries.map((kv) => _buildDirTile(kv.key, kv.value)), + ], ), - children: [ - SectionRow(AIcons.info), - ..._metadata.entries.map((kv) => _buildDirTile(kv.key, kv.value)), - ], - ), + ); + } + return AnimationLimiter( + // we update the limiter key after fetching the metadata of a new entry, + // in order to restart the staggered animation of the metadata section + key: Key(_loadedMetadataUri.value), + child: content, ); - } - return AnimationLimiter( - // we update the limiter key after fetching the metadata of a new entry, - // in order to restart the staggered animation of the metadata section - key: Key(_loadedMetadataUri.value), - child: content, - ); - }, + }, + ), ), ); } diff --git a/lib/widgets/fullscreen/info/metadata/xmp_namespaces.dart b/lib/widgets/fullscreen/info/metadata/xmp_namespaces.dart new file mode 100644 index 000000000..9fc45fa16 --- /dev/null +++ b/lib/widgets/fullscreen/info/metadata/xmp_namespaces.dart @@ -0,0 +1,218 @@ +import 'package:aves/ref/brand_colors.dart'; +import 'package:aves/ref/xmp.dart'; +import 'package:aves/utils/constants.dart'; +import 'package:aves/widgets/common/identity/highlight_title.dart'; +import 'package:aves/widgets/fullscreen/info/common.dart'; +import 'package:aves/widgets/fullscreen/info/metadata/xmp_structs.dart'; +import 'package:collection/collection.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; + +class XmpNamespace { + final String namespace; + + const XmpNamespace(this.namespace); + + String get displayTitle => XMP.namespaces[namespace] ?? namespace; + + List buildNamespaceSection({ + @required List> props, + @required void Function(String propPath) openEmbeddedData, + }) { + final linkHandlers = {}; + + final entries = props + .map((prop) { + final propPath = prop.key; + final value = formatValue(prop.value); + if (extractData(propPath, value)) return null; + + final displayKey = _formatKey(propPath); + if (XMP.dataProps.contains(propPath)) { + linkHandlers.putIfAbsent( + displayKey, + () => InfoLinkHandler(linkText: 'Open', onTap: () => openEmbeddedData(propPath)), + ); + } + return MapEntry(displayKey, value); + }) + .where((e) => e != null) + .toList() + ..sort((a, b) => compareAsciiUpperCaseNatural(a.key, b.key)); + + final content = [ + if (entries.isNotEmpty) + InfoRowGroup( + Map.fromEntries(entries), + maxValueLength: Constants.infoGroupMaxValueLength, + linkHandlers: linkHandlers, + ), + ...buildFromExtractedData(), + ]; + + return content.isNotEmpty + ? [ + if (displayTitle.isNotEmpty) + Padding( + padding: EdgeInsets.only(top: 8), + child: HighlightTitle( + displayTitle, + color: BrandColors.get(displayTitle), + selectable: true, + ), + ), + ...content + ] + : []; + } + + String _formatKey(String propPath) { + return propPath.splitMapJoin(XMP.structFieldSeparator, onNonMatch: (s) { + // strip namespace + final key = s.split(XMP.propNamespaceSeparator).last; + // uppercase first letter + return key.replaceFirstMapped(RegExp('.'), (m) => m.group(0).toUpperCase()); + }); + } + + bool _extractStruct(String propPath, String value, RegExp pattern, Map store) { + final matches = pattern.allMatches(propPath); + if (matches.isEmpty) return false; + + final match = matches.first; + final field = _formatKey(match.group(1)); + store[field] = value; + return true; + } + + bool _extractIndexedStruct(String propPath, String value, RegExp pattern, Map> store) { + final matches = pattern.allMatches(propPath); + if (matches.isEmpty) return false; + + final match = matches.first; + final index = int.parse(match.group(1)); + final field = _formatKey(match.group(2)); + final fields = store.putIfAbsent(index, () => {}); + fields[field] = value; + return true; + } + + bool extractData(String propPath, String value) => false; + + List buildFromExtractedData() => []; + + String formatValue(String value) => value; + + // identity + + @override + bool operator ==(Object other) { + if (other.runtimeType != runtimeType) return false; + return other is XmpNamespace && other.namespace == namespace; + } + + @override + int get hashCode => namespace.hashCode; + + @override + String toString() { + return '$runtimeType#${shortHash(this)}{namespace=$namespace}'; + } +} + +class XmpBasicNamespace extends XmpNamespace { + static const ns = 'xmp'; + + static final thumbnailsPattern = RegExp(r'xmp:Thumbnails\[(\d+)\]/(.*)'); + + final thumbnails = >{}; + + XmpBasicNamespace() : super(ns); + + @override + bool extractData(String propPath, String value) => _extractIndexedStruct(propPath, value, thumbnailsPattern, thumbnails); + + @override + List buildFromExtractedData() => [ + if (thumbnails.isNotEmpty) + XmpStructArrayCard( + title: 'Thumbnail', + structByIndex: thumbnails, + ) + ]; +} + +class XmpIptcCoreNamespace extends XmpNamespace { + static const ns = 'Iptc4xmpCore'; + + static final creatorContactInfoPattern = RegExp(r'Iptc4xmpCore:CreatorContactInfo/(.*)'); + + final creatorContactInfo = {}; + + XmpIptcCoreNamespace() : super(ns); + + @override + bool extractData(String propPath, String value) => _extractStruct(propPath, value, creatorContactInfoPattern, creatorContactInfo); + + @override + List buildFromExtractedData() => [ + if (creatorContactInfo.isNotEmpty) + XmpStructCard( + title: 'Creator Contact Info', + struct: creatorContactInfo, + ), + ]; +} + +class XmpMMNamespace extends XmpNamespace { + static const ns = 'xmpMM'; + + static const didPrefix = 'xmp.did:'; + static const iidPrefix = 'xmp.iid:'; + + static final derivedFromPattern = RegExp(r'xmpMM:DerivedFrom/(.*)'); + static final historyPattern = RegExp(r'xmpMM:History\[(\d+)\]/(.*)'); + + final derivedFrom = {}; + final history = >{}; + + XmpMMNamespace() : super(ns); + + @override + bool extractData(String propPath, String value) => _extractStruct(propPath, value, derivedFromPattern, derivedFrom) || _extractIndexedStruct(propPath, value, historyPattern, history); + + @override + List buildFromExtractedData() => [ + if (derivedFrom.isNotEmpty) + XmpStructCard( + title: 'Derived From', + struct: derivedFrom, + ), + if (history.isNotEmpty) + XmpStructArrayCard( + title: 'History', + structByIndex: history, + ), + ]; + + @override + String formatValue(String value) { + if (value.startsWith(didPrefix)) return value.replaceFirst(didPrefix, ''); + if (value.startsWith(iidPrefix)) return value.replaceFirst(iidPrefix, ''); + return value; + } +} + +class XmpNoteNamespace extends XmpNamespace { + static const ns = 'xmpNote'; + + // `xmpNote:HasExtendedXMP` is structural and should not be displayed to users + static const hasExtendedXmp = '$ns:HasExtendedXMP'; + + XmpNoteNamespace() : super(ns); + + @override + bool extractData(String propPath, String value) { + return propPath == hasExtendedXmp; + } +} diff --git a/lib/widgets/fullscreen/info/metadata/xmp_structs.dart b/lib/widgets/fullscreen/info/metadata/xmp_structs.dart new file mode 100644 index 000000000..e45e6312b --- /dev/null +++ b/lib/widgets/fullscreen/info/metadata/xmp_structs.dart @@ -0,0 +1,135 @@ +import 'dart:math'; + +import 'package:aves/theme/durations.dart'; +import 'package:aves/theme/icons.dart'; +import 'package:aves/utils/constants.dart'; +import 'package:aves/widgets/common/basic/multi_cross_fader.dart'; +import 'package:aves/widgets/common/identity/highlight_title.dart'; +import 'package:aves/widgets/fullscreen/info/common.dart'; +import 'package:flutter/material.dart'; + +class XmpStructArrayCard extends StatefulWidget { + final String title; + final List> structs = []; + + XmpStructArrayCard({ + @required this.title, + @required Map> structByIndex, + }) { + structs.length = structByIndex.keys.fold(0, max); + structByIndex.keys.forEach((index) => structs[index - 1] = structByIndex[index]); + } + + @override + _XmpStructArrayCardState createState() => _XmpStructArrayCardState(); +} + +class _XmpStructArrayCardState extends State { + int _index; + + List> get structs => widget.structs; + + @override + void initState() { + super.initState(); + _index = structs.length - 1; + } + + @override + Widget build(BuildContext context) { + void setIndex(int index) { + index = index.clamp(0, structs.length - 1); + if (_index != index) { + _index = index; + setState(() {}); + } + } + + return Card( + margin: XmpStructCard.cardMargin, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: EdgeInsets.only(left: 8, top: 8, right: 8), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Flexible( + child: HighlightTitle( + '${widget.title} ${_index + 1}', + color: Colors.transparent, + selectable: true, + ), + ), + IconButton( + visualDensity: VisualDensity.compact, + icon: Icon(AIcons.previous), + onPressed: _index > 0 ? () => setIndex(_index - 1) : null, + tooltip: 'Previous', + ), + IconButton( + visualDensity: VisualDensity.compact, + icon: Icon(AIcons.next), + onPressed: _index < structs.length - 1 ? () => setIndex(_index + 1) : null, + tooltip: 'Next', + ), + ], + ), + ), + MultiCrossFader( + duration: Durations.xmpStructArrayCardTransition, + sizeCurve: Curves.easeOutBack, + alignment: AlignmentDirectional.topStart, + child: Padding( + // add padding at this level (instead of the column level) + // so that the crossfader can animate the content size + // without clipping the text + padding: EdgeInsets.only(left: 8, right: 8, bottom: 8), + child: InfoRowGroup( + structs[_index], + maxValueLength: Constants.infoGroupMaxValueLength, + ), + ), + ), + ], + ), + ); + } +} + +class XmpStructCard extends StatelessWidget { + final String title; + final Map struct; + + static const cardMargin = EdgeInsets.symmetric(vertical: 8, horizontal: 0); + + const XmpStructCard({ + @required this.title, + @required this.struct, + }); + + @override + Widget build(BuildContext context) { + return Card( + margin: cardMargin, + child: Padding( + padding: EdgeInsets.all(8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + HighlightTitle( + title, + color: Colors.transparent, + selectable: true, + ), + InfoRowGroup( + struct, + maxValueLength: Constants.infoGroupMaxValueLength, + ), + ], + ), + ), + ); + } +} diff --git a/lib/widgets/fullscreen/info/metadata/xmp_tile.dart b/lib/widgets/fullscreen/info/metadata/xmp_tile.dart index 5d3226803..c557eadf7 100644 --- a/lib/widgets/fullscreen/info/metadata/xmp_tile.dart +++ b/lib/widgets/fullscreen/info/metadata/xmp_tile.dart @@ -1,22 +1,18 @@ import 'dart:collection'; import 'package:aves/model/image_entry.dart'; -import 'package:aves/ref/brand_colors.dart'; import 'package:aves/ref/mime_types.dart'; import 'package:aves/ref/xmp.dart'; import 'package:aves/services/android_app_service.dart'; import 'package:aves/services/metadata_service.dart'; -import 'package:aves/utils/constants.dart'; import 'package:aves/widgets/common/action_mixins/feedback.dart'; import 'package:aves/widgets/common/behaviour/routes.dart'; import 'package:aves/widgets/common/identity/aves_expansion_tile.dart'; -import 'package:aves/widgets/common/identity/highlight_title.dart'; import 'package:aves/widgets/dialogs/aves_dialog.dart'; import 'package:aves/widgets/fullscreen/fullscreen_page.dart'; -import 'package:aves/widgets/fullscreen/info/common.dart'; import 'package:aves/widgets/fullscreen/info/metadata/metadata_thumbnail.dart'; +import 'package:aves/widgets/fullscreen/info/metadata/xmp_namespaces.dart'; import 'package:collection/collection.dart'; -import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:pedantic/pedantic.dart'; @@ -41,13 +37,23 @@ class _XmpDirTileState extends State with FeedbackMixin { @override Widget build(BuildContext context) { final thumbnail = MetadataThumbnails(source: MetadataThumbnailSource.xmp, entry: entry); - final sections = SplayTreeMap<_XmpNamespace, List>>.of( + final sections = SplayTreeMap>>.of( groupBy(widget.tags.entries, (kv) { final fullKey = kv.key; final i = fullKey.indexOf(XMP.propNamespaceSeparator); - if (i == -1) return _XmpNamespace(''); - final namespace = fullKey.substring(0, i); - return _XmpNamespace(namespace); + final namespace = i == -1 ? '' : fullKey.substring(0, i); + switch (namespace) { + case XmpBasicNamespace.ns: + return XmpBasicNamespace(); + case XmpIptcCoreNamespace.ns: + return XmpIptcCoreNamespace(); + case XmpMMNamespace.ns: + return XmpMMNamespace(); + case XmpNoteNamespace.ns: + return XmpNoteNamespace(); + default: + return XmpNamespace(namespace); + } }), (a, b) => compareAsciiUpperCase(a.displayTitle, b.displayTitle), ); @@ -60,48 +66,12 @@ class _XmpDirTileState extends State with FeedbackMixin { padding: EdgeInsets.only(left: 8, right: 8, bottom: 8), child: Column( crossAxisAlignment: CrossAxisAlignment.start, - children: sections.entries.expand((namespaceProps) { - final namespace = namespaceProps.key; - final displayNamespace = namespace.displayTitle; - final linkHandlers = {}; - - final entries = namespaceProps.value.map((prop) { - final propPath = prop.key; - - final displayKey = propPath.splitMapJoin(XMP.structFieldSeparator, onNonMatch: (s) { - // strip namespace - final key = s.split(XMP.propNamespaceSeparator).last; - // uppercase first letter - return key.replaceFirstMapped(RegExp('.'), (m) => m.group(0).toUpperCase()); - }); - - var value = prop.value; - if (XMP.dataProps.contains(propPath)) { - linkHandlers.putIfAbsent( - displayKey, - () => InfoLinkHandler(linkText: 'Open', onTap: () => _openEmbeddedData(propPath)), - ); - } - return MapEntry(displayKey, value); - }).toList() - ..sort((a, b) => compareAsciiUpperCaseNatural(a.key, b.key)); - return [ - if (displayNamespace.isNotEmpty) - Padding( - padding: EdgeInsets.only(top: 8), - child: HighlightTitle( - displayNamespace, - color: BrandColors.get(displayNamespace), - selectable: true, - ), - ), - InfoRowGroup( - Map.fromEntries(entries), - maxValueLength: Constants.infoGroupMaxValueLength, - linkHandlers: linkHandlers, - ), - ]; - }).toList(), + children: sections.entries + .expand((kv) => kv.key.buildNamespaceSection( + props: kv.value, + openEmbeddedData: _openEmbeddedData, + )) + .toList(), ), ), ], @@ -140,25 +110,3 @@ class _XmpDirTileState extends State with FeedbackMixin { )); } } - -class _XmpNamespace { - final String namespace; - - const _XmpNamespace(this.namespace); - - String get displayTitle => XMP.namespaces[namespace] ?? namespace; - - @override - bool operator ==(Object other) { - if (other.runtimeType != runtimeType) return false; - return other is _XmpNamespace && other.namespace == namespace; - } - - @override - int get hashCode => namespace.hashCode; - - @override - String toString() { - return '$runtimeType#${shortHash(this)}{namespace=$namespace}'; - } -} diff --git a/lib/widgets/settings/settings_page.dart b/lib/widgets/settings/settings_page.dart index 8661c13d5..a6e52de38 100644 --- a/lib/widgets/settings/settings_page.dart +++ b/lib/widgets/settings/settings_page.dart @@ -5,7 +5,6 @@ import 'package:aves/model/settings/settings.dart'; import 'package:aves/theme/durations.dart'; import 'package:aves/utils/constants.dart'; import 'package:aves/widgets/common/identity/aves_expansion_tile.dart'; -import 'package:aves/widgets/common/identity/highlight_title.dart'; import 'package:aves/widgets/common/providers/media_query_data_provider.dart'; import 'package:aves/widgets/dialogs/aves_selection_dialog.dart'; import 'package:aves/widgets/settings/access_grants.dart'; @@ -225,17 +224,3 @@ class _SettingsPageState extends State { ); } } - -class SectionTitle extends StatelessWidget { - final String text; - - const SectionTitle(this.text); - - @override - Widget build(BuildContext context) { - return Padding( - padding: EdgeInsets.only(left: 16, top: 6, right: 16, bottom: 12), - child: HighlightTitle(text), - ); - } -}