entry: split change notifier, address fallback
This commit is contained in:
parent
31eb61433e
commit
ff34e77cb3
11 changed files with 115 additions and 27 deletions
|
@ -120,7 +120,7 @@ class ImageCollection with ChangeNotifier {
|
||||||
|
|
||||||
catalogEntries() async {
|
catalogEntries() async {
|
||||||
final start = DateTime.now();
|
final start = DateTime.now();
|
||||||
final uncataloguedEntries = _rawEntries.where((entry) => !entry.isCatalogued);
|
final uncataloguedEntries = _rawEntries.where((entry) => !entry.isCatalogued).toList();
|
||||||
final newMetadata = List<CatalogMetadata>();
|
final newMetadata = List<CatalogMetadata>();
|
||||||
await Future.forEach<ImageEntry>(uncataloguedEntries, (entry) async {
|
await Future.forEach<ImageEntry>(uncataloguedEntries, (entry) async {
|
||||||
await entry.catalog();
|
await entry.catalog();
|
||||||
|
@ -133,7 +133,7 @@ class ImageCollection with ChangeNotifier {
|
||||||
|
|
||||||
locateEntries() async {
|
locateEntries() async {
|
||||||
final start = DateTime.now();
|
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<AddressDetails>();
|
final newAddresses = List<AddressDetails>();
|
||||||
await Future.forEach<ImageEntry>(unlocatedEntries, (entry) async {
|
await Future.forEach<ImageEntry>(unlocatedEntries, (entry) async {
|
||||||
await entry.locate();
|
await entry.locate();
|
||||||
|
|
|
@ -3,6 +3,7 @@ import 'dart:collection';
|
||||||
import 'package:aves/model/image_file_service.dart';
|
import 'package:aves/model/image_file_service.dart';
|
||||||
import 'package:aves/model/image_metadata.dart';
|
import 'package:aves/model/image_metadata.dart';
|
||||||
import 'package:aves/model/metadata_service.dart';
|
import 'package:aves/model/metadata_service.dart';
|
||||||
|
import 'package:aves/utils/change_notifier.dart';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:geocoder/geocoder.dart';
|
import 'package:geocoder/geocoder.dart';
|
||||||
import 'package:path/path.dart';
|
import 'package:path/path.dart';
|
||||||
|
@ -10,7 +11,7 @@ import 'package:tuple/tuple.dart';
|
||||||
|
|
||||||
import 'mime_types.dart';
|
import 'mime_types.dart';
|
||||||
|
|
||||||
class ImageEntry with ChangeNotifier {
|
class ImageEntry {
|
||||||
String uri;
|
String uri;
|
||||||
String path;
|
String path;
|
||||||
String directory;
|
String directory;
|
||||||
|
@ -28,6 +29,8 @@ class ImageEntry with ChangeNotifier {
|
||||||
CatalogMetadata catalogMetadata;
|
CatalogMetadata catalogMetadata;
|
||||||
AddressDetails addressDetails;
|
AddressDetails addressDetails;
|
||||||
|
|
||||||
|
AChangeNotifier imageChangeNotifier = new AChangeNotifier(), metadataChangeNotifier = new AChangeNotifier(), addressChangeNotifier = new AChangeNotifier();
|
||||||
|
|
||||||
ImageEntry({
|
ImageEntry({
|
||||||
this.uri,
|
this.uri,
|
||||||
this.path,
|
this.path,
|
||||||
|
@ -80,6 +83,12 @@ class ImageEntry with ChangeNotifier {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
dispose() {
|
||||||
|
imageChangeNotifier.dispose();
|
||||||
|
metadataChangeNotifier.dispose();
|
||||||
|
addressChangeNotifier.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String toString() {
|
String toString() {
|
||||||
return 'ImageEntry{uri=$uri, path=$path}';
|
return 'ImageEntry{uri=$uri, path=$path}';
|
||||||
|
@ -143,7 +152,7 @@ class ImageEntry with ChangeNotifier {
|
||||||
catalog() async {
|
catalog() async {
|
||||||
if (isCatalogued) return;
|
if (isCatalogued) return;
|
||||||
catalogMetadata = await MetadataService.getCatalogMetadata(this);
|
catalogMetadata = await MetadataService.getCatalogMetadata(this);
|
||||||
notifyListeners();
|
metadataChangeNotifier.notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
locate() async {
|
locate() async {
|
||||||
|
@ -166,10 +175,10 @@ class ImageEntry with ChangeNotifier {
|
||||||
adminArea: address.adminArea,
|
adminArea: address.adminArea,
|
||||||
locality: address.locality,
|
locality: address.locality,
|
||||||
);
|
);
|
||||||
notifyListeners();
|
addressChangeNotifier.notifyListeners();
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (exception) {
|
||||||
debugPrint('$runtimeType addAddressToMetadata failed with exception=${e.message}');
|
debugPrint('$runtimeType addAddressToMetadata failed with exception=$exception');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -204,7 +213,7 @@ class ImageEntry with ChangeNotifier {
|
||||||
if (contentId != null) this.contentId = contentId;
|
if (contentId != null) this.contentId = contentId;
|
||||||
final title = newFields['title'];
|
final title = newFields['title'];
|
||||||
if (title != null) this.title = title;
|
if (title != null) this.title = title;
|
||||||
notifyListeners();
|
metadataChangeNotifier.notifyListeners();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -220,7 +229,7 @@ class ImageEntry with ChangeNotifier {
|
||||||
if (height != null) this.height = height;
|
if (height != null) this.height = height;
|
||||||
final orientationDegrees = newFields['orientationDegrees'];
|
final orientationDegrees = newFields['orientationDegrees'];
|
||||||
if (orientationDegrees != null) this.orientationDegrees = orientationDegrees;
|
if (orientationDegrees != null) this.orientationDegrees = orientationDegrees;
|
||||||
notifyListeners();
|
imageChangeNotifier.notifyListeners();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -5,16 +5,16 @@ final AndroidFileUtils androidFileUtils = AndroidFileUtils._private();
|
||||||
typedef void AndroidFileUtilsCallback(String key, dynamic oldValue, dynamic newValue);
|
typedef void AndroidFileUtilsCallback(String key, dynamic oldValue, dynamic newValue);
|
||||||
|
|
||||||
class AndroidFileUtils {
|
class AndroidFileUtils {
|
||||||
String dcimPath, downloadPath, picturesPath;
|
String externalStorage, dcimPath, downloadPath, picturesPath;
|
||||||
|
|
||||||
AndroidFileUtils._private();
|
AndroidFileUtils._private();
|
||||||
|
|
||||||
init() async {
|
init() async {
|
||||||
// path_provider getExternalStorageDirectory() gives '/storage/emulated/0/Android/data/deckers.thibault.aves/files'
|
// path_provider getExternalStorageDirectory() gives '/storage/emulated/0/Android/data/deckers.thibault.aves/files'
|
||||||
final ext = '/storage/emulated/0';
|
externalStorage = '/storage/emulated/0';
|
||||||
dcimPath = join(ext, 'DCIM');
|
dcimPath = join(externalStorage, 'DCIM');
|
||||||
downloadPath = join(ext, 'Download');
|
downloadPath = join(externalStorage, 'Download');
|
||||||
picturesPath = join(ext, 'Pictures');
|
picturesPath = join(externalStorage, 'Pictures');
|
||||||
}
|
}
|
||||||
|
|
||||||
bool isCameraPath(String path) => path != null && path.startsWith(dcimPath) && (path.endsWith('Camera') || path.endsWith('100ANDRO'));
|
bool isCameraPath(String path) => path != null && path.startsWith(dcimPath) && (path.endsWith('Camera') || path.endsWith('100ANDRO'));
|
||||||
|
|
27
lib/utils/change_notifier.dart
Normal file
27
lib/utils/change_notifier.dart
Normal file
|
@ -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<VoidCallback> _listeners = ObserverList<VoidCallback>();
|
||||||
|
|
||||||
|
@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<VoidCallback>.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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
38
lib/utils/geo_utils.dart
Normal file
38
lib/utils/geo_utils.dart
Normal file
|
@ -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<int> _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<String> tmp = new NumberFormat('0.0#####').format(_round(value, decimals: 10)).split('.');
|
||||||
|
return <int>[int.parse(tmp[0]).abs(), int.parse(tmp[1])];
|
||||||
|
}
|
||||||
|
|
||||||
|
final List<int> 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<int> 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<String> toDMS(Tuple2<double, double> 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'}'];
|
||||||
|
}
|
|
@ -25,6 +25,7 @@ class Thumbnail extends StatefulWidget {
|
||||||
|
|
||||||
class ThumbnailState extends State<Thumbnail> {
|
class ThumbnailState extends State<Thumbnail> {
|
||||||
Future<Uint8List> _byteLoader;
|
Future<Uint8List> _byteLoader;
|
||||||
|
Listenable _entryChangeNotifier;
|
||||||
|
|
||||||
ImageEntry get entry => widget.entry;
|
ImageEntry get entry => widget.entry;
|
||||||
|
|
||||||
|
@ -33,7 +34,8 @@ class ThumbnailState extends State<Thumbnail> {
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
entry.addListener(onEntryChange);
|
_entryChangeNotifier = Listenable.merge([entry.imageChangeNotifier, entry.metadataChangeNotifier]);
|
||||||
|
_entryChangeNotifier.addListener(onEntryChange);
|
||||||
initByteLoader();
|
initByteLoader();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -53,7 +55,7 @@ class ThumbnailState extends State<Thumbnail> {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
entry.removeListener(onEntryChange);
|
_entryChangeNotifier.removeListener(onEntryChange);
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -99,7 +99,7 @@ class IconUtils {
|
||||||
if (androidFileUtils.isDownloadPath(albumDirectory)) return Icon(Icons.file_download);
|
if (androidFileUtils.isDownloadPath(albumDirectory)) return Icon(Icons.file_download);
|
||||||
|
|
||||||
final parts = albumDirectory.split(separator);
|
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];
|
final packageName = appNameMap[parts.last];
|
||||||
return AppIcon(
|
return AppIcon(
|
||||||
packageName: packageName,
|
packageName: packageName,
|
||||||
|
|
|
@ -9,11 +9,11 @@ class LocationSection extends AnimatedWidget {
|
||||||
final ImageEntry entry;
|
final ImageEntry entry;
|
||||||
final showTitle;
|
final showTitle;
|
||||||
|
|
||||||
const LocationSection({
|
LocationSection({
|
||||||
Key key,
|
Key key,
|
||||||
@required this.entry,
|
@required this.entry,
|
||||||
@required this.showTitle,
|
@required this.showTitle,
|
||||||
}) : super(key: key, listenable: entry);
|
}) : super(key: key, listenable: Listenable.merge([entry.metadataChangeNotifier, entry.addressChangeNotifier]));
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
|
|
@ -8,11 +8,11 @@ class XmpTagSection extends AnimatedWidget {
|
||||||
final ImageCollection collection;
|
final ImageCollection collection;
|
||||||
final ImageEntry entry;
|
final ImageEntry entry;
|
||||||
|
|
||||||
const XmpTagSection({
|
XmpTagSection({
|
||||||
Key key,
|
Key key,
|
||||||
@required this.collection,
|
@required this.collection,
|
||||||
@required this.entry,
|
@required this.entry,
|
||||||
}) : super(key: key, listenable: entry);
|
}) : super(key: key, listenable: entry.metadataChangeNotifier);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
|
|
@ -4,6 +4,7 @@ import 'dart:ui';
|
||||||
import 'package:aves/model/image_entry.dart';
|
import 'package:aves/model/image_entry.dart';
|
||||||
import 'package:aves/model/image_metadata.dart';
|
import 'package:aves/model/image_metadata.dart';
|
||||||
import 'package:aves/model/metadata_service.dart';
|
import 'package:aves/model/metadata_service.dart';
|
||||||
|
import 'package:aves/utils/geo_utils.dart';
|
||||||
import 'package:aves/widgets/common/blurred.dart';
|
import 'package:aves/widgets/common/blurred.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:intl/intl.dart';
|
import 'package:intl/intl.dart';
|
||||||
|
@ -123,7 +124,7 @@ class _FullscreenBottomOverlayContent extends StatelessWidget {
|
||||||
width: maxWidth,
|
width: maxWidth,
|
||||||
child: Text('$position – ${entry.title}', overflow: TextOverflow.ellipsis),
|
child: Text('$position – ${entry.title}', overflow: TextOverflow.ellipsis),
|
||||||
),
|
),
|
||||||
if (entry.isLocated)
|
if (entry.hasGps)
|
||||||
Container(
|
Container(
|
||||||
padding: EdgeInsets.only(top: interRowPadding),
|
padding: EdgeInsets.only(top: interRowPadding),
|
||||||
width: subRowWidth,
|
width: subRowWidth,
|
||||||
|
@ -158,11 +159,17 @@ class _FullscreenBottomOverlayContent extends StatelessWidget {
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildLocationRow() {
|
Widget _buildLocationRow() {
|
||||||
|
String text;
|
||||||
|
if (entry.isLocated) {
|
||||||
|
text = entry.shortAddress;
|
||||||
|
} else if (entry.hasGps) {
|
||||||
|
text = toDMS(entry.latLng).join(', ');
|
||||||
|
}
|
||||||
return Row(
|
return Row(
|
||||||
children: [
|
children: [
|
||||||
Icon(Icons.place, size: iconSize),
|
Icon(Icons.place, size: iconSize),
|
||||||
SizedBox(width: iconPadding),
|
SizedBox(width: iconPadding),
|
||||||
Expanded(child: Text(entry.shortAddress, overflow: TextOverflow.ellipsis)),
|
Expanded(child: Text(text, overflow: TextOverflow.ellipsis)),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -45,6 +45,15 @@ class FullscreenTopOverlay extends StatelessWidget {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
SizedBox(width: 8),
|
SizedBox(width: 8),
|
||||||
|
OverlayButton(
|
||||||
|
scale: scale,
|
||||||
|
child: IconButton(
|
||||||
|
icon: Icon(Icons.delete_outline),
|
||||||
|
onPressed: () => onActionSelected?.call(FullscreenAction.delete),
|
||||||
|
tooltip: 'Delete',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SizedBox(width: 8),
|
||||||
OverlayButton(
|
OverlayButton(
|
||||||
scale: scale,
|
scale: scale,
|
||||||
child: PopupMenuButton<FullscreenAction>(
|
child: PopupMenuButton<FullscreenAction>(
|
||||||
|
@ -53,10 +62,6 @@ class FullscreenTopOverlay extends StatelessWidget {
|
||||||
value: FullscreenAction.info,
|
value: FullscreenAction.info,
|
||||||
child: MenuRow(text: 'Info', icon: Icons.info_outline),
|
child: MenuRow(text: 'Info', icon: Icons.info_outline),
|
||||||
),
|
),
|
||||||
PopupMenuItem(
|
|
||||||
value: FullscreenAction.delete,
|
|
||||||
child: MenuRow(text: 'Delete', icon: Icons.delete_outline),
|
|
||||||
),
|
|
||||||
PopupMenuItem(
|
PopupMenuItem(
|
||||||
value: FullscreenAction.rename,
|
value: FullscreenAction.rename,
|
||||||
child: MenuRow(text: 'Rename', icon: Icons.title),
|
child: MenuRow(text: 'Rename', icon: Icons.title),
|
||||||
|
|
Loading…
Reference in a new issue