info: show XMP history and some other structs via cards

This commit is contained in:
Thibault Deckers 2020-12-07 13:07:20 +09:00
parent ab6124e093
commit f899f563e8
17 changed files with 619 additions and 167 deletions

View file

@ -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<String>("uri")?.let { Uri.parse(it) }
if (uri == null) {
result.error("getTiffStructure-args", "failed because of missing arguments", null)
return
}
try {
val metadataMap = HashMap<String, FieldMap>()
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"

View file

@ -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))
}
}
}

View file

@ -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);

View file

@ -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<String> rawImages = [arw, cr2, crw, dcr, dng, erf, k25, kdc, mrw, nef, nrw, orf, pef, raf, raw, rw2, sr2, srf, srw, x3f];

View file

@ -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<Map> getTiffStructure(ImageEntry entry) async {
if (entry.mimeType != MimeTypes.tiff) return {};
try {
final result = await platform.invokeMethod('getTiffStructure', <String, dynamic>{
'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 {};
}
}

View file

@ -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);

View file

@ -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;

View file

@ -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);

View file

@ -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<MultiCrossFader> {
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,
);
}
}

View file

@ -30,7 +30,6 @@ class AvesExpansionTile extends StatelessWidget {
title: HighlightTitle(
title,
color: color,
fontSize: 18,
enabled: enabled,
),
expandable: enabled,

View file

@ -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);

View file

@ -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<MetadataTab> {
Future<Map> _bitmapFactoryLoader, _contentResolverMetadataLoader, _exifInterfaceMetadataLoader, _mediaMetadataLoader, _metadataExtractorLoader;
Future<Map> _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<MetadataTab> {
_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<Map> 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<MetadataTab> {
);
}
Widget builderFromSnapshot(BuildContext context, AsyncSnapshot<Map> 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<Map>(
future: _bitmapFactoryLoader,
builder: (context, snapshot) => builder(context, snapshot, 'Bitmap Factory'),
builder: (context, snapshot) => builderFromSnapshot(context, snapshot, 'Bitmap Factory'),
),
FutureBuilder<Map>(
future: _contentResolverMetadataLoader,
builder: (context, snapshot) => builder(context, snapshot, 'Content Resolver'),
builder: (context, snapshot) => builderFromSnapshot(context, snapshot, 'Content Resolver'),
),
FutureBuilder<Map>(
future: _exifInterfaceMetadataLoader,
builder: (context, snapshot) => builder(context, snapshot, 'Exif Interface'),
builder: (context, snapshot) => builderFromSnapshot(context, snapshot, 'Exif Interface'),
),
FutureBuilder<Map>(
future: _mediaMetadataLoader,
builder: (context, snapshot) => builder(context, snapshot, 'Media Metadata Retriever'),
builder: (context, snapshot) => builderFromSnapshot(context, snapshot, 'Media Metadata Retriever'),
),
FutureBuilder<Map>(
future: _metadataExtractorLoader,
builder: (context, snapshot) => builder(context, snapshot, 'Metadata Extractor'),
builder: (context, snapshot) => builderFromSnapshot(context, snapshot, 'Metadata Extractor'),
),
if (entry.mimeType == MimeTypes.tiff)
FutureBuilder<Map>(
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(),
);
},
),
],
);
}

View file

@ -86,37 +86,42 @@ class _MetadataSectionSliverState extends State<MetadataSectionSliver> 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<ScrollNotification>(
// 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,
);
},
},
),
),
);
}

View file

@ -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<Widget> buildNamespaceSection({
@required List<MapEntry<String, String>> props,
@required void Function(String propPath) openEmbeddedData,
}) {
final linkHandlers = <String, InfoLinkHandler>{};
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<String, String> 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<int, Map<String, String>> 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, () => <String, String>{});
fields[field] = value;
return true;
}
bool extractData(String propPath, String value) => false;
List<Widget> 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 = <int, Map<String, String>>{};
XmpBasicNamespace() : super(ns);
@override
bool extractData(String propPath, String value) => _extractIndexedStruct(propPath, value, thumbnailsPattern, thumbnails);
@override
List<Widget> 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 = <String, String>{};
XmpIptcCoreNamespace() : super(ns);
@override
bool extractData(String propPath, String value) => _extractStruct(propPath, value, creatorContactInfoPattern, creatorContactInfo);
@override
List<Widget> 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 = <String, String>{};
final history = <int, Map<String, String>>{};
XmpMMNamespace() : super(ns);
@override
bool extractData(String propPath, String value) => _extractStruct(propPath, value, derivedFromPattern, derivedFrom) || _extractIndexedStruct(propPath, value, historyPattern, history);
@override
List<Widget> 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;
}
}

View file

@ -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<Map<String, String>> structs = [];
XmpStructArrayCard({
@required this.title,
@required Map<int, Map<String, String>> 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<XmpStructArrayCard> {
int _index;
List<Map<String, String>> 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<String, String> 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,
),
],
),
),
);
}
}

View file

@ -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<XmpDirTile> with FeedbackMixin {
@override
Widget build(BuildContext context) {
final thumbnail = MetadataThumbnails(source: MetadataThumbnailSource.xmp, entry: entry);
final sections = SplayTreeMap<_XmpNamespace, List<MapEntry<String, String>>>.of(
final sections = SplayTreeMap<XmpNamespace, List<MapEntry<String, String>>>.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<XmpDirTile> 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 = <String, InfoLinkHandler>{};
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<XmpDirTile> 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}';
}
}

View file

@ -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<SettingsPage> {
);
}
}
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),
);
}
}