From ea765fbdc97f39f1be5e36a0ebc2ddf56e67ef58 Mon Sep 17 00:00:00 2001 From: Thibault Deckers Date: Sat, 10 Aug 2019 15:17:47 +0900 Subject: [PATCH] refactored metadata loading & listening --- .../aves/channelhandlers/MetadataHandler.java | 2 +- lib/model/catalog_metadata.dart | 36 +++ lib/model/image_entry.dart | 105 ++++--- lib/model/metadata_service.dart | 2 +- lib/model/metadata_storage_service.dart | 2 +- lib/widgets/album/search_delegate.dart | 2 +- lib/widgets/debug_page.dart | 2 +- lib/widgets/fullscreen/image_page.dart | 2 +- lib/widgets/fullscreen/info/info_page.dart | 126 ++++++++ .../fullscreen/info/location_section.dart | 69 +++++ .../fullscreen/info/metadata_section.dart | 69 +++++ lib/widgets/fullscreen/info/xmp_section.dart | 33 +++ lib/widgets/fullscreen/info_page.dart | 273 ------------------ pubspec.lock | 7 + pubspec.yaml | 1 + 15 files changed, 406 insertions(+), 325 deletions(-) create mode 100644 lib/model/catalog_metadata.dart create mode 100644 lib/widgets/fullscreen/info/info_page.dart create mode 100644 lib/widgets/fullscreen/info/location_section.dart create mode 100644 lib/widgets/fullscreen/info/metadata_section.dart create mode 100644 lib/widgets/fullscreen/info/xmp_section.dart delete mode 100644 lib/widgets/fullscreen/info_page.dart diff --git a/android/app/src/main/java/deckers/thibault/aves/channelhandlers/MetadataHandler.java b/android/app/src/main/java/deckers/thibault/aves/channelhandlers/MetadataHandler.java index e3ccc595a..fd457a4fe 100644 --- a/android/app/src/main/java/deckers/thibault/aves/channelhandlers/MetadataHandler.java +++ b/android/app/src/main/java/deckers/thibault/aves/channelhandlers/MetadataHandler.java @@ -174,7 +174,7 @@ public class MetadataHandler implements MethodChannel.MethodCallHandler { int count = xmpMeta.countArrayItems(XMP_DC_SCHEMA_NS, XMP_SUBJECT_PROP_NAME); for (int i = 1; i < count + 1; i++) { XMPProperty item = xmpMeta.getArrayItem(XMP_DC_SCHEMA_NS, XMP_SUBJECT_PROP_NAME, i); - sb.append(" ").append(item.getValue()); + sb.append(";").append(item.getValue()); } metadataMap.put("xmpSubjects", sb.toString()); } diff --git a/lib/model/catalog_metadata.dart b/lib/model/catalog_metadata.dart new file mode 100644 index 000000000..d4258ee7a --- /dev/null +++ b/lib/model/catalog_metadata.dart @@ -0,0 +1,36 @@ +import 'package:geocoder/model.dart'; + +class CatalogMetadata { + final int contentId, dateMillis; + final String xmpSubjects; + final double latitude, longitude; + Address address; + + CatalogMetadata({this.contentId, this.dateMillis, this.xmpSubjects, double latitude, double longitude}) + // Geocoder throws an IllegalArgumentException when a coordinate has a funky values like 1.7056881853375E7 + : this.latitude = latitude == null || latitude < -90.0 || latitude > 90.0 ? null : latitude, + this.longitude = longitude == null || longitude < -180.0 || longitude > 180.0 ? null : longitude; + + factory CatalogMetadata.fromMap(Map map) { + return CatalogMetadata( + contentId: map['contentId'], + dateMillis: map['dateMillis'], + xmpSubjects: map['xmpSubjects'], + latitude: map['latitude'], + longitude: map['longitude'], + ); + } + + Map toMap() => { + 'contentId': contentId, + 'dateMillis': dateMillis, + 'xmpSubjects': xmpSubjects, + 'latitude': latitude, + 'longitude': longitude, + }; + + @override + String toString() { + return 'CatalogMetadata{contentId=$contentId, dateMillis=$dateMillis, latitude=$latitude, longitude=$longitude, xmpSubjects=$xmpSubjects}'; + } +} diff --git a/lib/model/image_entry.dart b/lib/model/image_entry.dart index de48a45f5..2cc1db303 100644 --- a/lib/model/image_entry.dart +++ b/lib/model/image_entry.dart @@ -1,21 +1,27 @@ -import 'package:geocoder/model.dart'; +import 'package:aves/model/catalog_metadata.dart'; +import 'package:aves/model/metadata_service.dart'; +import 'package:flutter/material.dart'; +import 'package:geocoder/geocoder.dart'; +import 'package:tuple/tuple.dart'; import 'mime_types.dart'; -class ImageEntry { - String uri; - String path; - int contentId; - String mimeType; - int width; - int height; - int orientationDegrees; - int sizeBytes; - String title; - int dateModifiedSecs; - int sourceDateTakenMillis; - String bucketDisplayName; - int durationMillis; +class ImageEntry with ChangeNotifier { + final String uri; + final String path; + final int contentId; + final String mimeType; + final int width; + final int height; + final int orientationDegrees; + final int sizeBytes; + final String title; + final int dateModifiedSecs; + final int sourceDateTakenMillis; + final String bucketDisplayName; + final int durationMillis; + CatalogMetadata catalogMetadata; + String addressLine, addressCountry; ImageEntry({ this.uri, @@ -74,18 +80,18 @@ class ImageEntry { return 'ImageEntry{uri=$uri, path=$path}'; } - // TODO TLAD add xmp subjects, address, etc. - String get searchable => title.toLowerCase(); - bool get isGif => mimeType == MimeTypes.MIME_GIF; bool get isVideo => mimeType.startsWith(MimeTypes.MIME_VIDEO); + bool get isCataloged => catalogMetadata != null; + double get aspectRatio => height == 0 ? 1 : width / height; int get megaPixels => (width * height / 1000000).round(); DateTime get bestDate { + if (isCataloged && catalogMetadata.dateMillis > 0) return DateTime.fromMillisecondsSinceEpoch(catalogMetadata.dateMillis); if (sourceDateTakenMillis != null && sourceDateTakenMillis > 0) return DateTime.fromMillisecondsSinceEpoch(sourceDateTakenMillis); if (dateModifiedSecs != null && dateModifiedSecs > 0) return DateTime.fromMillisecondsSinceEpoch(dateModifiedSecs * 1000); return null; @@ -110,39 +116,46 @@ class ImageEntry { String twoDigitMinutes = twoDigits(d.inMinutes.remainder(Duration.minutesPerHour)); return '${d.inHours}:$twoDigitMinutes:$twoDigitSeconds'; } -} -class CatalogMetadata { - final int contentId, dateMillis; - final String xmpSubjects; - final double latitude, longitude; - Address address; + bool get hasGps => isCataloged && catalogMetadata.latitude != null; - CatalogMetadata({this.contentId, this.dateMillis, this.xmpSubjects, double latitude, double longitude}) - // Geocoder throws an IllegalArgumentException when a coordinate has a funky values like 1.7056881853375E7 - : this.latitude = latitude == null || latitude < -90.0 || latitude > 90.0 ? null : latitude, - this.longitude = longitude == null || longitude < -180.0 || longitude > 180.0 ? null : longitude; + bool get isLocated => addressLine != null; - factory CatalogMetadata.fromMap(Map map) { - return CatalogMetadata( - contentId: map['contentId'], - dateMillis: map['dateMillis'], - xmpSubjects: map['xmpSubjects'], - latitude: map['latitude'], - longitude: map['longitude'], - ); + Tuple2 get latLng => isCataloged ? Tuple2(catalogMetadata.latitude, catalogMetadata.longitude) : null; + + List get xmpSubjects => catalogMetadata?.xmpSubjects?.split(';')?.where((tag) => tag.isNotEmpty)?.toList() ?? []; + + catalog() async { + if (isCataloged) return; + catalogMetadata = await MetadataService.getCatalogMetadata(contentId, path); + notifyListeners(); } - Map toMap() => { - 'contentId': contentId, - 'dateMillis': dateMillis, - 'xmpSubjects': xmpSubjects, - 'latitude': latitude, - 'longitude': longitude, - }; + locate() async { + if (isLocated) return; + await catalog(); + final latitude = catalogMetadata?.latitude; + final longitude = catalogMetadata?.longitude; + if (latitude != null && longitude != null) { + final coordinates = Coordinates(latitude, longitude); + try { + final addresses = await Geocoder.local.findAddressesFromCoordinates(coordinates); + if (addresses != null && addresses.length > 0) { + final address = addresses.first; + addressLine = address.addressLine; + addressCountry = address.countryName; + notifyListeners(); + } + } catch (e) { + debugPrint('$runtimeType addAddressToMetadata failed with exception=${e.message}'); + } + } + } - @override - String toString() { - return 'CatalogMetadata{contentId=$contentId, dateMillis=$dateMillis, latitude=$latitude, longitude=$longitude, xmpSubjects=$xmpSubjects}'; + bool search(String query) { + if (title.toLowerCase().contains(query)) return true; + if (catalogMetadata?.xmpSubjects?.toLowerCase()?.contains(query) ?? false) return true; + if (isLocated && addressLine.toLowerCase().contains(query)) return true; + return false; } } diff --git a/lib/model/metadata_service.dart b/lib/model/metadata_service.dart index 1da5a8196..1689cbdda 100644 --- a/lib/model/metadata_service.dart +++ b/lib/model/metadata_service.dart @@ -1,4 +1,4 @@ -import 'package:aves/model/image_entry.dart'; +import 'package:aves/model/catalog_metadata.dart'; import 'package:aves/model/metadata_storage_service.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; diff --git a/lib/model/metadata_storage_service.dart b/lib/model/metadata_storage_service.dart index acfa524e0..9ab0af866 100644 --- a/lib/model/metadata_storage_service.dart +++ b/lib/model/metadata_storage_service.dart @@ -1,4 +1,4 @@ -import 'package:aves/model/image_entry.dart'; +import 'package:aves/model/catalog_metadata.dart'; import 'package:flutter/foundation.dart'; import 'package:path/path.dart'; import 'package:sqflite/sqflite.dart'; diff --git a/lib/widgets/album/search_delegate.dart b/lib/widgets/album/search_delegate.dart index b52af5aca..813896d05 100644 --- a/lib/widgets/album/search_delegate.dart +++ b/lib/widgets/album/search_delegate.dart @@ -51,7 +51,7 @@ class ImageSearchDelegate extends SearchDelegate { return SizedBox.shrink(); } final lowerQuery = query.toLowerCase(); - final matches = entries.where((entry) => entry.searchable.contains(lowerQuery)).toList(); + final matches = entries.where((entry) => entry.search(lowerQuery)).toList(); if (matches.isEmpty) { return Center( child: Text( diff --git a/lib/widgets/debug_page.dart b/lib/widgets/debug_page.dart index 752ebefc4..7f19a39f8 100644 --- a/lib/widgets/debug_page.dart +++ b/lib/widgets/debug_page.dart @@ -1,4 +1,4 @@ -import 'package:aves/model/image_entry.dart'; +import 'package:aves/model/catalog_metadata.dart'; import 'package:aves/model/metadata_storage_service.dart'; import 'package:flutter/material.dart'; diff --git a/lib/widgets/fullscreen/image_page.dart b/lib/widgets/fullscreen/image_page.dart index 012b4c304..53d189b73 100644 --- a/lib/widgets/fullscreen/image_page.dart +++ b/lib/widgets/fullscreen/image_page.dart @@ -2,7 +2,7 @@ import 'dart:io'; import 'dart:math'; import 'package:aves/model/image_entry.dart'; -import 'package:aves/widgets/fullscreen/info_page.dart'; +import 'package:aves/widgets/fullscreen/info/info_page.dart'; import 'package:aves/widgets/fullscreen/overlay_bottom.dart'; import 'package:aves/widgets/fullscreen/overlay_top.dart'; import 'package:aves/widgets/fullscreen/video.dart'; diff --git a/lib/widgets/fullscreen/info/info_page.dart b/lib/widgets/fullscreen/info/info_page.dart new file mode 100644 index 000000000..b84ff2f25 --- /dev/null +++ b/lib/widgets/fullscreen/info/info_page.dart @@ -0,0 +1,126 @@ +import 'package:aves/model/image_entry.dart'; +import 'package:aves/utils/file_utils.dart'; +import 'package:aves/widgets/fullscreen/info/location_section.dart'; +import 'package:aves/widgets/fullscreen/info/metadata_section.dart'; +import 'package:aves/widgets/fullscreen/info/xmp_section.dart'; +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; + +class InfoPage extends StatefulWidget { + final ImageEntry entry; + + const InfoPage({this.entry}); + + @override + State createState() => InfoPageState(); +} + +class InfoPageState extends State { + bool _scrollStartFromTop = false; + + ImageEntry get entry => widget.entry; + + @override + void initState() { + super.initState(); + entry.locate(); + } + + @override + Widget build(BuildContext context) { + final date = entry.bestDate; + final dateText = '${DateFormat.yMMMd().format(date)} – ${DateFormat.Hm().format(date)}'; + final resolutionText = '${entry.width} × ${entry.height}${entry.isVideo ? '' : ' (${entry.megaPixels} MP)'}'; + return Scaffold( + appBar: AppBar( + leading: IconButton( + icon: Icon(Icons.arrow_upward), + onPressed: () => BackUpNotification().dispatch(context), + tooltip: 'Back to image', + ), + title: Text('Info'), + ), + body: NotificationListener( + onNotification: handleTopScroll, + child: ListView( + padding: EdgeInsets.all(8.0), + children: [ + InfoRow('Title', entry.title), + InfoRow('Date', dateText), + if (entry.isVideo) InfoRow('Duration', entry.durationText), + InfoRow('Resolution', resolutionText), + InfoRow('Size', formatFilesize(entry.sizeBytes)), + InfoRow('Path', entry.path), + LocationSection(entry: entry), + XmpTagSection(entry: entry), + MetadataSection(entry: entry), + ], + ), + ), + ); + } + + bool handleTopScroll(Notification notification) { + if (notification is ScrollNotification) { + if (notification is ScrollStartNotification) { + final metrics = notification.metrics; + _scrollStartFromTop = metrics.pixels == metrics.minScrollExtent; + } + if (_scrollStartFromTop) { + if (notification is ScrollEndNotification) { + _scrollStartFromTop = false; + } else if (notification is OverscrollNotification) { + if (notification.overscroll < 0) { + BackUpNotification().dispatch(context); + _scrollStartFromTop = false; + } + } + } + } + return false; + } +} + +class SectionRow extends StatelessWidget { + final String title; + + const SectionRow(this.title); + + @override + Widget build(BuildContext context) { + return Row( + children: [ + Expanded(child: Divider(color: Colors.white70)), + Padding( + padding: EdgeInsets.all(16.0), + child: Text(title, style: TextStyle(fontSize: 20)), + ), + Expanded(child: Divider(color: Colors.white70)), + ], + ); + } +} + +class InfoRow extends StatelessWidget { + final String label, value; + + const InfoRow(this.label, this.value); + + @override + Widget build(BuildContext context) { + return Padding( + padding: EdgeInsets.symmetric(vertical: 4.0), + child: RichText( + text: TextSpan( + style: DefaultTextStyle.of(context).style, + children: [ + TextSpan(text: '$label ', style: TextStyle(color: Colors.white70)), + TextSpan(text: value), + ], + ), + ), + ); + } +} + +class BackUpNotification extends Notification {} diff --git a/lib/widgets/fullscreen/info/location_section.dart b/lib/widgets/fullscreen/info/location_section.dart new file mode 100644 index 000000000..f6e9d1005 --- /dev/null +++ b/lib/widgets/fullscreen/info/location_section.dart @@ -0,0 +1,69 @@ +import 'package:aves/model/image_entry.dart'; +import 'package:aves/widgets/fullscreen/info/info_page.dart'; +import 'package:flutter/material.dart'; +import 'package:google_maps_flutter/google_maps_flutter.dart'; + +class LocationSection extends AnimatedWidget { + final ImageEntry entry; + + const LocationSection({Key key, this.entry}) : super(key: key, listenable: entry); + + @override + Widget build(BuildContext context) { + return !entry.hasGps + ? SizedBox.shrink() + : Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SectionRow('Location'), + ImageMap(markerId: entry.path, latLng: LatLng(entry.latLng.item1, entry.latLng.item2)), + if (entry.isLocated) + Padding( + padding: EdgeInsets.only(top: 8), + child: InfoRow('Address', entry.addressLine), + ), + ], + ); + } +} + +class ImageMap extends StatefulWidget { + final String markerId; + final LatLng latLng; + + const ImageMap({Key key, this.markerId, this.latLng}) : super(key: key); + + @override + State createState() => ImageMapState(); +} + +class ImageMapState extends State with AutomaticKeepAliveClientMixin { + @override + Widget build(BuildContext context) { + super.build(context); + return SizedBox( + height: 200, + child: ClipRRect( + borderRadius: BorderRadius.all( + Radius.circular(16), + ), + child: GoogleMap( + initialCameraPosition: CameraPosition( + target: widget.latLng, + zoom: 12, + ), + markers: [ + Marker( + markerId: MarkerId(widget.markerId), + icon: BitmapDescriptor.defaultMarker, + position: widget.latLng, + ) + ].toSet(), + ), + ), + ); + } + + @override + bool get wantKeepAlive => true; +} diff --git a/lib/widgets/fullscreen/info/metadata_section.dart b/lib/widgets/fullscreen/info/metadata_section.dart new file mode 100644 index 000000000..609d8de30 --- /dev/null +++ b/lib/widgets/fullscreen/info/metadata_section.dart @@ -0,0 +1,69 @@ +import 'dart:async'; + +import 'package:aves/model/image_entry.dart'; +import 'package:aves/model/metadata_service.dart'; +import 'package:aves/widgets/fullscreen/info/info_page.dart'; +import 'package:flutter/material.dart'; + +class MetadataSection extends StatefulWidget { + final ImageEntry entry; + + const MetadataSection({this.entry}); + + @override + State createState() => MetadataSectionState(); +} + +class MetadataSectionState extends State { + Future _metadataLoader; + + @override + void initState() { + super.initState(); + initMetadataLoader(); + } + + @override + void didUpdateWidget(MetadataSection oldWidget) { + super.didUpdateWidget(oldWidget); + initMetadataLoader(); + } + + initMetadataLoader() async { + _metadataLoader = MetadataService.getAllMetadata(widget.entry.path); + } + + @override + Widget build(BuildContext context) { + return FutureBuilder( + future: _metadataLoader, + builder: (futureContext, AsyncSnapshot snapshot) { + if (snapshot.hasError) return Text(snapshot.error); + if (snapshot.connectionState != ConnectionState.done) return SizedBox.shrink(); + final metadataMap = snapshot.data.cast(); + final directoryNames = metadataMap.keys.toList()..sort(); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SectionRow('Metadata'), + ...directoryNames.expand( + (directoryName) { + final directory = metadataMap[directoryName]; + final tagKeys = directory.keys.toList()..sort(); + return [ + if (directoryName.isNotEmpty) + Padding( + padding: EdgeInsets.symmetric(vertical: 4.0), + child: Text(directoryName, style: TextStyle(fontSize: 18)), + ), + ...tagKeys.map((tagKey) => InfoRow(tagKey, directory[tagKey])), + SizedBox(height: 16), + ]; + }, + ) + ], + ); + }, + ); + } +} diff --git a/lib/widgets/fullscreen/info/xmp_section.dart b/lib/widgets/fullscreen/info/xmp_section.dart new file mode 100644 index 000000000..5c10d6961 --- /dev/null +++ b/lib/widgets/fullscreen/info/xmp_section.dart @@ -0,0 +1,33 @@ +import 'package:aves/model/image_entry.dart'; +import 'package:aves/widgets/fullscreen/info/info_page.dart'; +import 'package:flutter/material.dart'; + +class XmpTagSection extends AnimatedWidget { + final ImageEntry entry; + + const XmpTagSection({Key key, this.entry}) : super(key: key, listenable: entry); + + @override + Widget build(BuildContext context) { + final tags = entry.xmpSubjects; + return tags.isEmpty + ? SizedBox.shrink() + : Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SectionRow('XMP Tags'), + Wrap( + children: tags + .map((tag) => Padding( + padding: EdgeInsets.symmetric(horizontal: 4.0), + child: Chip( + backgroundColor: Colors.indigo, + label: Text(tag), + ), + )) + .toList(), + ), + ], + ); + } +} diff --git a/lib/widgets/fullscreen/info_page.dart b/lib/widgets/fullscreen/info_page.dart deleted file mode 100644 index 037da8fe0..000000000 --- a/lib/widgets/fullscreen/info_page.dart +++ /dev/null @@ -1,273 +0,0 @@ -import 'dart:async'; - -import 'package:aves/model/image_entry.dart'; -import 'package:aves/model/metadata_service.dart'; -import 'package:aves/utils/file_utils.dart'; -import 'package:flutter/material.dart'; -import 'package:geocoder/geocoder.dart'; -import 'package:google_maps_flutter/google_maps_flutter.dart'; -import 'package:intl/intl.dart'; - -class InfoPage extends StatefulWidget { - final ImageEntry entry; - - const InfoPage({this.entry}); - - @override - State createState() => InfoPageState(); -} - -class InfoPageState extends State { - Future _metadataLoader; - Future _catalogLoader; - bool _scrollStartFromTop = false; - - ImageEntry get entry => widget.entry; - - @override - void initState() { - super.initState(); - initMetadataLoader(); - } - - @override - void didUpdateWidget(InfoPage oldWidget) { - super.didUpdateWidget(oldWidget); - initMetadataLoader(); - } - - initMetadataLoader() { - _catalogLoader = MetadataService.getCatalogMetadata(entry.contentId, entry.path).then(addAddressToMetadata); - _metadataLoader = MetadataService.getAllMetadata(entry.path); - } - - Future addAddressToMetadata(metadata) async { - if (metadata == null) return null; - final latitude = metadata.latitude; - final longitude = metadata.longitude; - if (latitude != null && longitude != null) { - final coordinates = Coordinates(latitude, longitude); - try { - final addresses = await Geocoder.local.findAddressesFromCoordinates(coordinates); - if (addresses != null && addresses.length > 0) { - metadata.address = addresses.first; - } - } catch (e) { - debugPrint('$runtimeType addAddressToMetadata failed with exception=${e.message}'); - } - } - return metadata; - } - - @override - Widget build(BuildContext context) { - final date = entry.bestDate; - final dateText = '${DateFormat.yMMMd().format(date)} – ${DateFormat.Hm().format(date)}'; - final resolutionText = '${entry.width} × ${entry.height}${entry.isVideo ? '' : ' (${entry.megaPixels} MP)'}'; - return Scaffold( - appBar: AppBar( - leading: IconButton( - icon: Icon(Icons.arrow_upward), - onPressed: () => BackUpNotification().dispatch(context), - tooltip: 'Back to image', - ), - title: Text('Info'), - ), - body: NotificationListener( - onNotification: (notification) { - if (notification is ScrollNotification) { - if (notification is ScrollStartNotification) { - final metrics = notification.metrics; - _scrollStartFromTop = metrics.pixels == metrics.minScrollExtent; - } - if (_scrollStartFromTop) { - if (notification is ScrollEndNotification) { - _scrollStartFromTop = false; - } else if (notification is OverscrollNotification) { - if (notification.overscroll < 0) { - BackUpNotification().dispatch(context); - _scrollStartFromTop = false; - } - } - } - } - return false; - }, - child: ListView( - padding: EdgeInsets.all(8.0), - children: [ - InfoRow('Title', entry.title), - InfoRow('Date', dateText), - if (entry.isVideo) InfoRow('Duration', entry.durationText), - InfoRow('Resolution', resolutionText), - InfoRow('Size', formatFilesize(entry.sizeBytes)), - InfoRow('Path', entry.path), - FutureBuilder( - future: _catalogLoader, - builder: (futureContext, AsyncSnapshot snapshot) { - if (snapshot.hasError) return Text(snapshot.error); - if (snapshot.connectionState != ConnectionState.done) return SizedBox.shrink(); - final metadata = snapshot.data; - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - ..._buildLocationSection(metadata?.latitude, metadata?.longitude, metadata?.address), - ..._buildTagSection(metadata?.xmpSubjects), - ], - ); - }, - ), - FutureBuilder( - future: _metadataLoader, - builder: (futureContext, AsyncSnapshot snapshot) { - if (snapshot.hasError) return Text(snapshot.error); - if (snapshot.connectionState != ConnectionState.done) return SizedBox.shrink(); - final metadataMap = snapshot.data.cast(); - final directoryNames = metadataMap.keys.toList()..sort(); - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - SectionRow('Metadata'), - ...directoryNames.expand( - (directoryName) { - final directory = metadataMap[directoryName]; - final tagKeys = directory.keys.toList()..sort(); - return [ - if (directoryName.isNotEmpty) - Padding( - padding: EdgeInsets.symmetric(vertical: 4.0), - child: Text(directoryName, style: TextStyle(fontSize: 18)), - ), - ...tagKeys.map((tagKey) => InfoRow(tagKey, directory[tagKey])), - SizedBox(height: 16), - ]; - }, - ) - ], - ); - }, - ), - ], - ), - ), - ); - } - - List _buildLocationSection(double latitude, double longitude, Address address) { - if (latitude == null || longitude == null) return []; - return [ - SectionRow('Location'), - ImageMap(markerId: entry.path, latLng: LatLng(latitude, longitude)), - if (address != null) - Padding( - padding: EdgeInsets.only(top: 8), - child: InfoRow('Address', address.addressLine), - ), - ]; - } - - List _buildTagSection(String xmpSubjects) { - if (xmpSubjects == null) return []; - return [ - SectionRow('XMP Tags'), - Wrap( - children: xmpSubjects - .split(' ') - .where((word) => word.isNotEmpty) - .map((word) => Padding( - padding: EdgeInsets.symmetric(horizontal: 4.0), - child: Chip( - backgroundColor: Colors.indigo, - label: Text(word), - ), - )) - .toList(), - ), - ]; - } -} - -class ImageMap extends StatefulWidget { - final String markerId; - final LatLng latLng; - - const ImageMap({Key key, this.markerId, this.latLng}) : super(key: key); - - @override - State createState() => ImageMapState(); -} - -class ImageMapState extends State with AutomaticKeepAliveClientMixin { - @override - Widget build(BuildContext context) { - super.build(context); - return SizedBox( - height: 200, - child: ClipRRect( - borderRadius: BorderRadius.all( - Radius.circular(16), - ), - child: GoogleMap( - initialCameraPosition: CameraPosition( - target: widget.latLng, - zoom: 12, - ), - markers: [ - Marker( - markerId: MarkerId(widget.markerId), - icon: BitmapDescriptor.defaultMarker, - position: widget.latLng, - ) - ].toSet(), - ), - ), - ); - } - - @override - bool get wantKeepAlive => true; -} - -class SectionRow extends StatelessWidget { - final String title; - - const SectionRow(this.title); - - @override - Widget build(BuildContext context) { - return Row( - children: [ - Expanded(child: Divider(color: Colors.white70)), - Padding( - padding: EdgeInsets.all(16.0), - child: Text(title, style: TextStyle(fontSize: 20)), - ), - Expanded(child: Divider(color: Colors.white70)), - ], - ); - } -} - -class InfoRow extends StatelessWidget { - final String label, value; - - const InfoRow(this.label, this.value); - - @override - Widget build(BuildContext context) { - return Padding( - padding: EdgeInsets.symmetric(vertical: 4.0), - child: RichText( - text: TextSpan( - style: DefaultTextStyle.of(context).style, - children: [ - TextSpan(text: '$label ', style: TextStyle(color: Colors.white70)), - TextSpan(text: value), - ], - ), - ), - ); - } -} - -class BackUpNotification extends Notification {} diff --git a/pubspec.lock b/pubspec.lock index b2557c4b7..42f48dddb 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -205,6 +205,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "1.0.0" + tuple: + dependency: "direct main" + description: + name: tuple + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.2" typed_data: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 0537ff0dd..692aac262 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -30,6 +30,7 @@ dependencies: screen: sqflite: transparent_image: + tuple: dev_dependencies: flutter_test: