info: improved display for XMP

This commit is contained in:
Thibault Deckers 2022-10-01 11:30:54 +02:00
parent 47a2364f5a
commit f687622997
17 changed files with 426 additions and 642 deletions

View file

@ -10,6 +10,7 @@ All notable changes to this project will be documented in this file.
- reverse filters to filter out/in
- Collection: selection edit actions available as quick actions
- Albums: group by content type
- Info: improved display for XMP
- Stats: top albums
- Stats: open full top listings
- Slideshow: option for no transition

View file

@ -10,6 +10,7 @@ class Namespaces {
static const container = 'http://ns.google.com/photos/1.0/container/';
static const creatorAtom = 'http://ns.adobe.com/creatorAtom/1.0/';
static const crd = 'http://ns.adobe.com/camera-raw-defaults/1.0/';
static const crlcp = 'http://ns.adobe.com/camera-raw-embedded-lens-profile/1.0/';
static const crs = 'http://ns.adobe.com/camera-raw-settings/1.0/';
static const crss = 'http://ns.adobe.com/camera-raw-saved-settings/1.0/';
static const darktable = 'http://darktable.sf.net/';
@ -30,7 +31,8 @@ class Namespaces {
static const gettyImagesGift = 'http://xmp.gettyimages.com/gift/1.0/';
static const gFocus = 'http://ns.google.com/photos/1.0/focus/';
static const gImage = 'http://ns.google.com/photos/1.0/image/';
static const gimp = 'http://www.gimp.org/ns/2.10/';
static const gimp210 = 'http://www.gimp.org/ns/2.10/';
static const gimpXmp = 'http://www.gimp.org/xmp/';
static const gPano = 'http://ns.google.com/photos/1.0/panorama/';
static const gSpherical = 'http://ns.google.com/videos/1.0/spherical/';
static const illustrator = 'http://ns.adobe.com/illustrator/1.0/';
@ -56,6 +58,7 @@ class Namespaces {
static const plus = 'http://ns.useplus.org/ldf/xmp/1.0/';
static const pmtm = 'http://www.hdrsoft.com/photomatix_settings01';
static const rdf = 'http://www.w3.org/1999/02/22-rdf-syntax-ns#';
static const stCamera = 'http://ns.adobe.com/photoshop/1.0/camera-profile';
static const stEvt = 'http://ns.adobe.com/xap/1.0/sType/ResourceEvent#';
static const stRef = 'http://ns.adobe.com/xap/1.0/sType/ResourceRef#';
static const tiff = 'http://ns.adobe.com/tiff/1.0/';
@ -96,7 +99,8 @@ class Namespaces {
gDepth: 'Google Depth',
gFocus: 'Google Focus',
gImage: 'Google Image',
gimp: 'GIMP',
gimp210: 'GIMP 2.10',
gimpXmp: 'GIMP',
gPano: 'Google Panorama',
gSpherical: 'Google Spherical',
illustrator: 'Illustrator',
@ -122,6 +126,7 @@ class Namespaces {
xmpBJ: 'Basic Job Ticket',
xmpDM: 'Dynamic Media',
xmpMM: 'Media Management',
xmpNote: 'Note',
xmpRights: 'Rights Management',
xmpTPg: 'Paged-Text',
};

View file

@ -0,0 +1,157 @@
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/extensions/build_context.dart';
import 'package:aves/widgets/common/identity/highlight_title.dart';
import 'package:aves/widgets/viewer/info/common.dart';
import 'package:aves/widgets/viewer/info/metadata/xmp_namespaces.dart';
import 'package:collection/collection.dart';
import 'package:flutter/material.dart';
import 'package:tuple/tuple.dart';
typedef XmpExtractedCard = Tuple2<Map<String, XmpProp>, List<XmpCardData>?>;
class XmpCard extends StatefulWidget {
final String title;
late final XmpExtractedCard? directStruct;
late final List<XmpExtractedCard>? indexedStructs;
final String Function(XmpProp prop) formatValue;
final Map<String, InfoValueSpanBuilder> Function(int? index)? spanBuilders;
XmpCard({
super.key,
required this.title,
required Map<int?, XmpExtractedCard> structByIndex,
required this.formatValue,
this.spanBuilders,
}) {
directStruct = structByIndex[null];
final length = structByIndex.keys.whereNotNull().fold(0, max);
indexedStructs = length > 0 ? [for (var i = 0; i < length; i++) structByIndex[i + 1] ?? const Tuple2({}, null)] : null;
}
@override
State<XmpCard> createState() => _XmpCardState();
}
class _XmpCardState extends State<XmpCard> {
final ValueNotifier<int> _indexNotifier = ValueNotifier(0);
List<XmpExtractedCard>? get indexedStructs => widget.indexedStructs;
bool get isIndexed => indexedStructs != null;
int get indexedStructCount => indexedStructs?.length ?? 0;
@override
void initState() {
super.initState();
if (isIndexed) {
_indexNotifier.value = indexedStructCount - 1;
}
}
@override
void didUpdateWidget(covariant XmpCard oldWidget) {
super.didUpdateWidget(oldWidget);
if (_indexNotifier.value >= indexedStructCount) {
_indexNotifier.value = indexedStructCount - 1;
}
}
@override
Widget build(BuildContext context) {
final _isIndexed = isIndexed;
return Container(
decoration: BoxDecoration(
border: Border.all(
color: Theme.of(context).colorScheme.onPrimary.withOpacity(.2),
),
borderRadius: const BorderRadius.all(Radius.circular(4)),
),
child: ValueListenableBuilder<int>(
valueListenable: _indexNotifier,
builder: (context, index, child) {
final data = _isIndexed ? indexedStructs![index] : widget.directStruct!;
final props = data.item1.entries.map((kv) => XmpProp(kv.key, kv.value.value)).toList()..sort();
final cards = data.item2;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.only(left: 8, top: 8, right: 8),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Expanded(
child: HighlightTitle(
title: widget.title,
selectable: true,
showHighlight: false,
),
),
if (_isIndexed) ...[
IconButton(
visualDensity: VisualDensity.compact,
icon: const Icon(AIcons.previous),
onPressed: index > 0 ? () => _setIndex(index - 1) : null,
tooltip: context.l10n.previousTooltip,
),
HighlightTitle(
title: '${index + 1}',
showHighlight: false,
),
IconButton(
visualDensity: VisualDensity.compact,
icon: const Icon(AIcons.next),
onPressed: index < indexedStructCount - 1 ? () => _setIndex(index + 1) : null,
tooltip: context.l10n.nextTooltip,
),
]
],
),
),
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: const EdgeInsets.only(left: 8, right: 8, bottom: 8),
child: InfoRowGroup(
info: Map.fromEntries(props.map((prop) => MapEntry(prop.displayKey, widget.formatValue(prop)))),
maxValueLength: Constants.infoGroupMaxValueLength,
spanBuilders: widget.spanBuilders?.call(_isIndexed ? index + 1 : null),
),
),
),
if (cards != null)
...cards.where((v) => !v.isEmpty).map((card) {
final spanBuilders = card.spanBuilders;
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 8),
child: XmpCard(
title: card.title,
structByIndex: card.data,
formatValue: widget.formatValue,
spanBuilders: spanBuilders != null ? (index) => spanBuilders(index, card.data[index]!.item1) : null,
),
);
}),
],
);
},
),
);
}
void _setIndex(int index) => _indexNotifier.value = index.clamp(0, indexedStructCount - 1);
}

View file

@ -5,17 +5,12 @@ import 'package:aves/utils/string_utils.dart';
import 'package:aves/utils/xmp_utils.dart';
import 'package:aves/widgets/common/identity/highlight_title.dart';
import 'package:aves/widgets/viewer/info/common.dart';
import 'package:aves/widgets/viewer/info/metadata/xmp_card.dart';
import 'package:aves/widgets/viewer/info/metadata/xmp_ns/crs.dart';
import 'package:aves/widgets/viewer/info/metadata/xmp_ns/darktable.dart';
import 'package:aves/widgets/viewer/info/metadata/xmp_ns/dwc.dart';
import 'package:aves/widgets/viewer/info/metadata/xmp_ns/exif.dart';
import 'package:aves/widgets/viewer/info/metadata/xmp_ns/google.dart';
import 'package:aves/widgets/viewer/info/metadata/xmp_ns/iptc.dart';
import 'package:aves/widgets/viewer/info/metadata/xmp_ns/iptc4xmpext.dart';
import 'package:aves/widgets/viewer/info/metadata/xmp_ns/microsoft.dart';
import 'package:aves/widgets/viewer/info/metadata/xmp_ns/mwg.dart';
import 'package:aves/widgets/viewer/info/metadata/xmp_ns/misc.dart';
import 'package:aves/widgets/viewer/info/metadata/xmp_ns/photoshop.dart';
import 'package:aves/widgets/viewer/info/metadata/xmp_ns/plus.dart';
import 'package:aves/widgets/viewer/info/metadata/xmp_ns/tiff.dart';
import 'package:aves/widgets/viewer/info/metadata/xmp_ns/xmp.dart';
import 'package:collection/collection.dart';
@ -23,6 +18,7 @@ import 'package:equatable/equatable.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:tuple/tuple.dart';
@immutable
class XmpNamespace extends Equatable {
@ -70,8 +66,6 @@ class XmpNamespace extends Equatable {
return XmpBasicNamespace(nsPrefix, rawProps);
case Namespaces.xmpMM:
return XmpMMNamespace(nsPrefix, rawProps);
case Namespaces.xmpNote:
return XmpNoteNamespace(nsPrefix, rawProps);
default:
return XmpNamespace(nsUri, nsPrefix, rawProps);
}
@ -79,26 +73,37 @@ class XmpNamespace extends Equatable {
String get displayTitle => Namespaces.nsTitles[nsUri] ?? (nsPrefix.isEmpty ? nsUri : '${nsPrefix.substring(0, nsPrefix.length - 1)} ($nsUri)');
Map<String, String> get buildProps => rawProps;
List<Widget> buildNamespaceSection(BuildContext context) {
final props = buildProps.entries
final props = rawProps.entries
.map((kv) {
final prop = XmpProp(kv.key, kv.value);
return extractData(prop) ? null : prop;
var extracted = false;
cards.forEach((card) => extracted |= card.extract(prop));
return extracted ? null : prop;
})
.whereNotNull()
.toList()
..sort((a, b) => compareAsciiUpperCaseNatural(a.displayKey, b.displayKey));
..sort();
final content = [
if (props.isNotEmpty)
InfoRowGroup(
info: Map.fromEntries(props.map((prop) => MapEntry(prop.displayKey, formatValue(prop)))),
info: Map.fromEntries(props.map((v) => MapEntry(v.displayKey, formatValue(v)))),
maxValueLength: Constants.infoGroupMaxValueLength,
spanBuilders: linkifyValues(props),
),
...buildFromExtractedData(),
...cards.where((v) => !v.isEmpty).map((card) {
final spanBuilders = card.spanBuilders;
return Padding(
padding: const EdgeInsets.only(top: 8),
child: XmpCard(
title: card.title,
structByIndex: card.data,
formatValue: formatValue,
spanBuilders: spanBuilders != null ? (index) => spanBuilders(index, card.data[index]!.item1) : null,
),
);
}),
];
return content.isNotEmpty
@ -117,38 +122,14 @@ class XmpNamespace extends Equatable {
: [];
}
bool extractStruct(XmpProp prop, RegExp pattern, Map<String, String> store) {
final matches = pattern.allMatches(prop.path);
if (matches.isEmpty) return false;
final match = matches.first;
final field = match.group(1)!;
store[field] = formatValue(prop);
return true;
}
bool extractIndexedStruct(XmpProp prop, RegExp pattern, Map<int, Map<String, String>> store) {
final matches = pattern.allMatches(prop.path);
if (matches.isEmpty) return false;
final match = matches.first;
final index = int.parse(match.group(1)!);
final field = match.group(2)!;
final fields = store.putIfAbsent(index, () => <String, String>{});
fields[field] = formatValue(prop);
return true;
}
bool extractData(XmpProp prop) => false;
List<Widget> buildFromExtractedData() => [];
List<XmpCardData> get cards => [];
String formatValue(XmpProp prop) => prop.value;
Map<String, InfoValueSpanBuilder> linkifyValues(List<XmpProp> props) => {};
}
class XmpProp {
class XmpProp implements Comparable<XmpProp> {
final String path, value;
final String displayKey;
@ -165,6 +146,82 @@ class XmpProp {
});
}
@override
int compareTo(XmpProp other) => compareAsciiUpperCaseNatural(displayKey, other.displayKey);
@override
String toString() => '$runtimeType#${shortHash(this)}{path=$path, value=$value}';
}
class XmpCardData {
final String title;
final RegExp pattern;
final bool indexed;
final Map<String, InfoValueSpanBuilder> Function(int?, Map<String, XmpProp> data)? spanBuilders;
final List<XmpCardData>? cards;
final Map<int?, XmpExtractedCard> data = {};
bool get isEmpty => data.isEmpty && (cards?.every((card) => card.isEmpty) ?? true);
static final titlePattern = RegExp(r'(.*?)[\\/]');
XmpCardData(
this.pattern, {
String? title,
this.spanBuilders,
this.cards,
}) : indexed = pattern.pattern.contains(r'\[(\d+)\]'),
title = title ?? XmpProp.formatKey(titlePattern.firstMatch(pattern.pattern)!.group(1)!);
XmpCardData cloneEmpty() {
return XmpCardData(
pattern,
title: title,
spanBuilders: spanBuilders,
cards: cards?.map((v) => v.cloneEmpty()).toList(),
);
}
bool extract(XmpProp prop) => indexed ? _extractIndexedStruct(prop) : _extractDirectStruct(prop);
bool _extractDirectStruct(XmpProp prop) {
final matches = pattern.allMatches(prop.path);
if (matches.isEmpty) return false;
final match = matches.first;
final field = match.group(1)!;
final fields = data.putIfAbsent(null, () => Tuple2(<String, XmpProp>{}, cards?.map((v) => v.cloneEmpty()).toList()));
final _cards = fields.item2;
if (_cards != null) {
final fieldProp = XmpProp(field, prop.value);
if (_cards.any((v) => v.extract(fieldProp))) {
return true;
}
}
fields.item1[field] = prop;
return true;
}
bool _extractIndexedStruct(XmpProp prop) {
final matches = pattern.allMatches(prop.path);
if (matches.isEmpty) return false;
final match = matches.first;
final index = int.parse(match.group(1)!);
final field = match.group(2)!;
final fields = data.putIfAbsent(index, () => Tuple2(<String, XmpProp>{}, cards?.map((v) => v.cloneEmpty()).toList()));
final _cards = fields.item2;
if (_cards != null) {
final fieldProp = XmpProp(field, prop.value);
if (_cards.any((v) => v.extract(fieldProp))) {
return true;
}
}
fields.item1[field] = prop;
return true;
}
}

View file

@ -1,75 +1,50 @@
import 'package:aves/utils/xmp_utils.dart';
import 'package:aves/widgets/viewer/info/metadata/xmp_namespaces.dart';
import 'package:aves/widgets/viewer/info/metadata/xmp_structs.dart';
import 'package:flutter/widgets.dart';
class XmpCrsNamespace extends XmpNamespace {
late final cgbcPattern = RegExp(nsPrefix + r'CircularGradientBasedCorrections\[(\d+)\]/(.*)');
late final gbcPattern = RegExp(nsPrefix + r'GradientBasedCorrections\[(\d+)\]/(.*)');
late final mgbcPattern = RegExp(nsPrefix + r'MaskGroupBasedCorrections\[(\d+)\]/(.*)');
late final pbcPattern = RegExp(nsPrefix + r'PaintBasedCorrections\[(\d+)\]/(.*)');
late final retouchAreasPattern = RegExp(nsPrefix + r'RetouchAreas\[(\d+)\]/(.*)');
late final lookPattern = RegExp(nsPrefix + r'Look/(.*)');
late final rmmiPattern = RegExp(nsPrefix + r'RangeMaskMapInfo/' + nsPrefix + r'RangeMaskMapInfo/(.*)');
final cgbc = <int, Map<String, String>>{};
final gbc = <int, Map<String, String>>{};
final mgbc = <int, Map<String, String>>{};
final pbc = <int, Map<String, String>>{};
final retouchAreas = <int, Map<String, String>>{};
final look = <String, String>{};
final rmmi = <String, String>{};
XmpCrsNamespace(String nsPrefix, Map<String, String> rawProps) : super(Namespaces.crs, nsPrefix, rawProps);
@override
bool extractData(XmpProp prop) {
var hasStructs = extractStruct(prop, lookPattern, look);
hasStructs |= extractStruct(prop, rmmiPattern, rmmi);
var hasIndexedStructs = extractIndexedStruct(prop, cgbcPattern, cgbc);
hasIndexedStructs |= extractIndexedStruct(prop, gbcPattern, gbc);
hasIndexedStructs |= extractIndexedStruct(prop, mgbcPattern, mgbc);
hasIndexedStructs |= extractIndexedStruct(prop, pbcPattern, pbc);
hasIndexedStructs |= extractIndexedStruct(prop, retouchAreasPattern, retouchAreas);
return hasStructs || hasIndexedStructs;
}
@override
List<Widget> buildFromExtractedData() => [
if (cgbc.isNotEmpty)
XmpStructArrayCard(
title: 'Circular Gradient Based Corrections',
structByIndex: cgbc,
late final List<XmpCardData> cards = [
XmpCardData(RegExp(nsPrefix + r'CircularGradientBasedCorrections\[(\d+)\]/(.*)')),
XmpCardData(
RegExp(nsPrefix + r'GradientBasedCorrections\[(\d+)\]/(.*)'),
cards: [
XmpCardData(RegExp(nsPrefix + r'CorrectionMasks\[(\d+)\]/(.*)')),
XmpCardData(RegExp(nsPrefix + r'CorrectionRangeMask/(.*)')),
],
),
if (gbc.isNotEmpty)
XmpStructArrayCard(
title: 'Gradient Based Corrections',
structByIndex: gbc,
XmpCardData(
RegExp(nsPrefix + r'Look/(.*)'),
cards: [
XmpCardData(RegExp(nsPrefix + r'Parameters/(.*)')),
],
),
if (look.isNotEmpty)
XmpStructCard(
title: 'Look',
struct: look,
XmpCardData(
RegExp(nsPrefix + r'MaskGroupBasedCorrections\[(\d+)\]/(.*)'),
cards: [
XmpCardData(
RegExp(nsPrefix + r'CorrectionMasks\[(\d+)\]/(.*)'),
cards: [
XmpCardData(RegExp(nsPrefix + r'CorrectionRangeMask/(.*)')),
XmpCardData(RegExp(nsPrefix + r'Masks\[(\d+)\]/(.*)')),
],
),
if (mgbc.isNotEmpty)
XmpStructArrayCard(
title: 'Mask Group Based Corrections',
structByIndex: mgbc,
],
),
if (pbc.isNotEmpty)
XmpStructArrayCard(
title: 'Paint Based Corrections',
structByIndex: pbc,
XmpCardData(
RegExp(nsPrefix + r'PaintBasedCorrections\[(\d+)\]/(.*)'),
cards: [
XmpCardData(RegExp(nsPrefix + r'CorrectionMasks\[(\d+)\]/(.*)')),
XmpCardData(RegExp(nsPrefix + r'CorrectionRangeMask/(.*)')),
],
),
if (rmmi.isNotEmpty)
XmpStructCard(
title: 'Range Mask Map Info',
struct: rmmi,
),
if (retouchAreas.isNotEmpty)
XmpStructArrayCard(
title: 'Retouch Areas',
structByIndex: retouchAreas,
XmpCardData(
RegExp(nsPrefix + r'RetouchAreas\[(\d+)\]/(.*)'),
cards: [
XmpCardData(RegExp(nsPrefix + r'Masks\[(\d+)\]/(.*)')),
],
),
XmpCardData(RegExp(nsPrefix + r'RangeMaskMapInfo/' + nsPrefix + r'RangeMaskMapInfo/(.*)')),
];
}

View file

@ -1,24 +0,0 @@
import 'package:aves/utils/xmp_utils.dart';
import 'package:aves/widgets/viewer/info/metadata/xmp_namespaces.dart';
import 'package:aves/widgets/viewer/info/metadata/xmp_structs.dart';
import 'package:flutter/material.dart';
class XmpDarktableNamespace extends XmpNamespace {
late final historyPattern = RegExp(nsPrefix + r'history\[(\d+)\]/(.*)');
final history = <int, Map<String, String>>{};
XmpDarktableNamespace(String nsPrefix, Map<String, String> rawProps) : super(Namespaces.darktable, nsPrefix, rawProps);
@override
bool extractData(XmpProp prop) => extractIndexedStruct(prop, historyPattern, history);
@override
List<Widget> buildFromExtractedData() => [
if (history.isNotEmpty)
XmpStructArrayCard(
title: 'History',
structByIndex: history,
),
];
}

View file

@ -1,91 +0,0 @@
import 'package:aves/utils/xmp_utils.dart';
import 'package:aves/widgets/viewer/info/metadata/xmp_namespaces.dart';
import 'package:aves/widgets/viewer/info/metadata/xmp_structs.dart';
import 'package:flutter/widgets.dart';
class XmpDwcNamespace extends XmpNamespace {
late final dcTermsLocationPattern = RegExp(nsPrefix + r'dctermsLocation/(.*)');
late final eventPattern = RegExp(nsPrefix + r'Event/(.*)');
late final geologicalContextPattern = RegExp(nsPrefix + r'GeologicalContext/(.*)');
late final identificationPattern = RegExp(nsPrefix + r'Identification/(.*)');
late final measurementOrFactPattern = RegExp(nsPrefix + r'MeasurementOrFact/(.*)');
late final occurrencePattern = RegExp(nsPrefix + r'Occurrence/(.*)');
late final recordPattern = RegExp(nsPrefix + r'Record/(.*)');
late final resourceRelationshipPattern = RegExp(nsPrefix + r'ResourceRelationship/(.*)');
late final taxonPattern = RegExp(nsPrefix + r'Taxon/(.*)');
final dcTermsLocation = <String, String>{};
final event = <String, String>{};
final identification = <String, String>{};
final geologicalContext = <String, String>{};
final measurementOrFact = <String, String>{};
final occurrence = <String, String>{};
final record = <String, String>{};
final resourceRelationship = <String, String>{};
final taxon = <String, String>{};
XmpDwcNamespace(String nsPrefix, Map<String, String> rawProps) : super(Namespaces.dwc, nsPrefix, rawProps);
@override
bool extractData(XmpProp prop) {
var hasStructs = extractStruct(prop, dcTermsLocationPattern, dcTermsLocation);
hasStructs |= extractStruct(prop, eventPattern, event);
hasStructs |= extractStruct(prop, geologicalContextPattern, geologicalContext);
hasStructs |= extractStruct(prop, measurementOrFactPattern, measurementOrFact);
hasStructs |= extractStruct(prop, identificationPattern, identification);
hasStructs |= extractStruct(prop, occurrencePattern, occurrence);
hasStructs |= extractStruct(prop, recordPattern, record);
hasStructs |= extractStruct(prop, resourceRelationshipPattern, resourceRelationship);
hasStructs |= extractStruct(prop, taxonPattern, taxon);
return hasStructs;
}
@override
List<Widget> buildFromExtractedData() => [
if (dcTermsLocation.isNotEmpty)
XmpStructCard(
title: 'DC Terms Location',
struct: dcTermsLocation,
),
if (event.isNotEmpty)
XmpStructCard(
title: 'Event',
struct: event,
),
if (geologicalContext.isNotEmpty)
XmpStructCard(
title: 'Geological Context',
struct: geologicalContext,
),
if (identification.isNotEmpty)
XmpStructCard(
title: 'Identification',
struct: identification,
),
if (measurementOrFact.isNotEmpty)
XmpStructCard(
title: 'Measurement Or Fact',
struct: measurementOrFact,
),
if (occurrence.isNotEmpty)
XmpStructCard(
title: 'Occurrence',
struct: occurrence,
),
if (record.isNotEmpty)
XmpStructCard(
title: 'Record',
struct: record,
),
if (resourceRelationship.isNotEmpty)
XmpStructCard(
title: 'Resource Relationship',
struct: resourceRelationship,
),
if (taxon.isNotEmpty)
XmpStructCard(
title: 'Taxon',
struct: taxon,
),
];
}

View file

@ -3,9 +3,7 @@ import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:aves/widgets/viewer/embedded/notifications.dart';
import 'package:aves/widgets/viewer/info/common.dart';
import 'package:aves/widgets/viewer/info/metadata/xmp_namespaces.dart';
import 'package:aves/widgets/viewer/info/metadata/xmp_structs.dart';
import 'package:collection/collection.dart';
import 'package:flutter/widgets.dart';
import 'package:tuple/tuple.dart';
abstract class XmpGoogleNamespace extends XmpNamespace {
@ -79,21 +77,10 @@ class XmpGImageNamespace extends XmpGoogleNamespace {
}
class XmpContainer extends XmpNamespace {
late final directoryPattern = RegExp('${nsPrefix}Directory\\[(\\d+)\\]/${nsPrefix}Item/(.*)');
final directories = <int, Map<String, String>>{};
XmpContainer(String nsPrefix, Map<String, String> rawProps) : super(Namespaces.container, nsPrefix, rawProps);
@override
bool extractData(XmpProp prop) => extractIndexedStruct(prop, directoryPattern, directories);
@override
List<Widget> buildFromExtractedData() => [
if (directories.isNotEmpty)
XmpStructArrayCard(
title: 'Directory Item',
structByIndex: directories,
),
late final List<XmpCardData> cards = [
XmpCardData(RegExp('${nsPrefix}Directory\\[(\\d+)\\]/${nsPrefix}Item/(.*)'), title: 'Directory Item'),
];
}

View file

@ -1,24 +0,0 @@
import 'package:aves/utils/xmp_utils.dart';
import 'package:aves/widgets/viewer/info/metadata/xmp_namespaces.dart';
import 'package:aves/widgets/viewer/info/metadata/xmp_structs.dart';
import 'package:flutter/material.dart';
class XmpIptcCoreNamespace extends XmpNamespace {
late final creatorContactInfoPattern = RegExp(nsPrefix + r'CreatorContactInfo/(.*)');
final creatorContactInfo = <String, String>{};
XmpIptcCoreNamespace(String nsPrefix, Map<String, String> rawProps) : super(Namespaces.iptc4xmpCore, nsPrefix, rawProps);
@override
bool extractData(XmpProp prop) => extractStruct(prop, creatorContactInfoPattern, creatorContactInfo);
@override
List<Widget> buildFromExtractedData() => [
if (creatorContactInfo.isNotEmpty)
XmpStructCard(
title: 'Creator Contact Info',
struct: creatorContactInfo,
),
];
}

View file

@ -1,24 +0,0 @@
import 'package:aves/utils/xmp_utils.dart';
import 'package:aves/widgets/viewer/info/metadata/xmp_namespaces.dart';
import 'package:aves/widgets/viewer/info/metadata/xmp_structs.dart';
import 'package:flutter/material.dart';
class XmpIptc4xmpExtNamespace extends XmpNamespace {
late final aooPattern = RegExp(nsPrefix + r'ArtworkOrObject\[(\d+)\]/(.*)');
final aoo = <int, Map<String, String>>{};
XmpIptc4xmpExtNamespace(String nsPrefix, Map<String, String> rawProps) : super(Namespaces.iptc4xmpExt, nsPrefix, rawProps);
@override
bool extractData(XmpProp prop) => extractIndexedStruct(prop, aooPattern, aoo);
@override
List<Widget> buildFromExtractedData() => [
if (aoo.isNotEmpty)
XmpStructArrayCard(
title: 'Artwork or Object',
structByIndex: aoo,
),
];
}

View file

@ -1,24 +0,0 @@
import 'package:aves/utils/xmp_utils.dart';
import 'package:aves/widgets/viewer/info/metadata/xmp_namespaces.dart';
import 'package:aves/widgets/viewer/info/metadata/xmp_structs.dart';
import 'package:flutter/widgets.dart';
class XmpMPNamespace extends XmpNamespace {
late final regionListPattern = RegExp(nsPrefix + r'RegionInfo/MPRI:Regions\[(\d+)\]/(.*)');
final regionList = <int, Map<String, String>>{};
XmpMPNamespace(String nsPrefix, Map<String, String> rawProps) : super(Namespaces.mp, nsPrefix, rawProps);
@override
bool extractData(XmpProp prop) => extractIndexedStruct(prop, regionListPattern, regionList);
@override
List<Widget> buildFromExtractedData() => [
if (regionList.isNotEmpty)
XmpStructArrayCard(
title: 'Regions',
structByIndex: regionList,
),
];
}

View file

@ -0,0 +1,87 @@
import 'package:aves/utils/xmp_utils.dart';
import 'package:aves/widgets/viewer/info/metadata/xmp_namespaces.dart';
class XmpDarktableNamespace extends XmpNamespace {
XmpDarktableNamespace(String nsPrefix, Map<String, String> rawProps) : super(Namespaces.darktable, nsPrefix, rawProps);
@override
late final List<XmpCardData> cards = [
XmpCardData(RegExp(nsPrefix + r'history\[(\d+)\]/(.*)')),
];
}
class XmpDwcNamespace extends XmpNamespace {
XmpDwcNamespace(String nsPrefix, Map<String, String> rawProps) : super(Namespaces.dwc, nsPrefix, rawProps);
@override
late final List<XmpCardData> cards = [
XmpCardData(RegExp(nsPrefix + r'dctermsLocation/(.*)'), title: 'DC Terms Location'),
XmpCardData(RegExp(nsPrefix + r'Event/(.*)')),
XmpCardData(RegExp(nsPrefix + r'GeologicalContext/(.*)')),
XmpCardData(RegExp(nsPrefix + r'Identification/(.*)')),
XmpCardData(RegExp(nsPrefix + r'MeasurementOrFact/(.*)')),
XmpCardData(RegExp(nsPrefix + r'Occurrence/(.*)')),
XmpCardData(RegExp(nsPrefix + r'Record/(.*)')),
XmpCardData(RegExp(nsPrefix + r'ResourceRelationship/(.*)')),
XmpCardData(RegExp(nsPrefix + r'Taxon/(.*)')),
];
}
class XmpIptcCoreNamespace extends XmpNamespace {
XmpIptcCoreNamespace(String nsPrefix, Map<String, String> rawProps) : super(Namespaces.iptc4xmpCore, nsPrefix, rawProps);
@override
late final List<XmpCardData> cards = [
XmpCardData(RegExp(nsPrefix + r'CreatorContactInfo/(.*)')),
];
}
class XmpIptc4xmpExtNamespace extends XmpNamespace {
XmpIptc4xmpExtNamespace(String nsPrefix, Map<String, String> rawProps) : super(Namespaces.iptc4xmpExt, nsPrefix, rawProps);
@override
late final List<XmpCardData> cards = [
XmpCardData(RegExp(nsPrefix + r'ArtworkOrObject\[(\d+)\]/(.*)')),
];
}
class XmpMPNamespace extends XmpNamespace {
XmpMPNamespace(String nsPrefix, Map<String, String> rawProps) : super(Namespaces.mp, nsPrefix, rawProps);
@override
late final List<XmpCardData> cards = [
XmpCardData(RegExp(nsPrefix + r'RegionInfo/MPRI:Regions\[(\d+)\]/(.*)'), title: 'Regions'),
];
}
// cf www.metadataworkinggroup.org/pdf/mwg_guidance.pdf (down, as of 2021/02/15)
class XmpMgwRegionsNamespace extends XmpNamespace {
XmpMgwRegionsNamespace(String nsPrefix, Map<String, String> rawProps) : super(Namespaces.mwgrs, nsPrefix, rawProps);
@override
late final List<XmpCardData> cards = [
XmpCardData(RegExp(nsPrefix + r'Regions/mwg-rs:AppliedToDimensions/(.*)'), title: 'Applied to Dimensions'),
XmpCardData(RegExp(nsPrefix + r'Regions/mwg-rs:RegionList\[(\d+)\]/(.*)'), title: 'Region List'),
];
}
class XmpPlusNamespace extends XmpNamespace {
XmpPlusNamespace(String nsPrefix, Map<String, String> rawProps) : super(Namespaces.plus, nsPrefix, rawProps);
@override
late final List<XmpCardData> cards = [
XmpCardData(RegExp(nsPrefix + r'Licensor\[(\d+)\]/(.*)')),
];
}
class XmpMMNamespace extends XmpNamespace {
XmpMMNamespace(String nsPrefix, Map<String, String> rawProps) : super(Namespaces.xmpMM, nsPrefix, rawProps);
@override
late final List<XmpCardData> cards = [
XmpCardData(RegExp(nsPrefix + r'DerivedFrom/(.*)')),
XmpCardData(RegExp(nsPrefix + r'History\[(\d+)\]/(.*)')),
XmpCardData(RegExp(nsPrefix + r'Ingredients\[(\d+)\]/(.*)')),
XmpCardData(RegExp(nsPrefix + r'Pantry\[(\d+)\]/(.*)')),
];
}

View file

@ -1,36 +0,0 @@
import 'package:aves/utils/xmp_utils.dart';
import 'package:aves/widgets/viewer/info/metadata/xmp_namespaces.dart';
import 'package:aves/widgets/viewer/info/metadata/xmp_structs.dart';
import 'package:flutter/widgets.dart';
// cf www.metadataworkinggroup.org/pdf/mwg_guidance.pdf (down, as of 2021/02/15)
class XmpMgwRegionsNamespace extends XmpNamespace {
late final dimensionsPattern = RegExp(nsPrefix + r'Regions/mwg-rs:AppliedToDimensions/(.*)');
late final regionListPattern = RegExp(nsPrefix + r'Regions/mwg-rs:RegionList\[(\d+)\]/(.*)');
final dimensions = <String, String>{};
final regionList = <int, Map<String, String>>{};
XmpMgwRegionsNamespace(String nsPrefix, Map<String, String> rawProps) : super(Namespaces.mwgrs, nsPrefix, rawProps);
@override
bool extractData(XmpProp prop) {
final hasStructs = extractStruct(prop, dimensionsPattern, dimensions);
final hasIndexedStructs = extractIndexedStruct(prop, regionListPattern, regionList);
return hasStructs || hasIndexedStructs;
}
@override
List<Widget> buildFromExtractedData() => [
if (dimensions.isNotEmpty)
XmpStructCard(
title: 'Applied To Dimensions',
struct: dimensions,
),
if (regionList.isNotEmpty)
XmpStructArrayCard(
title: 'Region',
structByIndex: regionList,
),
];
}

View file

@ -1,37 +1,19 @@
import 'package:aves/utils/xmp_utils.dart';
import 'package:aves/widgets/viewer/info/metadata/xmp_namespaces.dart';
import 'package:aves/widgets/viewer/info/metadata/xmp_structs.dart';
import 'package:flutter/widgets.dart';
// cf https://github.com/adobe/xmp-docs/blob/master/XMPNamespaces/photoshop.md
class XmpPhotoshopNamespace extends XmpNamespace {
late final cameraProfilesPattern = RegExp(nsPrefix + r'CameraProfiles\[(\d+)\]/(.*)');
late final textLayersPattern = RegExp(nsPrefix + r'TextLayers\[(\d+)\]/(.*)');
final cameraProfiles = <int, Map<String, String>>{};
final textLayers = <int, Map<String, String>>{};
XmpPhotoshopNamespace(String nsPrefix, Map<String, String> rawProps) : super(Namespaces.photoshop, nsPrefix, rawProps);
@override
bool extractData(XmpProp prop) {
var hasIndexedStructs = extractIndexedStruct(prop, cameraProfilesPattern, cameraProfiles);
hasIndexedStructs |= extractIndexedStruct(prop, textLayersPattern, textLayers);
return hasIndexedStructs;
}
@override
List<Widget> buildFromExtractedData() => [
if (cameraProfiles.isNotEmpty)
XmpStructArrayCard(
title: 'Camera Profiles',
structByIndex: cameraProfiles,
),
if (textLayers.isNotEmpty)
XmpStructArrayCard(
title: 'Text Layers',
structByIndex: textLayers,
late final List<XmpCardData> cards = [
XmpCardData(
RegExp(nsPrefix + r'CameraProfiles\[(\d+)\]/(.*)'),
cards: [
XmpCardData(RegExp(r'crlcp:PerspectiveModel/(.*)')),
],
),
XmpCardData(RegExp(nsPrefix + r'TextLayers\[(\d+)\]/(.*)')),
];
@override

View file

@ -1,24 +0,0 @@
import 'package:aves/utils/xmp_utils.dart';
import 'package:aves/widgets/viewer/info/metadata/xmp_namespaces.dart';
import 'package:aves/widgets/viewer/info/metadata/xmp_structs.dart';
import 'package:flutter/material.dart';
class XmpPlusNamespace extends XmpNamespace {
late final licensorPattern = RegExp(nsPrefix + r'Licensor\[(\d+)\]/(.*)');
final licensor = <int, Map<String, String>>{};
XmpPlusNamespace(String nsPrefix, Map<String, String> rawProps) : super(Namespaces.plus, nsPrefix, rawProps);
@override
bool extractData(XmpProp prop) => extractIndexedStruct(prop, licensorPattern, licensor);
@override
List<Widget> buildFromExtractedData() => [
if (licensor.isNotEmpty)
XmpStructArrayCard(
title: 'Licensor',
structByIndex: licensor,
),
];
}

View file

@ -4,31 +4,18 @@ import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:aves/widgets/viewer/embedded/notifications.dart';
import 'package:aves/widgets/viewer/info/common.dart';
import 'package:aves/widgets/viewer/info/metadata/xmp_namespaces.dart';
import 'package:aves/widgets/viewer/info/metadata/xmp_structs.dart';
import 'package:flutter/material.dart';
class XmpBasicNamespace extends XmpNamespace {
late final thumbnailsPattern = RegExp(nsPrefix + r'Thumbnails\[(\d+)\]/(.*)');
static const thumbnailDataDisplayKey = 'Image';
final thumbnails = <int, Map<String, String>>{};
XmpBasicNamespace(String nsPrefix, Map<String, String> rawProps) : super(Namespaces.xmp, nsPrefix, rawProps);
@override
bool extractData(XmpProp prop) => extractIndexedStruct(prop, thumbnailsPattern, thumbnails);
@override
List<Widget> buildFromExtractedData() => [
if (thumbnails.isNotEmpty)
XmpStructArrayCard(
title: 'Thumbnail',
structByIndex: thumbnails,
linkifier: (index) {
final struct = thumbnails[index]!;
late final List<XmpCardData> cards = [
XmpCardData(
RegExp(nsPrefix + r'Thumbnails\[(\d+)\]/(.*)'),
spanBuilders: (index, struct) {
return {
if (struct.containsKey(thumbnailDataDisplayKey))
thumbnailDataDisplayKey: InfoRowGroup.linkSpanBuilder(
if (struct.containsKey('xmpGImg:image'))
'Image': InfoRowGroup.linkSpanBuilder(
linkText: (context) => context.l10n.viewerInfoOpenLinkText,
onTap: (context) => OpenEmbeddedDataNotification.xmp(
props: [
@ -41,65 +28,6 @@ class XmpBasicNamespace extends XmpNamespace {
),
};
},
)
];
}
class XmpMMNamespace extends XmpNamespace {
late final derivedFromPattern = RegExp(nsPrefix + r'DerivedFrom/(.*)');
late final historyPattern = RegExp(nsPrefix + r'History\[(\d+)\]/(.*)');
late final ingredientsPattern = RegExp(nsPrefix + r'Ingredients\[(\d+)\]/(.*)');
late final pantryPattern = RegExp(nsPrefix + r'Pantry\[(\d+)\]/(.*)');
final derivedFrom = <String, String>{};
final history = <int, Map<String, String>>{};
final ingredients = <int, Map<String, String>>{};
final pantry = <int, Map<String, String>>{};
XmpMMNamespace(String nsPrefix, Map<String, String> rawProps) : super(Namespaces.xmpMM, nsPrefix, rawProps);
@override
bool extractData(XmpProp prop) {
final hasStructs = extractStruct(prop, derivedFromPattern, derivedFrom);
var hasIndexedStructs = extractIndexedStruct(prop, historyPattern, history);
hasIndexedStructs |= extractIndexedStruct(prop, ingredientsPattern, ingredients);
hasIndexedStructs |= extractIndexedStruct(prop, pantryPattern, pantry);
return hasStructs || hasIndexedStructs;
}
@override
List<Widget> buildFromExtractedData() => [
if (derivedFrom.isNotEmpty)
XmpStructCard(
title: 'Derived From',
struct: derivedFrom,
),
if (history.isNotEmpty)
XmpStructArrayCard(
title: 'History',
structByIndex: history,
),
if (ingredients.isNotEmpty)
XmpStructArrayCard(
title: 'Ingredients',
structByIndex: ingredients,
),
if (pantry.isNotEmpty)
XmpStructArrayCard(
title: 'Pantry',
structByIndex: pantry,
),
];
}
class XmpNoteNamespace extends XmpNamespace {
// `xmpNote:HasExtendedXMP` is structural and should not be displayed to users
late final hasExtendedXmp = '${nsPrefix}HasExtendedXMP';
XmpNoteNamespace(String nsPrefix, Map<String, String> rawProps) : super(Namespaces.xmpNote, nsPrefix, rawProps);
@override
bool extractData(XmpProp prop) {
return prop.path == hasExtendedXmp;
}
}

View file

@ -1,148 +0,0 @@
import 'dart:math';
import 'package:aves/theme/durations.dart';
import 'package:aves/theme/icons.dart';
import 'package:aves/theme/themes.dart';
import 'package:aves/utils/constants.dart';
import 'package:aves/widgets/common/basic/multi_cross_fader.dart';
import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:aves/widgets/common/identity/highlight_title.dart';
import 'package:aves/widgets/viewer/info/common.dart';
import 'package:aves/widgets/viewer/info/metadata/xmp_namespaces.dart';
import 'package:flutter/material.dart';
class XmpStructArrayCard extends StatefulWidget {
final String title;
late final List<Map<String, String>> structs;
final Map<String, InfoValueSpanBuilder> Function(int index)? linkifier;
XmpStructArrayCard({
super.key,
required this.title,
required Map<int, Map<String, String>> structByIndex,
this.linkifier,
}) {
final length = structByIndex.keys.fold(0, max);
structs = [for (var i = 0; i < length; i++) structByIndex[i + 1] ?? {}];
}
@override
State<XmpStructArrayCard> createState() => _XmpStructArrayCardState();
}
class _XmpStructArrayCardState extends State<XmpStructArrayCard> {
late 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(
color: Themes.thirdLayerColor(context),
margin: XmpStructCard.cardMargin,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.only(left: 8, top: 8, right: 8),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Expanded(
child: HighlightTitle(
title: '${widget.title} ${_index + 1}',
selectable: true,
showHighlight: false,
),
),
IconButton(
visualDensity: VisualDensity.compact,
icon: const Icon(AIcons.previous),
onPressed: _index > 0 ? () => setIndex(_index - 1) : null,
tooltip: context.l10n.previousTooltip,
),
IconButton(
visualDensity: VisualDensity.compact,
icon: const Icon(AIcons.next),
onPressed: _index < structs.length - 1 ? () => setIndex(_index + 1) : null,
tooltip: context.l10n.nextTooltip,
),
],
),
),
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: const EdgeInsets.only(left: 8, right: 8, bottom: 8),
child: InfoRowGroup(
info: structs[_index].map((key, value) => MapEntry(XmpProp.formatKey(key), value)),
maxValueLength: Constants.infoGroupMaxValueLength,
spanBuilders: widget.linkifier?.call(_index + 1),
),
),
),
],
),
);
}
}
class XmpStructCard extends StatelessWidget {
final String title;
final Map<String, String> struct;
final Map<String, InfoValueSpanBuilder> Function()? linkifier;
static const cardMargin = EdgeInsets.symmetric(vertical: 8, horizontal: 0);
const XmpStructCard({
super.key,
required this.title,
required this.struct,
this.linkifier,
});
@override
Widget build(BuildContext context) {
return Card(
color: Themes.thirdLayerColor(context),
margin: cardMargin,
child: Padding(
padding: const EdgeInsets.all(8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
HighlightTitle(
title: title,
selectable: true,
showHighlight: false,
),
InfoRowGroup(
info: struct.map((key, value) => MapEntry(XmpProp.formatKey(key), value)),
maxValueLength: Constants.infoGroupMaxValueLength,
spanBuilders: linkifier?.call(),
),
],
),
),
);
}
}