552 lines
17 KiB
Dart
552 lines
17 KiB
Dart
import 'dart:async';
|
||
|
||
import 'package:aves/model/entry_cache.dart';
|
||
import 'package:aves/model/favourite_repo.dart';
|
||
import 'package:aves/model/image_metadata.dart';
|
||
import 'package:aves/model/metadata_db.dart';
|
||
import 'package:aves/services/image_file_service.dart';
|
||
import 'package:aves/services/metadata_service.dart';
|
||
import 'package:aves/services/service_policy.dart';
|
||
import 'package:aves/utils/change_notifier.dart';
|
||
import 'package:aves/utils/math_utils.dart';
|
||
import 'package:aves/utils/time_utils.dart';
|
||
import 'package:collection/collection.dart';
|
||
import 'package:flutter/foundation.dart';
|
||
import 'package:flutter/material.dart';
|
||
import 'package:geocoder/geocoder.dart';
|
||
import 'package:latlong/latlong.dart';
|
||
import 'package:path/path.dart' as ppath;
|
||
|
||
import 'mime_types.dart';
|
||
|
||
class ImageEntry {
|
||
String uri;
|
||
String _path, _directory, _filename, _extension;
|
||
int contentId;
|
||
final String sourceMimeType;
|
||
|
||
// TODO TLAD use SVG viewport as width/height
|
||
int width;
|
||
int height;
|
||
int sourceRotationDegrees;
|
||
final int sizeBytes;
|
||
String sourceTitle;
|
||
int _dateModifiedSecs;
|
||
final int sourceDateTakenMillis;
|
||
final int durationMillis;
|
||
int _catalogDateMillis;
|
||
CatalogMetadata _catalogMetadata;
|
||
AddressDetails _addressDetails;
|
||
|
||
final AChangeNotifier imageChangeNotifier = AChangeNotifier(), metadataChangeNotifier = AChangeNotifier(), addressChangeNotifier = AChangeNotifier();
|
||
|
||
ImageEntry({
|
||
this.uri,
|
||
String path,
|
||
this.contentId,
|
||
this.sourceMimeType,
|
||
@required this.width,
|
||
@required this.height,
|
||
this.sourceRotationDegrees,
|
||
this.sizeBytes,
|
||
this.sourceTitle,
|
||
int dateModifiedSecs,
|
||
this.sourceDateTakenMillis,
|
||
this.durationMillis,
|
||
}) : assert(width != null),
|
||
assert(height != null) {
|
||
this.path = path;
|
||
this.dateModifiedSecs = dateModifiedSecs;
|
||
}
|
||
|
||
bool get canDecode => !MimeTypes.undecodable.contains(mimeType);
|
||
|
||
ImageEntry copyWith({
|
||
@required String uri,
|
||
@required String path,
|
||
@required int contentId,
|
||
@required int dateModifiedSecs,
|
||
}) {
|
||
final copyContentId = contentId ?? this.contentId;
|
||
final copied = ImageEntry(
|
||
uri: uri ?? uri,
|
||
path: path ?? this.path,
|
||
contentId: copyContentId,
|
||
sourceMimeType: sourceMimeType,
|
||
width: width,
|
||
height: height,
|
||
sourceRotationDegrees: sourceRotationDegrees,
|
||
sizeBytes: sizeBytes,
|
||
sourceTitle: sourceTitle,
|
||
dateModifiedSecs: dateModifiedSecs,
|
||
sourceDateTakenMillis: sourceDateTakenMillis,
|
||
durationMillis: durationMillis,
|
||
)
|
||
..catalogMetadata = _catalogMetadata?.copyWith(contentId: copyContentId)
|
||
..addressDetails = _addressDetails?.copyWith(contentId: copyContentId);
|
||
|
||
return copied;
|
||
}
|
||
|
||
// from DB or platform source entry
|
||
factory ImageEntry.fromMap(Map map) {
|
||
return ImageEntry(
|
||
uri: map['uri'] as String,
|
||
path: map['path'] as String,
|
||
contentId: map['contentId'] as int,
|
||
sourceMimeType: map['sourceMimeType'] as String,
|
||
width: map['width'] as int ?? 0,
|
||
height: map['height'] as int ?? 0,
|
||
sourceRotationDegrees: map['sourceRotationDegrees'] as int ?? 0,
|
||
sizeBytes: map['sizeBytes'] as int,
|
||
sourceTitle: map['title'] as String,
|
||
dateModifiedSecs: map['dateModifiedSecs'] as int,
|
||
sourceDateTakenMillis: map['sourceDateTakenMillis'] as int,
|
||
durationMillis: map['durationMillis'] as int,
|
||
);
|
||
}
|
||
|
||
// for DB only
|
||
Map<String, dynamic> toMap() {
|
||
return {
|
||
'uri': uri,
|
||
'path': path,
|
||
'contentId': contentId,
|
||
'sourceMimeType': sourceMimeType,
|
||
'width': width,
|
||
'height': height,
|
||
'sourceRotationDegrees': sourceRotationDegrees,
|
||
'sizeBytes': sizeBytes,
|
||
'title': sourceTitle,
|
||
'dateModifiedSecs': dateModifiedSecs,
|
||
'sourceDateTakenMillis': sourceDateTakenMillis,
|
||
'durationMillis': durationMillis,
|
||
};
|
||
}
|
||
|
||
void dispose() {
|
||
imageChangeNotifier.dispose();
|
||
metadataChangeNotifier.dispose();
|
||
addressChangeNotifier.dispose();
|
||
}
|
||
|
||
@override
|
||
String toString() {
|
||
return 'ImageEntry{uri=$uri, path=$path}';
|
||
}
|
||
|
||
set path(String path) {
|
||
_path = path;
|
||
_directory = null;
|
||
_filename = null;
|
||
_extension = null;
|
||
}
|
||
|
||
String get path => _path;
|
||
|
||
String get directory {
|
||
_directory ??= path != null ? ppath.dirname(path) : null;
|
||
return _directory;
|
||
}
|
||
|
||
String get filenameWithoutExtension {
|
||
_filename ??= path != null ? ppath.basenameWithoutExtension(path) : null;
|
||
return _filename;
|
||
}
|
||
|
||
String get extension {
|
||
_extension ??= path != null ? ppath.extension(path) : null;
|
||
return _extension;
|
||
}
|
||
|
||
// the MIME type reported by the Media Store is unreliable
|
||
// so we use the one found during cataloguing if possible
|
||
String get mimeType => _catalogMetadata?.mimeType ?? sourceMimeType;
|
||
|
||
String get mimeTypeAnySubtype => mimeType.replaceAll(RegExp('/.*'), '/*');
|
||
|
||
bool get isFavourite => favourites.isFavourite(this);
|
||
|
||
bool get isSvg => mimeType == MimeTypes.svg;
|
||
|
||
// guess whether this is a photo, according to file type (used as a hint to e.g. display megapixels)
|
||
bool get isPhoto => [MimeTypes.heic, MimeTypes.heif, MimeTypes.jpeg].contains(mimeType) || isRaw;
|
||
|
||
// Android's `BitmapRegionDecoder` documentation states that "only the JPEG and PNG formats are supported"
|
||
// but in practice (tested on API 25, 27, 29), it successfully decodes the formats listed below,
|
||
// and it actually fails to decode GIF, DNG and animated WEBP. Other formats were not tested.
|
||
bool get canTile =>
|
||
[
|
||
MimeTypes.heic,
|
||
MimeTypes.heif,
|
||
MimeTypes.jpeg,
|
||
MimeTypes.webp,
|
||
MimeTypes.arw,
|
||
MimeTypes.cr2,
|
||
MimeTypes.nef,
|
||
MimeTypes.nrw,
|
||
MimeTypes.orf,
|
||
MimeTypes.pef,
|
||
MimeTypes.raf,
|
||
MimeTypes.rw2,
|
||
MimeTypes.srw,
|
||
].contains(mimeType) &&
|
||
!isAnimated;
|
||
|
||
bool get isRaw => MimeTypes.rawImages.contains(mimeType);
|
||
|
||
bool get isVideo => mimeType.startsWith('video');
|
||
|
||
bool get isCatalogued => _catalogMetadata != null;
|
||
|
||
bool get isAnimated => _catalogMetadata?.isAnimated ?? false;
|
||
|
||
bool get canEdit => path != null;
|
||
|
||
bool get canPrint => !isVideo;
|
||
|
||
bool get canRotateAndFlip => canEdit && canEditExif;
|
||
|
||
// support for writing EXIF
|
||
// as of androidx.exifinterface:exifinterface:1.3.0
|
||
bool get canEditExif {
|
||
switch (mimeType.toLowerCase()) {
|
||
case MimeTypes.jpeg:
|
||
case MimeTypes.png:
|
||
case MimeTypes.webp:
|
||
return true;
|
||
default:
|
||
return false;
|
||
}
|
||
}
|
||
|
||
// The additional comparison of width to height is a workaround for badly registered entries.
|
||
// e.g. a portrait FHD video should be registered as width=1920, height=1080, orientation=90,
|
||
// but is incorrectly registered in the Media Store as width=1080, height=1920, orientation=0
|
||
// Double-checking the width/height during loading or cataloguing is the proper solution,
|
||
// but it would take space and time, so a basic workaround will do.
|
||
bool get isPortrait => rotationDegrees % 180 == 90 && (catalogMetadata?.rotationDegrees == null || width > height);
|
||
|
||
String get resolutionText {
|
||
final w = width ?? '?';
|
||
final h = height ?? '?';
|
||
return isPortrait ? '$h × $w' : '$w × $h';
|
||
}
|
||
|
||
double get displayAspectRatio {
|
||
if (width == 0 || height == 0) return 1;
|
||
return isPortrait ? height / width : width / height;
|
||
}
|
||
|
||
Size get displaySize => isPortrait ? Size(height.toDouble(), width.toDouble()) : Size(width.toDouble(), height.toDouble());
|
||
|
||
int get megaPixels => width != null && height != null ? (width * height / 1000000).round() : null;
|
||
|
||
DateTime _bestDate;
|
||
|
||
DateTime get bestDate {
|
||
if (_bestDate == null) {
|
||
if ((_catalogDateMillis ?? 0) > 0) {
|
||
_bestDate = DateTime.fromMillisecondsSinceEpoch(_catalogDateMillis);
|
||
} else if ((sourceDateTakenMillis ?? 0) > 0) {
|
||
_bestDate = DateTime.fromMillisecondsSinceEpoch(sourceDateTakenMillis);
|
||
} else if ((dateModifiedSecs ?? 0) > 0) {
|
||
_bestDate = DateTime.fromMillisecondsSinceEpoch(dateModifiedSecs * 1000);
|
||
}
|
||
}
|
||
return _bestDate;
|
||
}
|
||
|
||
int get rotationDegrees => _catalogMetadata?.rotationDegrees ?? sourceRotationDegrees ?? 0;
|
||
|
||
set rotationDegrees(int rotationDegrees) {
|
||
sourceRotationDegrees = rotationDegrees;
|
||
_catalogMetadata?.rotationDegrees = rotationDegrees;
|
||
}
|
||
|
||
bool get isFlipped => _catalogMetadata?.isFlipped ?? false;
|
||
|
||
set isFlipped(bool isFlipped) => _catalogMetadata?.isFlipped = isFlipped;
|
||
|
||
int get dateModifiedSecs => _dateModifiedSecs;
|
||
|
||
set dateModifiedSecs(int dateModifiedSecs) {
|
||
_dateModifiedSecs = dateModifiedSecs;
|
||
_bestDate = null;
|
||
}
|
||
|
||
DateTime get monthTaken {
|
||
final d = bestDate;
|
||
return d == null ? null : DateTime(d.year, d.month);
|
||
}
|
||
|
||
DateTime get dayTaken {
|
||
final d = bestDate;
|
||
return d == null ? null : DateTime(d.year, d.month, d.day);
|
||
}
|
||
|
||
String _durationText;
|
||
|
||
String get durationText {
|
||
_durationText ??= formatDuration(Duration(milliseconds: durationMillis ?? 0));
|
||
return _durationText;
|
||
}
|
||
|
||
bool get hasGps => isCatalogued && _catalogMetadata.latitude != null;
|
||
|
||
bool get isLocated => _addressDetails != null;
|
||
|
||
LatLng get latLng => isCatalogued ? LatLng(_catalogMetadata.latitude, _catalogMetadata.longitude) : null;
|
||
|
||
String get geoUri {
|
||
if (!hasGps) return null;
|
||
final latitude = roundToPrecision(_catalogMetadata.latitude, decimals: 6);
|
||
final longitude = roundToPrecision(_catalogMetadata.longitude, decimals: 6);
|
||
return 'geo:$latitude,$longitude?q=$latitude,$longitude';
|
||
}
|
||
|
||
List<String> get xmpSubjects => _catalogMetadata?.xmpSubjects?.split(';')?.where((tag) => tag.isNotEmpty)?.toList() ?? [];
|
||
|
||
String _bestTitle;
|
||
|
||
String get bestTitle {
|
||
_bestTitle ??= (isCatalogued && _catalogMetadata.xmpTitleDescription.isNotEmpty) ? _catalogMetadata.xmpTitleDescription : sourceTitle;
|
||
return _bestTitle;
|
||
}
|
||
|
||
CatalogMetadata get catalogMetadata => _catalogMetadata;
|
||
|
||
set catalogDateMillis(int dateMillis) {
|
||
_catalogDateMillis = dateMillis;
|
||
_bestDate = null;
|
||
}
|
||
|
||
set catalogMetadata(CatalogMetadata newMetadata) {
|
||
final oldDateModifiedSecs = dateModifiedSecs;
|
||
final oldRotationDegrees = rotationDegrees;
|
||
final oldIsFlipped = isFlipped;
|
||
|
||
catalogDateMillis = newMetadata?.dateMillis;
|
||
_catalogMetadata = newMetadata;
|
||
_bestTitle = null;
|
||
metadataChangeNotifier.notifyListeners();
|
||
|
||
_onImageChanged(oldDateModifiedSecs, oldRotationDegrees, oldIsFlipped);
|
||
}
|
||
|
||
void clearMetadata() {
|
||
catalogMetadata = null;
|
||
addressDetails = null;
|
||
}
|
||
|
||
Future<void> catalog({bool background = false}) async {
|
||
if (isCatalogued) return;
|
||
catalogMetadata = await MetadataService.getCatalogMetadata(this, background: background);
|
||
}
|
||
|
||
AddressDetails get addressDetails => _addressDetails;
|
||
|
||
set addressDetails(AddressDetails newAddress) {
|
||
_addressDetails = newAddress;
|
||
addressChangeNotifier.notifyListeners();
|
||
}
|
||
|
||
Future<void> locate({bool background = false}) async {
|
||
if (isLocated) return;
|
||
|
||
await catalog(background: background);
|
||
final latitude = _catalogMetadata?.latitude;
|
||
final longitude = _catalogMetadata?.longitude;
|
||
if (latitude == null || longitude == null || (latitude == 0 && longitude == 0)) return;
|
||
|
||
final coordinates = Coordinates(latitude, longitude);
|
||
try {
|
||
Future<List<Address>> call() => Geocoder.local.findAddressesFromCoordinates(coordinates);
|
||
final addresses = await (background
|
||
? servicePolicy.call(
|
||
call,
|
||
priority: ServiceCallPriority.getLocation,
|
||
)
|
||
: call());
|
||
if (addresses != null && addresses.isNotEmpty) {
|
||
final address = addresses.first;
|
||
addressDetails = AddressDetails(
|
||
contentId: contentId,
|
||
countryCode: address.countryCode,
|
||
countryName: address.countryName,
|
||
adminArea: address.adminArea,
|
||
locality: address.locality,
|
||
);
|
||
}
|
||
} catch (error, stackTrace) {
|
||
debugPrint('$runtimeType locate failed with path=$path coordinates=$coordinates error=$error\n$stackTrace');
|
||
}
|
||
}
|
||
|
||
Future<String> findAddressLine() async {
|
||
final latitude = _catalogMetadata?.latitude;
|
||
final longitude = _catalogMetadata?.longitude;
|
||
if (latitude == null || longitude == null || (latitude == 0 && longitude == 0)) return null;
|
||
|
||
final coordinates = Coordinates(latitude, longitude);
|
||
try {
|
||
final addresses = await Geocoder.local.findAddressesFromCoordinates(coordinates);
|
||
if (addresses != null && addresses.isNotEmpty) {
|
||
final address = addresses.first;
|
||
return address.addressLine;
|
||
}
|
||
} catch (error, stackTrace) {
|
||
debugPrint('$runtimeType findAddressLine failed with path=$path coordinates=$coordinates error=$error\n$stackTrace');
|
||
}
|
||
return null;
|
||
}
|
||
|
||
String get shortAddress {
|
||
if (!isLocated) return '';
|
||
|
||
// `admin area` examples: Seoul, Geneva, null
|
||
// `locality` examples: Mapo-gu, Geneva, Annecy
|
||
return {
|
||
_addressDetails.countryName,
|
||
_addressDetails.adminArea,
|
||
_addressDetails.locality,
|
||
}.where((part) => part != null && part.isNotEmpty).join(', ');
|
||
}
|
||
|
||
bool search(String query) => {
|
||
bestTitle,
|
||
_catalogMetadata?.xmpSubjects,
|
||
_addressDetails?.countryName,
|
||
_addressDetails?.adminArea,
|
||
_addressDetails?.locality,
|
||
}.any((s) => s != null && s.toUpperCase().contains(query));
|
||
|
||
Future<void> _applyNewFields(Map newFields) async {
|
||
final uri = newFields['uri'];
|
||
if (uri is String) this.uri = uri;
|
||
final path = newFields['path'];
|
||
if (path is String) this.path = path;
|
||
final contentId = newFields['contentId'];
|
||
if (contentId is int) this.contentId = contentId;
|
||
final sourceTitle = newFields['title'];
|
||
if (sourceTitle is String) {
|
||
this.sourceTitle = sourceTitle;
|
||
_bestTitle = null;
|
||
}
|
||
final dateModifiedSecs = newFields['dateModifiedSecs'];
|
||
if (dateModifiedSecs is int) this.dateModifiedSecs = dateModifiedSecs;
|
||
final rotationDegrees = newFields['rotationDegrees'];
|
||
if (rotationDegrees is int) this.rotationDegrees = rotationDegrees;
|
||
final isFlipped = newFields['isFlipped'];
|
||
if (isFlipped is bool) this.isFlipped = isFlipped;
|
||
|
||
await metadataDb.saveEntries({this});
|
||
await metadataDb.saveMetadata({catalogMetadata});
|
||
|
||
metadataChangeNotifier.notifyListeners();
|
||
}
|
||
|
||
Future<bool> rename(String newName) async {
|
||
if (newName == filenameWithoutExtension) return true;
|
||
|
||
final newFields = await ImageFileService.rename(this, '$newName$extension');
|
||
if (newFields.isEmpty) return false;
|
||
|
||
await _applyNewFields(newFields);
|
||
return true;
|
||
}
|
||
|
||
Future<bool> rotate({@required bool clockwise}) async {
|
||
final newFields = await ImageFileService.rotate(this, clockwise: clockwise);
|
||
if (newFields.isEmpty) return false;
|
||
|
||
final oldDateModifiedSecs = dateModifiedSecs;
|
||
final oldRotationDegrees = rotationDegrees;
|
||
final oldIsFlipped = isFlipped;
|
||
await _applyNewFields(newFields);
|
||
await _onImageChanged(oldDateModifiedSecs, oldRotationDegrees, oldIsFlipped);
|
||
return true;
|
||
}
|
||
|
||
Future<bool> flip() async {
|
||
final newFields = await ImageFileService.flip(this);
|
||
if (newFields.isEmpty) return false;
|
||
|
||
final oldDateModifiedSecs = dateModifiedSecs;
|
||
final oldRotationDegrees = rotationDegrees;
|
||
final oldIsFlipped = isFlipped;
|
||
await _applyNewFields(newFields);
|
||
await _onImageChanged(oldDateModifiedSecs, oldRotationDegrees, oldIsFlipped);
|
||
return true;
|
||
}
|
||
|
||
Future<bool> delete() {
|
||
Completer completer = Completer<bool>();
|
||
ImageFileService.delete([this]).listen(
|
||
(event) => completer.complete(event.success),
|
||
onError: completer.completeError,
|
||
onDone: () {
|
||
if (!completer.isCompleted) {
|
||
completer.complete(false);
|
||
}
|
||
},
|
||
);
|
||
return completer.future;
|
||
}
|
||
|
||
// when the entry image itself changed (e.g. after rotation)
|
||
void _onImageChanged(int oldDateModifiedSecs, int oldRotationDegrees, bool oldIsFlipped) async {
|
||
if (oldDateModifiedSecs != dateModifiedSecs || oldRotationDegrees != rotationDegrees || oldIsFlipped != isFlipped) {
|
||
await EntryCache.evict(uri, mimeType, oldDateModifiedSecs, oldRotationDegrees, oldIsFlipped);
|
||
imageChangeNotifier.notifyListeners();
|
||
}
|
||
}
|
||
|
||
// favourites
|
||
|
||
void toggleFavourite() {
|
||
if (isFavourite) {
|
||
removeFromFavourites();
|
||
} else {
|
||
addToFavourites();
|
||
}
|
||
}
|
||
|
||
void addToFavourites() {
|
||
if (!isFavourite) {
|
||
favourites.add([this]);
|
||
}
|
||
}
|
||
|
||
void removeFromFavourites() {
|
||
if (isFavourite) {
|
||
favourites.remove([this]);
|
||
}
|
||
}
|
||
|
||
// compare by:
|
||
// 1) title ascending
|
||
// 2) extension ascending
|
||
static int compareByName(ImageEntry a, ImageEntry b) {
|
||
final c = compareAsciiUpperCase(a.bestTitle, b.bestTitle);
|
||
return c != 0 ? c : compareAsciiUpperCase(a.extension, b.extension);
|
||
}
|
||
|
||
// compare by:
|
||
// 1) size descending
|
||
// 2) name ascending
|
||
static int compareBySize(ImageEntry a, ImageEntry b) {
|
||
final c = b.sizeBytes.compareTo(a.sizeBytes);
|
||
return c != 0 ? c : compareByName(a, b);
|
||
}
|
||
|
||
static final _epoch = DateTime.fromMillisecondsSinceEpoch(0);
|
||
|
||
// compare by:
|
||
// 1) date descending
|
||
// 2) name ascending
|
||
static int compareByDate(ImageEntry a, ImageEntry b) {
|
||
final c = (b.bestDate ?? _epoch).compareTo(a.bestDate ?? _epoch);
|
||
return c != 0 ? c : compareByName(a, b);
|
||
}
|
||
}
|