322 lines
10 KiB
Dart
322 lines
10 KiB
Dart
import 'dart:async';
|
|
|
|
import 'package:aves/model/favourite_repo.dart';
|
|
import 'package:aves/model/image_metadata.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/time_utils.dart';
|
|
import 'package:flutter/foundation.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:geocoder/geocoder.dart';
|
|
import 'package:path/path.dart';
|
|
import 'package:tuple/tuple.dart';
|
|
|
|
import 'mime_types.dart';
|
|
|
|
class ImageEntry {
|
|
String uri;
|
|
String path;
|
|
String directory;
|
|
int contentId;
|
|
final String mimeType;
|
|
int width;
|
|
int height;
|
|
int orientationDegrees;
|
|
final int sizeBytes;
|
|
String sourceTitle;
|
|
final int dateModifiedSecs;
|
|
final int sourceDateTakenMillis;
|
|
final String bucketDisplayName;
|
|
final int durationMillis;
|
|
int _catalogDateMillis;
|
|
CatalogMetadata _catalogMetadata;
|
|
AddressDetails _addressDetails;
|
|
|
|
final AChangeNotifier imageChangeNotifier = AChangeNotifier(), metadataChangeNotifier = AChangeNotifier(), addressChangeNotifier = AChangeNotifier();
|
|
final ValueNotifier<bool> isFavouriteNotifier = ValueNotifier(false);
|
|
|
|
ImageEntry({
|
|
this.uri,
|
|
this.path,
|
|
this.contentId,
|
|
this.mimeType,
|
|
this.width,
|
|
this.height,
|
|
this.orientationDegrees,
|
|
this.sizeBytes,
|
|
this.sourceTitle,
|
|
this.dateModifiedSecs,
|
|
this.sourceDateTakenMillis,
|
|
this.bucketDisplayName,
|
|
this.durationMillis,
|
|
}) : directory = path != null ? dirname(path) : null {
|
|
isFavouriteNotifier.value = isFavourite;
|
|
}
|
|
|
|
factory ImageEntry.fromMap(Map map) {
|
|
return ImageEntry(
|
|
uri: map['uri'] as String,
|
|
path: map['path'] as String,
|
|
contentId: map['contentId'] as int,
|
|
mimeType: map['mimeType'] as String,
|
|
width: map['width'] as int,
|
|
height: map['height'] as int,
|
|
orientationDegrees: map['orientationDegrees'] as int,
|
|
sizeBytes: map['sizeBytes'] as int,
|
|
sourceTitle: map['title'] as String,
|
|
dateModifiedSecs: map['dateModifiedSecs'] as int,
|
|
sourceDateTakenMillis: map['sourceDateTakenMillis'] as int,
|
|
bucketDisplayName: map['bucketDisplayName'] as String,
|
|
durationMillis: map['durationMillis'] as int,
|
|
);
|
|
}
|
|
|
|
Map<String, dynamic> toMap() {
|
|
return {
|
|
'uri': uri,
|
|
'path': path,
|
|
'contentId': contentId,
|
|
'mimeType': mimeType,
|
|
'width': width,
|
|
'height': height,
|
|
'orientationDegrees': orientationDegrees,
|
|
'sizeBytes': sizeBytes,
|
|
'title': sourceTitle,
|
|
'dateModifiedSecs': dateModifiedSecs,
|
|
'sourceDateTakenMillis': sourceDateTakenMillis,
|
|
'bucketDisplayName': bucketDisplayName,
|
|
'durationMillis': durationMillis,
|
|
};
|
|
}
|
|
|
|
void dispose() {
|
|
imageChangeNotifier.dispose();
|
|
metadataChangeNotifier.dispose();
|
|
addressChangeNotifier.dispose();
|
|
isFavouriteNotifier.dispose();
|
|
}
|
|
|
|
@override
|
|
String toString() {
|
|
return 'ImageEntry{uri=$uri, path=$path}';
|
|
}
|
|
|
|
String get filename => basenameWithoutExtension(path);
|
|
|
|
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);
|
|
|
|
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 canRotate => canEdit && (mimeType == MimeTypes.JPEG || mimeType == MimeTypes.PNG);
|
|
|
|
bool get rotated => ((isVideo && isCatalogued) ? _catalogMetadata.videoRotation : orientationDegrees) % 180 == 90;
|
|
|
|
double get displayAspectRatio {
|
|
if (width == 0 || height == 0) return 1;
|
|
return rotated ? height / width : width / height;
|
|
}
|
|
|
|
Size get displaySize => rotated ? 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;
|
|
}
|
|
|
|
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));
|
|
return _durationText;
|
|
}
|
|
|
|
bool get hasGps => isCatalogued && _catalogMetadata.latitude != null;
|
|
|
|
bool get isLocated => _addressDetails != null;
|
|
|
|
Tuple2<double, double> get latLng => isCatalogued ? Tuple2(_catalogMetadata.latitude, _catalogMetadata.longitude) : null;
|
|
|
|
String get geoUri => hasGps ? 'geo:${_catalogMetadata.latitude},${_catalogMetadata.longitude}?q=${_catalogMetadata.latitude},${_catalogMetadata.longitude}' : null;
|
|
|
|
List<String> get xmpSubjects => _catalogMetadata?.xmpSubjects?.split(';')?.where((tag) => tag.isNotEmpty)?.toList() ?? [];
|
|
|
|
String _bestTitle;
|
|
|
|
String get bestTitle {
|
|
_bestTitle ??= (_catalogMetadata != null && _catalogMetadata.xmpTitleDescription.isNotEmpty) ? _catalogMetadata.xmpTitleDescription : sourceTitle;
|
|
return _bestTitle;
|
|
}
|
|
|
|
CatalogMetadata get catalogMetadata => _catalogMetadata;
|
|
|
|
set catalogDateMillis(int dateMillis) {
|
|
_catalogDateMillis = dateMillis;
|
|
_bestDate = null;
|
|
}
|
|
|
|
set catalogMetadata(CatalogMetadata newMetadata) {
|
|
if (newMetadata == null) return;
|
|
catalogDateMillis = newMetadata.dateMillis;
|
|
_catalogMetadata = newMetadata;
|
|
_bestTitle = null;
|
|
metadataChangeNotifier.notifyListeners();
|
|
}
|
|
|
|
Future<void> catalog() async {
|
|
if (isCatalogued) return;
|
|
catalogMetadata = await MetadataService.getCatalogMetadata(this);
|
|
}
|
|
|
|
AddressDetails get addressDetails => _addressDetails;
|
|
|
|
set addressDetails(AddressDetails newAddress) {
|
|
_addressDetails = newAddress;
|
|
addressChangeNotifier.notifyListeners();
|
|
}
|
|
|
|
Future<void> locate() async {
|
|
if (isLocated) return;
|
|
|
|
await catalog();
|
|
final latitude = _catalogMetadata?.latitude;
|
|
final longitude = _catalogMetadata?.longitude;
|
|
if (latitude == null || longitude == null) return;
|
|
|
|
final coordinates = Coordinates(latitude, longitude);
|
|
try {
|
|
final addresses = await servicePolicy.call(
|
|
() => Geocoder.local.findAddressesFromCoordinates(coordinates),
|
|
priority: ServiceCallPriority.background,
|
|
debugLabel: 'findAddressesFromCoordinates-$path',
|
|
);
|
|
if (addresses != null && addresses.isNotEmpty) {
|
|
final address = addresses.first;
|
|
addressDetails = AddressDetails(
|
|
contentId: contentId,
|
|
addressLine: address.addressLine,
|
|
countryCode: address.countryCode,
|
|
countryName: address.countryName,
|
|
adminArea: address.adminArea,
|
|
locality: address.locality,
|
|
);
|
|
}
|
|
} catch (exception) {
|
|
debugPrint('$runtimeType addAddressToMetadata failed with path=$path coordinates=$coordinates exception=$exception');
|
|
}
|
|
}
|
|
|
|
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) {
|
|
if (bestTitle?.toUpperCase()?.contains(query) ?? false) return true;
|
|
if (_catalogMetadata?.xmpSubjects?.toUpperCase()?.contains(query) ?? false) return true;
|
|
if (_addressDetails?.addressLine?.toUpperCase()?.contains(query) ?? false) return true;
|
|
return false;
|
|
}
|
|
|
|
Future<bool> rename(String newName) async {
|
|
if (newName == filename) return true;
|
|
|
|
final newFields = await ImageFileService.rename(this, '$newName${extension(this.path)}');
|
|
if (newFields.isEmpty) return false;
|
|
|
|
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;
|
|
metadataChangeNotifier.notifyListeners();
|
|
return true;
|
|
}
|
|
|
|
Future<bool> rotate({@required bool clockwise}) async {
|
|
final newFields = await ImageFileService.rotate(this, clockwise: clockwise);
|
|
if (newFields.isEmpty) return false;
|
|
|
|
final width = newFields['width'];
|
|
if (width is int) this.width = width;
|
|
final height = newFields['height'];
|
|
if (height is int) this.height = height;
|
|
final orientationDegrees = newFields['orientationDegrees'];
|
|
if (orientationDegrees is int) this.orientationDegrees = orientationDegrees;
|
|
|
|
imageChangeNotifier.notifyListeners();
|
|
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;
|
|
}
|
|
|
|
void toggleFavourite() {
|
|
if (isFavourite) {
|
|
favourites.remove(this);
|
|
} else {
|
|
favourites.add(this);
|
|
}
|
|
isFavouriteNotifier.value = !isFavouriteNotifier.value;
|
|
}
|
|
}
|