From ff34e77cb3688f96566fdb1ee405269e1f22110a Mon Sep 17 00:00:00 2001 From: Thibault Deckers Date: Sat, 7 Sep 2019 16:13:43 +0900 Subject: [PATCH] entry: split change notifier, address fallback --- lib/model/image_collection.dart | 4 +- lib/model/image_entry.dart | 23 +++++++---- lib/utils/android_file_utils.dart | 10 ++--- lib/utils/change_notifier.dart | 27 +++++++++++++ lib/utils/geo_utils.dart | 38 +++++++++++++++++++ lib/widgets/album/thumbnail.dart | 6 ++- lib/widgets/common/icons.dart | 2 +- .../fullscreen/info/location_section.dart | 4 +- lib/widgets/fullscreen/info/xmp_section.dart | 4 +- lib/widgets/fullscreen/overlay_bottom.dart | 11 +++++- lib/widgets/fullscreen/overlay_top.dart | 13 +++++-- 11 files changed, 115 insertions(+), 27 deletions(-) create mode 100644 lib/utils/change_notifier.dart create mode 100644 lib/utils/geo_utils.dart diff --git a/lib/model/image_collection.dart b/lib/model/image_collection.dart index 355c90eb8..c9d04f464 100644 --- a/lib/model/image_collection.dart +++ b/lib/model/image_collection.dart @@ -120,7 +120,7 @@ class ImageCollection with ChangeNotifier { catalogEntries() async { final start = DateTime.now(); - final uncataloguedEntries = _rawEntries.where((entry) => !entry.isCatalogued); + final uncataloguedEntries = _rawEntries.where((entry) => !entry.isCatalogued).toList(); final newMetadata = List(); await Future.forEach(uncataloguedEntries, (entry) async { await entry.catalog(); @@ -133,7 +133,7 @@ class ImageCollection with ChangeNotifier { locateEntries() async { final start = DateTime.now(); - final unlocatedEntries = _rawEntries.where((entry) => entry.hasGps && !entry.isLocated); + final unlocatedEntries = _rawEntries.where((entry) => entry.hasGps && !entry.isLocated).toList(); final newAddresses = List(); await Future.forEach(unlocatedEntries, (entry) async { await entry.locate(); diff --git a/lib/model/image_entry.dart b/lib/model/image_entry.dart index 829ecfbcd..bbafcaa13 100644 --- a/lib/model/image_entry.dart +++ b/lib/model/image_entry.dart @@ -3,6 +3,7 @@ import 'dart:collection'; import 'package:aves/model/image_file_service.dart'; import 'package:aves/model/image_metadata.dart'; import 'package:aves/model/metadata_service.dart'; +import 'package:aves/utils/change_notifier.dart'; import 'package:flutter/foundation.dart'; import 'package:geocoder/geocoder.dart'; import 'package:path/path.dart'; @@ -10,7 +11,7 @@ import 'package:tuple/tuple.dart'; import 'mime_types.dart'; -class ImageEntry with ChangeNotifier { +class ImageEntry { String uri; String path; String directory; @@ -28,6 +29,8 @@ class ImageEntry with ChangeNotifier { CatalogMetadata catalogMetadata; AddressDetails addressDetails; + AChangeNotifier imageChangeNotifier = new AChangeNotifier(), metadataChangeNotifier = new AChangeNotifier(), addressChangeNotifier = new AChangeNotifier(); + ImageEntry({ this.uri, this.path, @@ -80,6 +83,12 @@ class ImageEntry with ChangeNotifier { }; } + dispose() { + imageChangeNotifier.dispose(); + metadataChangeNotifier.dispose(); + addressChangeNotifier.dispose(); + } + @override String toString() { return 'ImageEntry{uri=$uri, path=$path}'; @@ -143,7 +152,7 @@ class ImageEntry with ChangeNotifier { catalog() async { if (isCatalogued) return; catalogMetadata = await MetadataService.getCatalogMetadata(this); - notifyListeners(); + metadataChangeNotifier.notifyListeners(); } locate() async { @@ -166,10 +175,10 @@ class ImageEntry with ChangeNotifier { adminArea: address.adminArea, locality: address.locality, ); - notifyListeners(); + addressChangeNotifier.notifyListeners(); } - } catch (e) { - debugPrint('$runtimeType addAddressToMetadata failed with exception=${e.message}'); + } catch (exception) { + debugPrint('$runtimeType addAddressToMetadata failed with exception=$exception'); } } @@ -204,7 +213,7 @@ class ImageEntry with ChangeNotifier { if (contentId != null) this.contentId = contentId; final title = newFields['title']; if (title != null) this.title = title; - notifyListeners(); + metadataChangeNotifier.notifyListeners(); return true; } @@ -220,7 +229,7 @@ class ImageEntry with ChangeNotifier { if (height != null) this.height = height; final orientationDegrees = newFields['orientationDegrees']; if (orientationDegrees != null) this.orientationDegrees = orientationDegrees; - notifyListeners(); + imageChangeNotifier.notifyListeners(); return true; } } diff --git a/lib/utils/android_file_utils.dart b/lib/utils/android_file_utils.dart index b93051c17..27855d72d 100644 --- a/lib/utils/android_file_utils.dart +++ b/lib/utils/android_file_utils.dart @@ -5,16 +5,16 @@ final AndroidFileUtils androidFileUtils = AndroidFileUtils._private(); typedef void AndroidFileUtilsCallback(String key, dynamic oldValue, dynamic newValue); class AndroidFileUtils { - String dcimPath, downloadPath, picturesPath; + String externalStorage, dcimPath, downloadPath, picturesPath; AndroidFileUtils._private(); init() async { // path_provider getExternalStorageDirectory() gives '/storage/emulated/0/Android/data/deckers.thibault.aves/files' - final ext = '/storage/emulated/0'; - dcimPath = join(ext, 'DCIM'); - downloadPath = join(ext, 'Download'); - picturesPath = join(ext, 'Pictures'); + externalStorage = '/storage/emulated/0'; + dcimPath = join(externalStorage, 'DCIM'); + downloadPath = join(externalStorage, 'Download'); + picturesPath = join(externalStorage, 'Pictures'); } bool isCameraPath(String path) => path != null && path.startsWith(dcimPath) && (path.endsWith('Camera') || path.endsWith('100ANDRO')); diff --git a/lib/utils/change_notifier.dart b/lib/utils/change_notifier.dart new file mode 100644 index 000000000..47fd036f7 --- /dev/null +++ b/lib/utils/change_notifier.dart @@ -0,0 +1,27 @@ +import 'package:flutter/foundation.dart'; + +// reimplemented ChangeNotifier so that it can be used anywhere, not just as a mixin +class AChangeNotifier implements Listenable { + ObserverList _listeners = ObserverList(); + + @override + void addListener(VoidCallback listener) => _listeners.add(listener); + + @override + void removeListener(VoidCallback listener) => _listeners.remove(listener); + + void dispose() => _listeners = null; + + @protected + void notifyListeners() { + if (_listeners == null) return; + final localListeners = List.from(_listeners); + for (final listener in localListeners) { + try { + if (_listeners.contains(listener)) listener(); + } catch (exception, stack) { + debugPrint('$runtimeType failed to notify listeners with exception=$exception\n$stack'); + } + } + } +} diff --git a/lib/utils/geo_utils.dart b/lib/utils/geo_utils.dart new file mode 100644 index 000000000..bc56793fe --- /dev/null +++ b/lib/utils/geo_utils.dart @@ -0,0 +1,38 @@ +import 'dart:math' as math; + +import 'package:intl/intl.dart'; +import 'package:tuple/tuple.dart'; + +// adapted from Mike Mitterer's dart-latlong library +String _decimal2sexagesimal(final double dec) { + double _round(final double value, {final int decimals: 6}) => (value * math.pow(10, decimals)).round() / math.pow(10, decimals); + + List _split(final double value) { + // NumberFormat is necessary to create digit after comma if the value + // has no decimal point (only necessary for browser) + final List tmp = new NumberFormat('0.0#####').format(_round(value, decimals: 10)).split('.'); + return [int.parse(tmp[0]).abs(), int.parse(tmp[1])]; + } + + final List parts = _split(dec); + final int integerPart = parts[0]; + final int fractionalPart = parts[1]; + + final int deg = integerPart; + final double min = double.parse('0.$fractionalPart') * 60; + + final List minParts = _split(min); + final int minFractionalPart = minParts[1]; + + final double sec = (double.parse('0.$minFractionalPart') * 60); + + return '$deg° ${min.floor()}′ ${_round(sec, decimals: 2).toStringAsFixed(2)}″'; +} + +// return coordinates formatted as DMS, e.g. ['41°24′12.2″ N', '2°10′26.5″E'] +List toDMS(Tuple2 latLng) { + if (latLng == null) return []; + final lat = latLng.item1; + final lng = latLng.item2; + return ['${_decimal2sexagesimal(lat)} ${lat < 0 ? 'S' : 'N'}', '${_decimal2sexagesimal(lng)} ${lng < 0 ? 'W' : 'E'}']; +} diff --git a/lib/widgets/album/thumbnail.dart b/lib/widgets/album/thumbnail.dart index 62174e8e4..ae40b4289 100644 --- a/lib/widgets/album/thumbnail.dart +++ b/lib/widgets/album/thumbnail.dart @@ -25,6 +25,7 @@ class Thumbnail extends StatefulWidget { class ThumbnailState extends State { Future _byteLoader; + Listenable _entryChangeNotifier; ImageEntry get entry => widget.entry; @@ -33,7 +34,8 @@ class ThumbnailState extends State { @override void initState() { super.initState(); - entry.addListener(onEntryChange); + _entryChangeNotifier = Listenable.merge([entry.imageChangeNotifier, entry.metadataChangeNotifier]); + _entryChangeNotifier.addListener(onEntryChange); initByteLoader(); } @@ -53,7 +55,7 @@ class ThumbnailState extends State { @override void dispose() { - entry.removeListener(onEntryChange); + _entryChangeNotifier.removeListener(onEntryChange); super.dispose(); } diff --git a/lib/widgets/common/icons.dart b/lib/widgets/common/icons.dart index 8351c8c89..54d402a65 100644 --- a/lib/widgets/common/icons.dart +++ b/lib/widgets/common/icons.dart @@ -99,7 +99,7 @@ class IconUtils { if (androidFileUtils.isDownloadPath(albumDirectory)) return Icon(Icons.file_download); final parts = albumDirectory.split(separator); - if (albumDirectory.startsWith(androidFileUtils.picturesPath) && appNameMap.keys.contains(parts.last)) { + if (albumDirectory.startsWith(androidFileUtils.externalStorage) && appNameMap.keys.contains(parts.last)) { final packageName = appNameMap[parts.last]; return AppIcon( packageName: packageName, diff --git a/lib/widgets/fullscreen/info/location_section.dart b/lib/widgets/fullscreen/info/location_section.dart index 50a5f0019..8440985b3 100644 --- a/lib/widgets/fullscreen/info/location_section.dart +++ b/lib/widgets/fullscreen/info/location_section.dart @@ -9,11 +9,11 @@ class LocationSection extends AnimatedWidget { final ImageEntry entry; final showTitle; - const LocationSection({ + LocationSection({ Key key, @required this.entry, @required this.showTitle, - }) : super(key: key, listenable: entry); + }) : super(key: key, listenable: Listenable.merge([entry.metadataChangeNotifier, entry.addressChangeNotifier])); @override Widget build(BuildContext context) { diff --git a/lib/widgets/fullscreen/info/xmp_section.dart b/lib/widgets/fullscreen/info/xmp_section.dart index 7f19ee80e..ada5266f9 100644 --- a/lib/widgets/fullscreen/info/xmp_section.dart +++ b/lib/widgets/fullscreen/info/xmp_section.dart @@ -8,11 +8,11 @@ class XmpTagSection extends AnimatedWidget { final ImageCollection collection; final ImageEntry entry; - const XmpTagSection({ + XmpTagSection({ Key key, @required this.collection, @required this.entry, - }) : super(key: key, listenable: entry); + }) : super(key: key, listenable: entry.metadataChangeNotifier); @override Widget build(BuildContext context) { diff --git a/lib/widgets/fullscreen/overlay_bottom.dart b/lib/widgets/fullscreen/overlay_bottom.dart index 7877ae49d..11e18c8a3 100644 --- a/lib/widgets/fullscreen/overlay_bottom.dart +++ b/lib/widgets/fullscreen/overlay_bottom.dart @@ -4,6 +4,7 @@ import 'dart:ui'; import 'package:aves/model/image_entry.dart'; import 'package:aves/model/image_metadata.dart'; import 'package:aves/model/metadata_service.dart'; +import 'package:aves/utils/geo_utils.dart'; import 'package:aves/widgets/common/blurred.dart'; import 'package:flutter/material.dart'; import 'package:intl/intl.dart'; @@ -123,7 +124,7 @@ class _FullscreenBottomOverlayContent extends StatelessWidget { width: maxWidth, child: Text('$position – ${entry.title}', overflow: TextOverflow.ellipsis), ), - if (entry.isLocated) + if (entry.hasGps) Container( padding: EdgeInsets.only(top: interRowPadding), width: subRowWidth, @@ -158,11 +159,17 @@ class _FullscreenBottomOverlayContent extends StatelessWidget { } Widget _buildLocationRow() { + String text; + if (entry.isLocated) { + text = entry.shortAddress; + } else if (entry.hasGps) { + text = toDMS(entry.latLng).join(', '); + } return Row( children: [ Icon(Icons.place, size: iconSize), SizedBox(width: iconPadding), - Expanded(child: Text(entry.shortAddress, overflow: TextOverflow.ellipsis)), + Expanded(child: Text(text, overflow: TextOverflow.ellipsis)), ], ); } diff --git a/lib/widgets/fullscreen/overlay_top.dart b/lib/widgets/fullscreen/overlay_top.dart index 1f34d0501..922820869 100644 --- a/lib/widgets/fullscreen/overlay_top.dart +++ b/lib/widgets/fullscreen/overlay_top.dart @@ -45,6 +45,15 @@ class FullscreenTopOverlay extends StatelessWidget { ), ), SizedBox(width: 8), + OverlayButton( + scale: scale, + child: IconButton( + icon: Icon(Icons.delete_outline), + onPressed: () => onActionSelected?.call(FullscreenAction.delete), + tooltip: 'Delete', + ), + ), + SizedBox(width: 8), OverlayButton( scale: scale, child: PopupMenuButton( @@ -53,10 +62,6 @@ class FullscreenTopOverlay extends StatelessWidget { value: FullscreenAction.info, child: MenuRow(text: 'Info', icon: Icons.info_outline), ), - PopupMenuItem( - value: FullscreenAction.delete, - child: MenuRow(text: 'Delete', icon: Icons.delete_outline), - ), PopupMenuItem( value: FullscreenAction.rename, child: MenuRow(text: 'Rename', icon: Icons.title),