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 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 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 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 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 catalog() async { if (isCatalogued) return; catalogMetadata = await MetadataService.getCatalogMetadata(this); } AddressDetails get addressDetails => _addressDetails; set addressDetails(AddressDetails newAddress) { _addressDetails = newAddress; addressChangeNotifier.notifyListeners(); } Future 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 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 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 delete() async => (await ImageFileService.delete([this])) == 1; void toggleFavourite() { if (isFavourite) { favourites.remove(this); } else { favourites.add(this); } isFavouriteNotifier.value = !isFavouriteNotifier.value; } }