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 com.drew.metadata.file.FileTypeDirectory
|
||||||
import deckers.thibault.aves.metadata.ExifInterfaceHelper
|
import deckers.thibault.aves.metadata.ExifInterfaceHelper
|
||||||
import deckers.thibault.aves.metadata.MediaMetadataRetrieverHelper
|
import deckers.thibault.aves.metadata.MediaMetadataRetrieverHelper
|
||||||
|
import deckers.thibault.aves.model.provider.FieldMap
|
||||||
import deckers.thibault.aves.utils.LogUtils
|
import deckers.thibault.aves.utils.LogUtils
|
||||||
import deckers.thibault.aves.utils.MimeTypes.isImage
|
import deckers.thibault.aves.utils.MimeTypes.isImage
|
||||||
import deckers.thibault.aves.utils.MimeTypes.isSupportedByExifInterface
|
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 io.flutter.plugin.common.MethodChannel.MethodCallHandler
|
||||||
import kotlinx.coroutines.GlobalScope
|
import kotlinx.coroutines.GlobalScope
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import org.beyka.tiffbitmapfactory.TiffBitmapFactory
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
import java.util.*
|
import java.util.*
|
||||||
|
|
||||||
|
@ -38,6 +40,7 @@ class DebugHandler(private val context: Context) : MethodCallHandler {
|
||||||
"getExifInterfaceMetadata" -> GlobalScope.launch { getExifInterfaceMetadata(call, Coresult(result)) }
|
"getExifInterfaceMetadata" -> GlobalScope.launch { getExifInterfaceMetadata(call, Coresult(result)) }
|
||||||
"getMediaMetadataRetrieverMetadata" -> GlobalScope.launch { getMediaMetadataRetrieverMetadata(call, Coresult(result)) }
|
"getMediaMetadataRetrieverMetadata" -> GlobalScope.launch { getMediaMetadataRetrieverMetadata(call, Coresult(result)) }
|
||||||
"getMetadataExtractorSummary" -> GlobalScope.launch { getMetadataExtractorSummary(call, Coresult(result)) }
|
"getMetadataExtractorSummary" -> GlobalScope.launch { getMetadataExtractorSummary(call, Coresult(result)) }
|
||||||
|
"getTiffStructure" -> GlobalScope.launch { getTiffStructure(call, Coresult(result)) }
|
||||||
else -> result.notImplemented()
|
else -> result.notImplemented()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -226,6 +229,70 @@ class DebugHandler(private val context: Context) : MethodCallHandler {
|
||||||
result.success(metadataMap)
|
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 {
|
companion object {
|
||||||
private val LOG_TAG = LogUtils.createTag(DebugHandler::class.java)
|
private val LOG_TAG = LogUtils.createTag(DebugHandler::class.java)
|
||||||
const val CHANNEL = "deckers.thibault/aves/debug"
|
const val CHANNEL = "deckers.thibault/aves/debug"
|
||||||
|
|
|
@ -138,6 +138,8 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
|
||||||
} catch (e: XMPException) {
|
} catch (e: XMPException) {
|
||||||
Log.w(LOG_TAG, "failed to read XMP directory for uri=$uri", e)
|
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 `"..."`
|
// allow untrimmed queries wrapped with `"..."`
|
||||||
final matches = exactRegex.allMatches(upQuery);
|
final matches = exactRegex.allMatches(upQuery);
|
||||||
if (matches.length == 1) {
|
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);
|
_filter = not ? (entry) => !entry.search(upQuery) : (entry) => entry.search(upQuery);
|
||||||
|
|
|
@ -1,43 +1,43 @@
|
||||||
class MimeTypes {
|
class MimeTypes {
|
||||||
static const String anyImage = 'image/*';
|
static const anyImage = 'image/*';
|
||||||
|
|
||||||
static const String gif = 'image/gif';
|
static const gif = 'image/gif';
|
||||||
static const String heic = 'image/heic';
|
static const heic = 'image/heic';
|
||||||
static const String heif = 'image/heif';
|
static const heif = 'image/heif';
|
||||||
static const String jpeg = 'image/jpeg';
|
static const jpeg = 'image/jpeg';
|
||||||
static const String png = 'image/png';
|
static const png = 'image/png';
|
||||||
static const String svg = 'image/svg+xml';
|
static const svg = 'image/svg+xml';
|
||||||
static const String webp = 'image/webp';
|
static const webp = 'image/webp';
|
||||||
|
|
||||||
static const String tiff = 'image/tiff';
|
static const tiff = 'image/tiff';
|
||||||
static const String psd = 'image/vnd.adobe.photoshop';
|
static const psd = 'image/vnd.adobe.photoshop';
|
||||||
|
|
||||||
static const String arw = 'image/x-sony-arw';
|
static const arw = 'image/x-sony-arw';
|
||||||
static const String cr2 = 'image/x-canon-cr2';
|
static const cr2 = 'image/x-canon-cr2';
|
||||||
static const String crw = 'image/x-canon-crw';
|
static const crw = 'image/x-canon-crw';
|
||||||
static const String dcr = 'image/x-kodak-dcr';
|
static const dcr = 'image/x-kodak-dcr';
|
||||||
static const String dng = 'image/x-adobe-dng';
|
static const dng = 'image/x-adobe-dng';
|
||||||
static const String erf = 'image/x-epson-erf';
|
static const erf = 'image/x-epson-erf';
|
||||||
static const String k25 = 'image/x-kodak-k25';
|
static const k25 = 'image/x-kodak-k25';
|
||||||
static const String kdc = 'image/x-kodak-kdc';
|
static const kdc = 'image/x-kodak-kdc';
|
||||||
static const String mrw = 'image/x-minolta-mrw';
|
static const mrw = 'image/x-minolta-mrw';
|
||||||
static const String nef = 'image/x-nikon-nef';
|
static const nef = 'image/x-nikon-nef';
|
||||||
static const String nrw = 'image/x-nikon-nrw';
|
static const nrw = 'image/x-nikon-nrw';
|
||||||
static const String orf = 'image/x-olympus-orf';
|
static const orf = 'image/x-olympus-orf';
|
||||||
static const String pef = 'image/x-pentax-pef';
|
static const pef = 'image/x-pentax-pef';
|
||||||
static const String raf = 'image/x-fuji-raf';
|
static const raf = 'image/x-fuji-raf';
|
||||||
static const String raw = 'image/x-panasonic-raw';
|
static const raw = 'image/x-panasonic-raw';
|
||||||
static const String rw2 = 'image/x-panasonic-rw2';
|
static const rw2 = 'image/x-panasonic-rw2';
|
||||||
static const String sr2 = 'image/x-sony-sr2';
|
static const sr2 = 'image/x-sony-sr2';
|
||||||
static const String srf = 'image/x-sony-srf';
|
static const srf = 'image/x-sony-srf';
|
||||||
static const String srw = 'image/x-samsung-srw';
|
static const srw = 'image/x-samsung-srw';
|
||||||
static const String x3f = 'image/x-sigma-x3f';
|
static const x3f = 'image/x-sigma-x3f';
|
||||||
|
|
||||||
static const String anyVideo = 'video/*';
|
static const anyVideo = 'video/*';
|
||||||
|
|
||||||
static const String avi = 'video/avi';
|
static const avi = 'video/avi';
|
||||||
static const String mp2t = 'video/mp2t'; // .m2ts
|
static const mp2t = 'video/mp2t'; // .m2ts
|
||||||
static const String mp4 = 'video/mp4';
|
static const mp4 = 'video/mp4';
|
||||||
|
|
||||||
// groups
|
// 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];
|
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/model/image_entry.dart';
|
||||||
|
import 'package:aves/ref/mime_types.dart';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
|
|
||||||
|
@ -94,4 +95,18 @@ class AndroidDebugService {
|
||||||
}
|
}
|
||||||
return {};
|
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
|
// info
|
||||||
static const mapStyleSwitchAnimation = Duration(milliseconds: 300);
|
static const mapStyleSwitchAnimation = Duration(milliseconds: 300);
|
||||||
|
static const xmpStructArrayCardTransition = Duration(milliseconds: 300);
|
||||||
|
|
||||||
// delays & refresh intervals
|
// delays & refresh intervals
|
||||||
static const opToastDisplay = Duration(seconds: 2);
|
static const opToastDisplay = Duration(seconds: 2);
|
||||||
|
|
|
@ -26,11 +26,9 @@ class AIcons {
|
||||||
// actions
|
// actions
|
||||||
static const IconData addShortcut = Icons.add_to_home_screen_outlined;
|
static const IconData addShortcut = Icons.add_to_home_screen_outlined;
|
||||||
static const IconData clear = Icons.clear_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 createAlbum = Icons.add_circle_outline;
|
||||||
static const IconData debug = Icons.whatshot_outlined;
|
static const IconData debug = Icons.whatshot_outlined;
|
||||||
static const IconData delete = Icons.delete_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 flip = Icons.flip_outlined;
|
||||||
static const IconData favourite = Icons.favorite_border;
|
static const IconData favourite = Icons.favorite_border;
|
||||||
static const IconData favouriteActive = Icons.favorite;
|
static const IconData favouriteActive = Icons.favorite;
|
||||||
|
@ -52,6 +50,10 @@ class AIcons {
|
||||||
static const IconData stats = Icons.pie_chart_outlined;
|
static const IconData stats = Icons.pie_chart_outlined;
|
||||||
static const IconData zoomIn = Icons.add_outlined;
|
static const IconData zoomIn = Icons.add_outlined;
|
||||||
static const IconData zoomOut = Icons.remove_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
|
// albums
|
||||||
static const IconData album = Icons.photo_album_outlined;
|
static const IconData album = Icons.photo_album_outlined;
|
||||||
|
|
|
@ -18,8 +18,8 @@ class Constants {
|
||||||
offset: Offset(0.5, 1.0),
|
offset: Offset(0.5, 1.0),
|
||||||
);
|
);
|
||||||
|
|
||||||
static const String overlayUnknown = '—'; // em dash
|
static const overlayUnknown = '—'; // em dash
|
||||||
static const String infoUnknown = 'unknown';
|
static const infoUnknown = 'unknown';
|
||||||
|
|
||||||
static final pointNemo = LatLng(-48.876667, -123.393333);
|
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: HighlightTitle(
|
||||||
title,
|
title,
|
||||||
color: color,
|
color: color,
|
||||||
fontSize: 18,
|
|
||||||
enabled: enabled,
|
enabled: enabled,
|
||||||
),
|
),
|
||||||
expandable: enabled,
|
expandable: enabled,
|
||||||
|
|
|
@ -11,7 +11,7 @@ class HighlightTitle extends StatelessWidget {
|
||||||
const HighlightTitle(
|
const HighlightTitle(
|
||||||
this.title, {
|
this.title, {
|
||||||
this.color,
|
this.color,
|
||||||
this.fontSize = 20,
|
this.fontSize = 18,
|
||||||
this.enabled = true,
|
this.enabled = true,
|
||||||
this.selectable = false,
|
this.selectable = false,
|
||||||
}) : assert(title != null);
|
}) : assert(title != null);
|
||||||
|
|
|
@ -2,6 +2,7 @@ import 'dart:collection';
|
||||||
import 'dart:typed_data';
|
import 'dart:typed_data';
|
||||||
|
|
||||||
import 'package:aves/model/image_entry.dart';
|
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/services/android_debug_service.dart';
|
||||||
import 'package:aves/utils/constants.dart';
|
import 'package:aves/utils/constants.dart';
|
||||||
import 'package:aves/widgets/common/identity/aves_expansion_tile.dart';
|
import 'package:aves/widgets/common/identity/aves_expansion_tile.dart';
|
||||||
|
@ -18,7 +19,7 @@ class MetadataTab extends StatefulWidget {
|
||||||
}
|
}
|
||||||
|
|
||||||
class _MetadataTabState extends State<MetadataTab> {
|
class _MetadataTabState extends State<MetadataTab> {
|
||||||
Future<Map> _bitmapFactoryLoader, _contentResolverMetadataLoader, _exifInterfaceMetadataLoader, _mediaMetadataLoader, _metadataExtractorLoader;
|
Future<Map> _bitmapFactoryLoader, _contentResolverMetadataLoader, _exifInterfaceMetadataLoader, _mediaMetadataLoader, _metadataExtractorLoader, _tiffStructureLoader;
|
||||||
|
|
||||||
// MediaStore timestamp keys
|
// MediaStore timestamp keys
|
||||||
static const secondTimestampKeys = ['date_added', 'date_modified', 'date_expires', 'isPlayed'];
|
static const secondTimestampKeys = ['date_added', 'date_modified', 'date_expires', 'isPlayed'];
|
||||||
|
@ -38,15 +39,14 @@ class _MetadataTabState extends State<MetadataTab> {
|
||||||
_exifInterfaceMetadataLoader = AndroidDebugService.getExifInterfaceMetadata(entry);
|
_exifInterfaceMetadataLoader = AndroidDebugService.getExifInterfaceMetadata(entry);
|
||||||
_mediaMetadataLoader = AndroidDebugService.getMediaMetadataRetrieverMetadata(entry);
|
_mediaMetadataLoader = AndroidDebugService.getMediaMetadataRetrieverMetadata(entry);
|
||||||
_metadataExtractorLoader = AndroidDebugService.getMetadataExtractorSummary(entry);
|
_metadataExtractorLoader = AndroidDebugService.getMetadataExtractorSummary(entry);
|
||||||
|
_tiffStructureLoader = AndroidDebugService.getTiffStructure(entry);
|
||||||
setState(() {});
|
setState(() {});
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
Widget builder(BuildContext context, AsyncSnapshot<Map> snapshot, String title) {
|
Widget builderFromSnapshotData(BuildContext context, Map snapshotData, String title) {
|
||||||
if (snapshot.hasError) return Text(snapshot.error.toString());
|
final data = SplayTreeMap.of(snapshotData.map((k, v) {
|
||||||
if (snapshot.connectionState != ConnectionState.done) return SizedBox.shrink();
|
|
||||||
final data = SplayTreeMap.of(snapshot.data.map((k, v) {
|
|
||||||
final key = k.toString();
|
final key = k.toString();
|
||||||
var value = v?.toString() ?? 'null';
|
var value = v?.toString() ?? 'null';
|
||||||
if ([...secondTimestampKeys, ...millisecondTimestampKeys].contains(key) && v is num && v != 0) {
|
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(
|
return ListView(
|
||||||
padding: EdgeInsets.all(8),
|
padding: EdgeInsets.all(8),
|
||||||
children: [
|
children: [
|
||||||
FutureBuilder<Map>(
|
FutureBuilder<Map>(
|
||||||
future: _bitmapFactoryLoader,
|
future: _bitmapFactoryLoader,
|
||||||
builder: (context, snapshot) => builder(context, snapshot, 'Bitmap Factory'),
|
builder: (context, snapshot) => builderFromSnapshot(context, snapshot, 'Bitmap Factory'),
|
||||||
),
|
),
|
||||||
FutureBuilder<Map>(
|
FutureBuilder<Map>(
|
||||||
future: _contentResolverMetadataLoader,
|
future: _contentResolverMetadataLoader,
|
||||||
builder: (context, snapshot) => builder(context, snapshot, 'Content Resolver'),
|
builder: (context, snapshot) => builderFromSnapshot(context, snapshot, 'Content Resolver'),
|
||||||
),
|
),
|
||||||
FutureBuilder<Map>(
|
FutureBuilder<Map>(
|
||||||
future: _exifInterfaceMetadataLoader,
|
future: _exifInterfaceMetadataLoader,
|
||||||
builder: (context, snapshot) => builder(context, snapshot, 'Exif Interface'),
|
builder: (context, snapshot) => builderFromSnapshot(context, snapshot, 'Exif Interface'),
|
||||||
),
|
),
|
||||||
FutureBuilder<Map>(
|
FutureBuilder<Map>(
|
||||||
future: _mediaMetadataLoader,
|
future: _mediaMetadataLoader,
|
||||||
builder: (context, snapshot) => builder(context, snapshot, 'Media Metadata Retriever'),
|
builder: (context, snapshot) => builderFromSnapshot(context, snapshot, 'Media Metadata Retriever'),
|
||||||
),
|
),
|
||||||
FutureBuilder<Map>(
|
FutureBuilder<Map>(
|
||||||
future: _metadataExtractorLoader,
|
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`
|
// warning: placing the `AnimationLimiter` as a parent to the `ScrollView`
|
||||||
// triggers dispose & reinitialization of other sections, including heavy widgets like maps
|
// triggers dispose & reinitialization of other sections, including heavy widgets like maps
|
||||||
return SliverToBoxAdapter(
|
return SliverToBoxAdapter(
|
||||||
child: AnimatedBuilder(
|
child: NotificationListener<ScrollNotification>(
|
||||||
animation: _loadedMetadataUri,
|
// cancel notification bubbling so that the info page
|
||||||
builder: (context, child) {
|
// does not misinterpret content scrolling for page scrolling
|
||||||
Widget content;
|
onNotification: (notification) => true,
|
||||||
if (_metadata.isEmpty) {
|
child: AnimatedBuilder(
|
||||||
content = SizedBox.shrink();
|
animation: _loadedMetadataUri,
|
||||||
} else {
|
builder: (context, child) {
|
||||||
content = Column(
|
Widget content;
|
||||||
children: AnimationConfiguration.toStaggeredList(
|
if (_metadata.isEmpty) {
|
||||||
duration: Durations.staggeredAnimation,
|
content = SizedBox.shrink();
|
||||||
delay: Durations.staggeredAnimationDelay,
|
} else {
|
||||||
childAnimationBuilder: (child) => SlideAnimation(
|
content = Column(
|
||||||
verticalOffset: 50.0,
|
children: AnimationConfiguration.toStaggeredList(
|
||||||
child: FadeInAnimation(
|
duration: Durations.staggeredAnimation,
|
||||||
child: child,
|
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 'dart:collection';
|
||||||
|
|
||||||
import 'package:aves/model/image_entry.dart';
|
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/mime_types.dart';
|
||||||
import 'package:aves/ref/xmp.dart';
|
import 'package:aves/ref/xmp.dart';
|
||||||
import 'package:aves/services/android_app_service.dart';
|
import 'package:aves/services/android_app_service.dart';
|
||||||
import 'package:aves/services/metadata_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/action_mixins/feedback.dart';
|
||||||
import 'package:aves/widgets/common/behaviour/routes.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/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/dialogs/aves_dialog.dart';
|
||||||
import 'package:aves/widgets/fullscreen/fullscreen_page.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/metadata_thumbnail.dart';
|
||||||
|
import 'package:aves/widgets/fullscreen/info/metadata/xmp_namespaces.dart';
|
||||||
import 'package:collection/collection.dart';
|
import 'package:collection/collection.dart';
|
||||||
import 'package:flutter/foundation.dart';
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:pedantic/pedantic.dart';
|
import 'package:pedantic/pedantic.dart';
|
||||||
|
|
||||||
|
@ -41,13 +37,23 @@ class _XmpDirTileState extends State<XmpDirTile> with FeedbackMixin {
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final thumbnail = MetadataThumbnails(source: MetadataThumbnailSource.xmp, entry: entry);
|
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) {
|
groupBy(widget.tags.entries, (kv) {
|
||||||
final fullKey = kv.key;
|
final fullKey = kv.key;
|
||||||
final i = fullKey.indexOf(XMP.propNamespaceSeparator);
|
final i = fullKey.indexOf(XMP.propNamespaceSeparator);
|
||||||
if (i == -1) return _XmpNamespace('');
|
final namespace = i == -1 ? '' : fullKey.substring(0, i);
|
||||||
final namespace = fullKey.substring(0, i);
|
switch (namespace) {
|
||||||
return _XmpNamespace(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),
|
(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),
|
padding: EdgeInsets.only(left: 8, right: 8, bottom: 8),
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: sections.entries.expand((namespaceProps) {
|
children: sections.entries
|
||||||
final namespace = namespaceProps.key;
|
.expand((kv) => kv.key.buildNamespaceSection(
|
||||||
final displayNamespace = namespace.displayTitle;
|
props: kv.value,
|
||||||
final linkHandlers = <String, InfoLinkHandler>{};
|
openEmbeddedData: _openEmbeddedData,
|
||||||
|
))
|
||||||
final entries = namespaceProps.value.map((prop) {
|
.toList(),
|
||||||
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(),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
@ -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/theme/durations.dart';
|
||||||
import 'package:aves/utils/constants.dart';
|
import 'package:aves/utils/constants.dart';
|
||||||
import 'package:aves/widgets/common/identity/aves_expansion_tile.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/common/providers/media_query_data_provider.dart';
|
||||||
import 'package:aves/widgets/dialogs/aves_selection_dialog.dart';
|
import 'package:aves/widgets/dialogs/aves_selection_dialog.dart';
|
||||||
import 'package:aves/widgets/settings/access_grants.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