info: show XMP history and some other structs via cards
This commit is contained in:
parent
ab6124e093
commit
f899f563e8
17 changed files with 619 additions and 167 deletions
|
@ -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"
|
||||
|
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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];
|
||||
|
|
|
@ -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 {};
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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);
|
||||
|
||||
|
|
57
lib/widgets/common/basic/multi_cross_fader.dart
Normal file
57
lib/widgets/common/basic/multi_cross_fader.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
|
@ -30,7 +30,6 @@ class AvesExpansionTile extends StatelessWidget {
|
|||
title: HighlightTitle(
|
||||
title,
|
||||
color: color,
|
||||
fontSize: 18,
|
||||
enabled: enabled,
|
||||
),
|
||||
expandable: enabled,
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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(),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
);
|
||||
},
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
218
lib/widgets/fullscreen/info/metadata/xmp_namespaces.dart
Normal file
218
lib/widgets/fullscreen/info/metadata/xmp_namespaces.dart
Normal 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;
|
||||
}
|
||||
}
|
135
lib/widgets/fullscreen/info/metadata/xmp_structs.dart
Normal file
135
lib/widgets/fullscreen/info/metadata/xmp_structs.dart
Normal 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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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}';
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue