refactored metadata loading & listening
This commit is contained in:
parent
ac8b6176c3
commit
ea765fbdc9
15 changed files with 406 additions and 325 deletions
|
@ -174,7 +174,7 @@ public class MetadataHandler implements MethodChannel.MethodCallHandler {
|
||||||
int count = xmpMeta.countArrayItems(XMP_DC_SCHEMA_NS, XMP_SUBJECT_PROP_NAME);
|
int count = xmpMeta.countArrayItems(XMP_DC_SCHEMA_NS, XMP_SUBJECT_PROP_NAME);
|
||||||
for (int i = 1; i < count + 1; i++) {
|
for (int i = 1; i < count + 1; i++) {
|
||||||
XMPProperty item = xmpMeta.getArrayItem(XMP_DC_SCHEMA_NS, XMP_SUBJECT_PROP_NAME, 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());
|
metadataMap.put("xmpSubjects", sb.toString());
|
||||||
}
|
}
|
||||||
|
|
36
lib/model/catalog_metadata.dart
Normal file
36
lib/model/catalog_metadata.dart
Normal file
|
@ -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<String, dynamic> 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}';
|
||||||
|
}
|
||||||
|
}
|
|
@ -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';
|
import 'mime_types.dart';
|
||||||
|
|
||||||
class ImageEntry {
|
class ImageEntry with ChangeNotifier {
|
||||||
String uri;
|
final String uri;
|
||||||
String path;
|
final String path;
|
||||||
int contentId;
|
final int contentId;
|
||||||
String mimeType;
|
final String mimeType;
|
||||||
int width;
|
final int width;
|
||||||
int height;
|
final int height;
|
||||||
int orientationDegrees;
|
final int orientationDegrees;
|
||||||
int sizeBytes;
|
final int sizeBytes;
|
||||||
String title;
|
final String title;
|
||||||
int dateModifiedSecs;
|
final int dateModifiedSecs;
|
||||||
int sourceDateTakenMillis;
|
final int sourceDateTakenMillis;
|
||||||
String bucketDisplayName;
|
final String bucketDisplayName;
|
||||||
int durationMillis;
|
final int durationMillis;
|
||||||
|
CatalogMetadata catalogMetadata;
|
||||||
|
String addressLine, addressCountry;
|
||||||
|
|
||||||
ImageEntry({
|
ImageEntry({
|
||||||
this.uri,
|
this.uri,
|
||||||
|
@ -74,18 +80,18 @@ class ImageEntry {
|
||||||
return 'ImageEntry{uri=$uri, path=$path}';
|
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 isGif => mimeType == MimeTypes.MIME_GIF;
|
||||||
|
|
||||||
bool get isVideo => mimeType.startsWith(MimeTypes.MIME_VIDEO);
|
bool get isVideo => mimeType.startsWith(MimeTypes.MIME_VIDEO);
|
||||||
|
|
||||||
|
bool get isCataloged => catalogMetadata != null;
|
||||||
|
|
||||||
double get aspectRatio => height == 0 ? 1 : width / height;
|
double get aspectRatio => height == 0 ? 1 : width / height;
|
||||||
|
|
||||||
int get megaPixels => (width * height / 1000000).round();
|
int get megaPixels => (width * height / 1000000).round();
|
||||||
|
|
||||||
DateTime get bestDate {
|
DateTime get bestDate {
|
||||||
|
if (isCataloged && catalogMetadata.dateMillis > 0) return DateTime.fromMillisecondsSinceEpoch(catalogMetadata.dateMillis);
|
||||||
if (sourceDateTakenMillis != null && sourceDateTakenMillis > 0) return DateTime.fromMillisecondsSinceEpoch(sourceDateTakenMillis);
|
if (sourceDateTakenMillis != null && sourceDateTakenMillis > 0) return DateTime.fromMillisecondsSinceEpoch(sourceDateTakenMillis);
|
||||||
if (dateModifiedSecs != null && dateModifiedSecs > 0) return DateTime.fromMillisecondsSinceEpoch(dateModifiedSecs * 1000);
|
if (dateModifiedSecs != null && dateModifiedSecs > 0) return DateTime.fromMillisecondsSinceEpoch(dateModifiedSecs * 1000);
|
||||||
return null;
|
return null;
|
||||||
|
@ -110,39 +116,46 @@ class ImageEntry {
|
||||||
String twoDigitMinutes = twoDigits(d.inMinutes.remainder(Duration.minutesPerHour));
|
String twoDigitMinutes = twoDigits(d.inMinutes.remainder(Duration.minutesPerHour));
|
||||||
return '${d.inHours}:$twoDigitMinutes:$twoDigitSeconds';
|
return '${d.inHours}:$twoDigitMinutes:$twoDigitSeconds';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool get hasGps => isCataloged && catalogMetadata.latitude != null;
|
||||||
|
|
||||||
|
bool get isLocated => addressLine != null;
|
||||||
|
|
||||||
|
Tuple2<double, double> get latLng => isCataloged ? Tuple2(catalogMetadata.latitude, catalogMetadata.longitude) : null;
|
||||||
|
|
||||||
|
List<String> get xmpSubjects => catalogMetadata?.xmpSubjects?.split(';')?.where((tag) => tag.isNotEmpty)?.toList() ?? [];
|
||||||
|
|
||||||
|
catalog() async {
|
||||||
|
if (isCataloged) return;
|
||||||
|
catalogMetadata = await MetadataService.getCatalogMetadata(contentId, path);
|
||||||
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
class CatalogMetadata {
|
locate() async {
|
||||||
final int contentId, dateMillis;
|
if (isLocated) return;
|
||||||
final String xmpSubjects;
|
await catalog();
|
||||||
final double latitude, longitude;
|
final latitude = catalogMetadata?.latitude;
|
||||||
Address address;
|
final longitude = catalogMetadata?.longitude;
|
||||||
|
if (latitude != null && longitude != null) {
|
||||||
CatalogMetadata({this.contentId, this.dateMillis, this.xmpSubjects, double latitude, double longitude})
|
final coordinates = Coordinates(latitude, longitude);
|
||||||
// Geocoder throws an IllegalArgumentException when a coordinate has a funky values like 1.7056881853375E7
|
try {
|
||||||
: this.latitude = latitude == null || latitude < -90.0 || latitude > 90.0 ? null : latitude,
|
final addresses = await Geocoder.local.findAddressesFromCoordinates(coordinates);
|
||||||
this.longitude = longitude == null || longitude < -180.0 || longitude > 180.0 ? null : longitude;
|
if (addresses != null && addresses.length > 0) {
|
||||||
|
final address = addresses.first;
|
||||||
factory CatalogMetadata.fromMap(Map map) {
|
addressLine = address.addressLine;
|
||||||
return CatalogMetadata(
|
addressCountry = address.countryName;
|
||||||
contentId: map['contentId'],
|
notifyListeners();
|
||||||
dateMillis: map['dateMillis'],
|
|
||||||
xmpSubjects: map['xmpSubjects'],
|
|
||||||
latitude: map['latitude'],
|
|
||||||
longitude: map['longitude'],
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
} catch (e) {
|
||||||
Map<String, dynamic> toMap() => {
|
debugPrint('$runtimeType addAddressToMetadata failed with exception=${e.message}');
|
||||||
'contentId': contentId,
|
}
|
||||||
'dateMillis': dateMillis,
|
}
|
||||||
'xmpSubjects': xmpSubjects,
|
}
|
||||||
'latitude': latitude,
|
|
||||||
'longitude': longitude,
|
bool search(String query) {
|
||||||
};
|
if (title.toLowerCase().contains(query)) return true;
|
||||||
|
if (catalogMetadata?.xmpSubjects?.toLowerCase()?.contains(query) ?? false) return true;
|
||||||
@override
|
if (isLocated && addressLine.toLowerCase().contains(query)) return true;
|
||||||
String toString() {
|
return false;
|
||||||
return 'CatalogMetadata{contentId=$contentId, dateMillis=$dateMillis, latitude=$latitude, longitude=$longitude, xmpSubjects=$xmpSubjects}';
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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:aves/model/metadata_storage_service.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.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:flutter/foundation.dart';
|
||||||
import 'package:path/path.dart';
|
import 'package:path/path.dart';
|
||||||
import 'package:sqflite/sqflite.dart';
|
import 'package:sqflite/sqflite.dart';
|
||||||
|
|
|
@ -51,7 +51,7 @@ class ImageSearchDelegate extends SearchDelegate<ImageEntry> {
|
||||||
return SizedBox.shrink();
|
return SizedBox.shrink();
|
||||||
}
|
}
|
||||||
final lowerQuery = query.toLowerCase();
|
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) {
|
if (matches.isEmpty) {
|
||||||
return Center(
|
return Center(
|
||||||
child: Text(
|
child: Text(
|
||||||
|
|
|
@ -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:aves/model/metadata_storage_service.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
|
|
@ -2,7 +2,7 @@ import 'dart:io';
|
||||||
import 'dart:math';
|
import 'dart:math';
|
||||||
|
|
||||||
import 'package:aves/model/image_entry.dart';
|
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_bottom.dart';
|
||||||
import 'package:aves/widgets/fullscreen/overlay_top.dart';
|
import 'package:aves/widgets/fullscreen/overlay_top.dart';
|
||||||
import 'package:aves/widgets/fullscreen/video.dart';
|
import 'package:aves/widgets/fullscreen/video.dart';
|
||||||
|
|
126
lib/widgets/fullscreen/info/info_page.dart
Normal file
126
lib/widgets/fullscreen/info/info_page.dart
Normal file
|
@ -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<StatefulWidget> createState() => InfoPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class InfoPageState extends State<InfoPage> {
|
||||||
|
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 {}
|
69
lib/widgets/fullscreen/info/location_section.dart
Normal file
69
lib/widgets/fullscreen/info/location_section.dart
Normal file
|
@ -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<StatefulWidget> createState() => ImageMapState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class ImageMapState extends State<ImageMap> 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;
|
||||||
|
}
|
69
lib/widgets/fullscreen/info/metadata_section.dart
Normal file
69
lib/widgets/fullscreen/info/metadata_section.dart
Normal file
|
@ -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<StatefulWidget> createState() => MetadataSectionState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class MetadataSectionState extends State<MetadataSection> {
|
||||||
|
Future<Map> _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<Map> snapshot) {
|
||||||
|
if (snapshot.hasError) return Text(snapshot.error);
|
||||||
|
if (snapshot.connectionState != ConnectionState.done) return SizedBox.shrink();
|
||||||
|
final metadataMap = snapshot.data.cast<String, Map>();
|
||||||
|
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),
|
||||||
|
];
|
||||||
|
},
|
||||||
|
)
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
33
lib/widgets/fullscreen/info/xmp_section.dart
Normal file
33
lib/widgets/fullscreen/info/xmp_section.dart
Normal file
|
@ -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(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -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<StatefulWidget> createState() => InfoPageState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class InfoPageState extends State<InfoPage> {
|
|
||||||
Future<Map> _metadataLoader;
|
|
||||||
Future<CatalogMetadata> _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<CatalogMetadata> 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<CatalogMetadata> 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<Map> snapshot) {
|
|
||||||
if (snapshot.hasError) return Text(snapshot.error);
|
|
||||||
if (snapshot.connectionState != ConnectionState.done) return SizedBox.shrink();
|
|
||||||
final metadataMap = snapshot.data.cast<String, Map>();
|
|
||||||
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<Widget> _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<Widget> _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<StatefulWidget> createState() => ImageMapState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class ImageMapState extends State<ImageMap> 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 {}
|
|
|
@ -205,6 +205,13 @@ packages:
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.0.0"
|
version: "1.0.0"
|
||||||
|
tuple:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: tuple
|
||||||
|
url: "https://pub.dartlang.org"
|
||||||
|
source: hosted
|
||||||
|
version: "1.0.2"
|
||||||
typed_data:
|
typed_data:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
|
@ -30,6 +30,7 @@ dependencies:
|
||||||
screen:
|
screen:
|
||||||
sqflite:
|
sqflite:
|
||||||
transparent_image:
|
transparent_image:
|
||||||
|
tuple:
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
|
|
Loading…
Reference in a new issue