823 lines
27 KiB
Dart
823 lines
27 KiB
Dart
import 'dart:async';
|
|
import 'dart:io';
|
|
import 'dart:ui';
|
|
|
|
import 'package:aves/geo/countries.dart';
|
|
import 'package:aves/model/entry_cache.dart';
|
|
import 'package:aves/model/entry_dirs.dart';
|
|
import 'package:aves/model/favourites.dart';
|
|
import 'package:aves/model/geotiff.dart';
|
|
import 'package:aves/model/metadata/address.dart';
|
|
import 'package:aves/model/metadata/catalog.dart';
|
|
import 'package:aves/model/metadata/trash.dart';
|
|
import 'package:aves/model/multipage.dart';
|
|
import 'package:aves/model/source/trash.dart';
|
|
import 'package:aves/model/video/metadata.dart';
|
|
import 'package:aves/ref/mime_types.dart';
|
|
import 'package:aves/services/common/service_policy.dart';
|
|
import 'package:aves/services/common/services.dart';
|
|
import 'package:aves/services/geocoding_service.dart';
|
|
import 'package:aves/services/metadata/svg_metadata_service.dart';
|
|
import 'package:aves/theme/format.dart';
|
|
import 'package:aves/utils/change_notifier.dart';
|
|
import 'package:aves/utils/time_utils.dart';
|
|
import 'package:collection/collection.dart';
|
|
import 'package:country_code/country_code.dart';
|
|
import 'package:flutter/foundation.dart';
|
|
import 'package:latlong2/latlong.dart';
|
|
|
|
enum EntryDataType { basic, aspectRatio, catalog, address, references }
|
|
|
|
class AvesEntry {
|
|
// `sizeBytes`, `dateModifiedSecs` can be missing in viewer mode
|
|
int id;
|
|
String uri;
|
|
String? _path, _filename, _extension, _sourceTitle;
|
|
EntryDir? _directory;
|
|
int? pageId, contentId;
|
|
final String sourceMimeType;
|
|
int width, height, sourceRotationDegrees;
|
|
int? sizeBytes, dateAddedSecs, _dateModifiedSecs, sourceDateTakenMillis, _durationMillis;
|
|
bool trashed;
|
|
|
|
int? _catalogDateMillis;
|
|
CatalogMetadata? _catalogMetadata;
|
|
AddressDetails? _addressDetails;
|
|
TrashDetails? trashDetails;
|
|
|
|
List<AvesEntry>? burstEntries;
|
|
|
|
final AChangeNotifier visualChangeNotifier = AChangeNotifier(), metadataChangeNotifier = AChangeNotifier(), addressChangeNotifier = AChangeNotifier();
|
|
|
|
AvesEntry({
|
|
required int? id,
|
|
required this.uri,
|
|
required String? path,
|
|
required this.contentId,
|
|
required this.pageId,
|
|
required this.sourceMimeType,
|
|
required this.width,
|
|
required this.height,
|
|
required this.sourceRotationDegrees,
|
|
required this.sizeBytes,
|
|
required String? sourceTitle,
|
|
required this.dateAddedSecs,
|
|
required int? dateModifiedSecs,
|
|
required this.sourceDateTakenMillis,
|
|
required int? durationMillis,
|
|
required this.trashed,
|
|
this.burstEntries,
|
|
}) : id = id ?? 0 {
|
|
this.path = path;
|
|
this.sourceTitle = sourceTitle;
|
|
this.dateModifiedSecs = dateModifiedSecs;
|
|
this.durationMillis = durationMillis;
|
|
}
|
|
|
|
bool get canDecode => !MimeTypes.undecodableImages.contains(mimeType);
|
|
|
|
bool get canHaveAlpha => MimeTypes.alphaImages.contains(mimeType);
|
|
|
|
AvesEntry copyWith({
|
|
int? id,
|
|
String? uri,
|
|
String? path,
|
|
int? contentId,
|
|
String? title,
|
|
int? dateAddedSecs,
|
|
int? dateModifiedSecs,
|
|
List<AvesEntry>? burstEntries,
|
|
}) {
|
|
final copyEntryId = id ?? this.id;
|
|
final copied = AvesEntry(
|
|
id: copyEntryId,
|
|
uri: uri ?? this.uri,
|
|
path: path ?? this.path,
|
|
contentId: contentId ?? this.contentId,
|
|
pageId: null,
|
|
sourceMimeType: sourceMimeType,
|
|
width: width,
|
|
height: height,
|
|
sourceRotationDegrees: sourceRotationDegrees,
|
|
sizeBytes: sizeBytes,
|
|
sourceTitle: title ?? sourceTitle,
|
|
dateAddedSecs: dateAddedSecs ?? this.dateAddedSecs,
|
|
dateModifiedSecs: dateModifiedSecs ?? this.dateModifiedSecs,
|
|
sourceDateTakenMillis: sourceDateTakenMillis,
|
|
durationMillis: durationMillis,
|
|
trashed: trashed,
|
|
burstEntries: burstEntries ?? this.burstEntries,
|
|
)
|
|
..catalogMetadata = _catalogMetadata?.copyWith(id: copyEntryId)
|
|
..addressDetails = _addressDetails?.copyWith(id: copyEntryId)
|
|
..trashDetails = trashDetails?.copyWith(id: copyEntryId);
|
|
|
|
return copied;
|
|
}
|
|
|
|
// from DB or platform source entry
|
|
factory AvesEntry.fromMap(Map map) {
|
|
return AvesEntry(
|
|
id: map['id'] as int?,
|
|
uri: map['uri'] as String,
|
|
path: map['path'] as String?,
|
|
pageId: null,
|
|
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?,
|
|
dateAddedSecs: map['dateAddedSecs'] as int?,
|
|
dateModifiedSecs: map['dateModifiedSecs'] as int?,
|
|
sourceDateTakenMillis: map['sourceDateTakenMillis'] as int?,
|
|
durationMillis: map['durationMillis'] as int?,
|
|
trashed: (map['trashed'] as int? ?? 0) != 0,
|
|
);
|
|
}
|
|
|
|
// for DB only
|
|
Map<String, dynamic> toMap() {
|
|
return {
|
|
'id': id,
|
|
'uri': uri,
|
|
'path': path,
|
|
'contentId': contentId,
|
|
'sourceMimeType': sourceMimeType,
|
|
'width': width,
|
|
'height': height,
|
|
'sourceRotationDegrees': sourceRotationDegrees,
|
|
'sizeBytes': sizeBytes,
|
|
'title': sourceTitle,
|
|
'dateAddedSecs': dateAddedSecs,
|
|
'dateModifiedSecs': dateModifiedSecs,
|
|
'sourceDateTakenMillis': sourceDateTakenMillis,
|
|
'durationMillis': durationMillis,
|
|
'trashed': trashed ? 1 : 0,
|
|
};
|
|
}
|
|
|
|
Map<String, dynamic> toPlatformEntryMap() {
|
|
return {
|
|
'uri': uri,
|
|
'path': path,
|
|
'pageId': pageId,
|
|
'mimeType': mimeType,
|
|
'width': width,
|
|
'height': height,
|
|
'rotationDegrees': rotationDegrees,
|
|
'isFlipped': isFlipped,
|
|
'dateModifiedSecs': dateModifiedSecs,
|
|
'sizeBytes': sizeBytes,
|
|
'trashed': trashed,
|
|
'trashPath': trashDetails?.path,
|
|
};
|
|
}
|
|
|
|
void dispose() {
|
|
visualChangeNotifier.dispose();
|
|
metadataChangeNotifier.dispose();
|
|
addressChangeNotifier.dispose();
|
|
}
|
|
|
|
// do not implement [Object.==] and [Object.hashCode] using mutable attributes (e.g. `uri`)
|
|
// so that we can reliably use instances in a `Set`, which requires consistent hash codes over time
|
|
|
|
@override
|
|
String toString() => '$runtimeType#${shortHash(this)}{id=$id, uri=$uri, path=$path, pageId=$pageId}';
|
|
|
|
set path(String? path) {
|
|
_path = path;
|
|
_directory = null;
|
|
_filename = null;
|
|
_extension = null;
|
|
_bestTitle = null;
|
|
}
|
|
|
|
String? get path => _path;
|
|
|
|
// directory path, without the trailing separator
|
|
String? get directory {
|
|
_directory ??= entryDirRepo.getOrCreate(path != null ? pContext.dirname(path!) : null);
|
|
return _directory!.resolved;
|
|
}
|
|
|
|
String? get filenameWithoutExtension {
|
|
_filename ??= path != null ? pContext.basenameWithoutExtension(path!) : null;
|
|
return _filename;
|
|
}
|
|
|
|
// file extension, including the `.`
|
|
String? get extension {
|
|
_extension ??= path != null ? pContext.extension(path!) : null;
|
|
return _extension;
|
|
}
|
|
|
|
String? get storagePath => trashed ? trashDetails?.path : path;
|
|
|
|
String? get storageDirectory => trashed ? pContext.dirname(trashDetails!.path) : directory;
|
|
|
|
bool get isMissingAtPath {
|
|
final _storagePath = storagePath;
|
|
return _storagePath != null && !File(_storagePath).existsSync();
|
|
}
|
|
|
|
// 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, MimeTypes.tiff].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 _supportedByBitmapRegionDecoder =>
|
|
[
|
|
MimeTypes.heic,
|
|
MimeTypes.heif,
|
|
MimeTypes.jpeg,
|
|
MimeTypes.png,
|
|
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 supportTiling => _supportedByBitmapRegionDecoder || mimeType == MimeTypes.tiff;
|
|
|
|
bool get useTiles => supportTiling && (width > 4096 || height > 4096);
|
|
|
|
bool get isRaw => MimeTypes.rawImages.contains(mimeType);
|
|
|
|
bool get isImage => MimeTypes.isImage(mimeType);
|
|
|
|
bool get isVideo => MimeTypes.isVideo(mimeType);
|
|
|
|
bool get isCatalogued => _catalogMetadata != null;
|
|
|
|
bool get isAnimated => _catalogMetadata?.isAnimated ?? false;
|
|
|
|
bool get isGeotiff => _catalogMetadata?.isGeotiff ?? false;
|
|
|
|
bool get is360 => _catalogMetadata?.is360 ?? false;
|
|
|
|
bool get isMediaStoreContent => uri.startsWith('content://media/');
|
|
|
|
bool get isMediaStoreMediaContent => isMediaStoreContent && {'/external/images/', '/external/video/'}.any(uri.contains);
|
|
|
|
bool get canEdit => path != null && !trashed && isMediaStoreContent;
|
|
|
|
bool get canEditDate => canEdit && (canEditExif || canEditXmp);
|
|
|
|
bool get canEditLocation => canEdit && (canEditExif || mimeType == MimeTypes.mp4);
|
|
|
|
bool get canEditTitleDescription => canEdit && canEditXmp;
|
|
|
|
bool get canEditRating => canEdit && canEditXmp;
|
|
|
|
bool get canEditTags => canEdit && canEditXmp;
|
|
|
|
bool get canRotate => canEdit && (canEditExif || mimeType == MimeTypes.mp4);
|
|
|
|
bool get canFlip => canEdit && canEditExif;
|
|
|
|
bool get canEditExif => MimeTypes.canEditExif(mimeType);
|
|
|
|
bool get canEditIptc => MimeTypes.canEditIptc(mimeType);
|
|
|
|
bool get canEditXmp => MimeTypes.canEditXmp(mimeType);
|
|
|
|
bool get canRemoveMetadata => MimeTypes.canRemoveMetadata(mimeType);
|
|
|
|
// Media Store size/rotation is inaccurate, e.g. a portrait FHD video is rotated according to its metadata,
|
|
// so it should be registered as width=1920, height=1080, orientation=90,
|
|
// but is incorrectly registered 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.
|
|
// Comparing width and height can help with the portrait FHD video example,
|
|
// but it fails for a portrait screenshot rotated, which is landscape with width=1080, height=1920, orientation=90
|
|
bool get isRotated => rotationDegrees % 180 == 90;
|
|
|
|
static const ratioSeparator = '\u2236';
|
|
static const resolutionSeparator = ' \u00D7 ';
|
|
|
|
bool get isSized => width > 0 && height > 0;
|
|
|
|
String get resolutionText {
|
|
final ws = width;
|
|
final hs = height;
|
|
return isRotated ? '$hs$resolutionSeparator$ws' : '$ws$resolutionSeparator$hs';
|
|
}
|
|
|
|
String get aspectRatioText {
|
|
if (width > 0 && height > 0) {
|
|
final gcd = width.gcd(height);
|
|
final w = width ~/ gcd;
|
|
final h = height ~/ gcd;
|
|
return isRotated ? '$h$ratioSeparator$w' : '$w$ratioSeparator$h';
|
|
} else {
|
|
return '?$ratioSeparator?';
|
|
}
|
|
}
|
|
|
|
double get displayAspectRatio {
|
|
if (width == 0 || height == 0) return 1;
|
|
return isRotated ? height / width : width / height;
|
|
}
|
|
|
|
Size get displaySize {
|
|
final w = width.toDouble();
|
|
final h = height.toDouble();
|
|
return isRotated ? Size(h, w) : Size(w, h);
|
|
}
|
|
|
|
Size videoDisplaySize(double sar) {
|
|
final size = displaySize;
|
|
if (sar != 1) {
|
|
final dar = displayAspectRatio * sar;
|
|
final w = size.width;
|
|
final h = size.height;
|
|
if (w >= h) return Size(w, w / dar);
|
|
if (h > w) return Size(h * dar, h);
|
|
}
|
|
return size;
|
|
}
|
|
|
|
int get megaPixels => (width * height / 1000000).round();
|
|
|
|
DateTime? _bestDate;
|
|
|
|
DateTime? get bestDate {
|
|
_bestDate ??= dateTimeFromMillis(_catalogDateMillis) ?? dateTimeFromMillis(sourceDateTakenMillis) ?? dateTimeFromMillis((dateModifiedSecs ?? 0) * 1000);
|
|
return _bestDate;
|
|
}
|
|
|
|
int get rating => _catalogMetadata?.rating ?? 0;
|
|
|
|
int get rotationDegrees => _catalogMetadata?.rotationDegrees ?? sourceRotationDegrees;
|
|
|
|
set rotationDegrees(int rotationDegrees) {
|
|
sourceRotationDegrees = rotationDegrees;
|
|
_catalogMetadata?.rotationDegrees = rotationDegrees;
|
|
}
|
|
|
|
bool get isFlipped => _catalogMetadata?.isFlipped ?? false;
|
|
|
|
set isFlipped(bool isFlipped) => _catalogMetadata?.isFlipped = isFlipped;
|
|
|
|
String? get sourceTitle => _sourceTitle;
|
|
|
|
set sourceTitle(String? sourceTitle) {
|
|
_sourceTitle = sourceTitle;
|
|
_bestTitle = null;
|
|
}
|
|
|
|
int? get dateModifiedSecs => _dateModifiedSecs;
|
|
|
|
set dateModifiedSecs(int? dateModifiedSecs) {
|
|
_dateModifiedSecs = dateModifiedSecs;
|
|
_bestDate = null;
|
|
}
|
|
|
|
// TODO TLAD cache _monthTaken
|
|
DateTime? get monthTaken {
|
|
final d = bestDate;
|
|
return d == null ? null : DateTime(d.year, d.month);
|
|
}
|
|
|
|
// TODO TLAD cache _dayTaken
|
|
DateTime? get dayTaken {
|
|
final d = bestDate;
|
|
return d == null ? null : DateTime(d.year, d.month, d.day);
|
|
}
|
|
|
|
int? get durationMillis => _durationMillis;
|
|
|
|
set durationMillis(int? durationMillis) {
|
|
_durationMillis = durationMillis;
|
|
_durationText = null;
|
|
}
|
|
|
|
String? _durationText;
|
|
|
|
String get durationText {
|
|
_durationText ??= formatFriendlyDuration(Duration(milliseconds: durationMillis ?? 0));
|
|
return _durationText!;
|
|
}
|
|
|
|
bool get isExpiredTrash {
|
|
final dateMillis = trashDetails?.dateMillis;
|
|
if (dateMillis == null) return false;
|
|
return DateTime.fromMillisecondsSinceEpoch(dateMillis).add(TrashMixin.binKeepDuration).isBefore(DateTime.now());
|
|
}
|
|
|
|
int? get trashDaysLeft {
|
|
final dateMillis = trashDetails?.dateMillis;
|
|
if (dateMillis == null) return null;
|
|
return DateTime.fromMillisecondsSinceEpoch(dateMillis).add(TrashMixin.binKeepDuration).difference(DateTime.now()).inDays;
|
|
}
|
|
|
|
// returns whether this entry has GPS coordinates
|
|
// (0, 0) coordinates are considered invalid, as it is likely a default value
|
|
bool get hasGps => (_catalogMetadata?.latitude ?? 0) != 0 || (_catalogMetadata?.longitude ?? 0) != 0;
|
|
|
|
bool get hasAddress => _addressDetails != null;
|
|
|
|
// has a place, or at least the full country name
|
|
// derived from Google reverse geocoding addresses
|
|
bool get hasFineAddress => _addressDetails?.place?.isNotEmpty == true || (_addressDetails?.countryName?.length ?? 0) > 3;
|
|
|
|
LatLng? get latLng => hasGps ? LatLng(_catalogMetadata!.latitude!, _catalogMetadata!.longitude!) : null;
|
|
|
|
Set<String>? _tags;
|
|
|
|
Set<String> get tags {
|
|
_tags ??= _catalogMetadata?.xmpSubjects?.split(';').where((tag) => tag.isNotEmpty).toSet() ?? {};
|
|
return _tags!;
|
|
}
|
|
|
|
String? _bestTitle;
|
|
|
|
String? get bestTitle {
|
|
_bestTitle ??= _catalogMetadata?.xmpTitle?.isNotEmpty == true ? _catalogMetadata!.xmpTitle : (filenameWithoutExtension ?? sourceTitle);
|
|
return _bestTitle;
|
|
}
|
|
|
|
int? get catalogDateMillis => _catalogDateMillis;
|
|
|
|
set catalogDateMillis(int? dateMillis) {
|
|
_catalogDateMillis = dateMillis;
|
|
_bestDate = null;
|
|
}
|
|
|
|
CatalogMetadata? get catalogMetadata => _catalogMetadata;
|
|
|
|
set catalogMetadata(CatalogMetadata? newMetadata) {
|
|
final oldMimeType = mimeType;
|
|
final oldDateModifiedSecs = dateModifiedSecs;
|
|
final oldRotationDegrees = rotationDegrees;
|
|
final oldIsFlipped = isFlipped;
|
|
|
|
catalogDateMillis = newMetadata?.dateMillis;
|
|
_catalogMetadata = newMetadata;
|
|
_bestTitle = null;
|
|
_tags = null;
|
|
metadataChangeNotifier.notify();
|
|
|
|
_onVisualFieldChanged(oldMimeType, oldDateModifiedSecs, oldRotationDegrees, oldIsFlipped);
|
|
}
|
|
|
|
void clearMetadata() {
|
|
catalogMetadata = null;
|
|
addressDetails = null;
|
|
}
|
|
|
|
Future<void> catalog({required bool background, required bool force, required bool persist}) async {
|
|
if (isCatalogued && !force) return;
|
|
if (isSvg) {
|
|
// vector image sizing is not essential, so we should not spend time for it during loading
|
|
// but it is useful anyway (for aspect ratios etc.) so we size them during cataloguing
|
|
final size = await SvgMetadataService.getSize(this);
|
|
if (size != null) {
|
|
final fields = {
|
|
'width': size.width.ceil(),
|
|
'height': size.height.ceil(),
|
|
};
|
|
await applyNewFields(fields, persist: persist);
|
|
}
|
|
catalogMetadata = CatalogMetadata(id: id);
|
|
} else {
|
|
// pre-processing
|
|
if (isVideo && (!isSized || durationMillis == 0)) {
|
|
// exotic video that is not sized during loading
|
|
final fields = await VideoMetadataFormatter.getLoadingMetadata(this);
|
|
await applyNewFields(fields, persist: persist);
|
|
}
|
|
|
|
// cataloguing on platform
|
|
catalogMetadata = await metadataFetchService.getCatalogMetadata(this, background: background);
|
|
|
|
// post-processing
|
|
if (isVideo && (catalogMetadata?.dateMillis ?? 0) == 0) {
|
|
catalogMetadata = await VideoMetadataFormatter.getCatalogMetadata(this);
|
|
}
|
|
if (isGeotiff && !hasGps) {
|
|
final info = await metadataFetchService.getGeoTiffInfo(this);
|
|
if (info != null) {
|
|
final center = MappedGeoTiff(
|
|
info: info,
|
|
entry: this,
|
|
).center;
|
|
if (center != null) {
|
|
catalogMetadata = catalogMetadata?.copyWith(
|
|
latitude: center.latitude,
|
|
longitude: center.longitude,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
AddressDetails? get addressDetails => _addressDetails;
|
|
|
|
set addressDetails(AddressDetails? newAddress) {
|
|
_addressDetails = newAddress;
|
|
addressChangeNotifier.notify();
|
|
}
|
|
|
|
Future<void> locate({required bool background, required bool force, required Locale geocoderLocale}) async {
|
|
if (hasGps) {
|
|
await _locateCountry(force: force);
|
|
if (await availability.canLocatePlaces) {
|
|
await locatePlace(background: background, force: force, geocoderLocale: geocoderLocale);
|
|
}
|
|
} else {
|
|
addressDetails = null;
|
|
}
|
|
}
|
|
|
|
// quick reverse geocoding to find the country, using an offline asset
|
|
Future<void> _locateCountry({required bool force}) async {
|
|
if (!hasGps || (hasAddress && !force)) return;
|
|
final countryCode = await countryTopology.countryCode(latLng!);
|
|
setCountry(countryCode);
|
|
}
|
|
|
|
void setCountry(CountryCode? countryCode) {
|
|
if (hasFineAddress || countryCode == null) return;
|
|
addressDetails = AddressDetails(
|
|
id: id,
|
|
countryCode: countryCode.alpha2,
|
|
countryName: countryCode.alpha3,
|
|
);
|
|
}
|
|
|
|
// full reverse geocoding, requiring Play Services and some connectivity
|
|
Future<void> locatePlace({required bool background, required bool force, required Locale geocoderLocale}) async {
|
|
if (!hasGps || (hasFineAddress && !force)) return;
|
|
try {
|
|
Future<List<Address>> call() => GeocodingService.getAddress(latLng!, geocoderLocale);
|
|
final addresses = await (background
|
|
? servicePolicy.call(
|
|
call,
|
|
priority: ServiceCallPriority.getLocation,
|
|
)
|
|
: call());
|
|
if (addresses.isNotEmpty) {
|
|
final address = addresses.first;
|
|
final cc = address.countryCode?.toUpperCase();
|
|
final cn = address.countryName;
|
|
final aa = address.adminArea;
|
|
addressDetails = AddressDetails(
|
|
id: id,
|
|
countryCode: cc,
|
|
countryName: cn,
|
|
adminArea: aa,
|
|
// if country & admin fields are null, it is likely the ocean,
|
|
// which is identified by `featureName` but we default to the address line anyway
|
|
locality: address.locality ?? (cc == null && cn == null && aa == null ? address.addressLine : null),
|
|
);
|
|
}
|
|
} catch (error, stack) {
|
|
debugPrint('$runtimeType locate failed with path=$path coordinates=$latLng error=$error\n$stack');
|
|
}
|
|
}
|
|
|
|
Future<String?> findAddressLine({required Locale geocoderLocale}) async {
|
|
if (!hasGps) return null;
|
|
|
|
try {
|
|
final addresses = await GeocodingService.getAddress(latLng!, geocoderLocale);
|
|
if (addresses.isNotEmpty) {
|
|
final address = addresses.first;
|
|
return address.addressLine;
|
|
}
|
|
} catch (error, stack) {
|
|
debugPrint('$runtimeType findAddressLine failed with path=$path coordinates=$latLng error=$error\n$stack');
|
|
}
|
|
return null;
|
|
}
|
|
|
|
String get shortAddress {
|
|
// `admin area` examples: Seoul, Geneva, null
|
|
// `locality` examples: Mapo-gu, Geneva, Annecy
|
|
return {
|
|
_addressDetails?.countryName,
|
|
_addressDetails?.adminArea,
|
|
_addressDetails?.locality,
|
|
}.whereNotNull().where((v) => v.isNotEmpty).join(', ');
|
|
}
|
|
|
|
Future<void> applyNewFields(Map newFields, {required bool persist}) async {
|
|
final oldMimeType = mimeType;
|
|
final oldDateModifiedSecs = this.dateModifiedSecs;
|
|
final oldRotationDegrees = this.rotationDegrees;
|
|
final oldIsFlipped = this.isFlipped;
|
|
|
|
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;
|
|
final sourceRotationDegrees = newFields['sourceRotationDegrees'];
|
|
if (sourceRotationDegrees is int) this.sourceRotationDegrees = sourceRotationDegrees;
|
|
final sourceDateTakenMillis = newFields['sourceDateTakenMillis'];
|
|
if (sourceDateTakenMillis is int) this.sourceDateTakenMillis = sourceDateTakenMillis;
|
|
|
|
final width = newFields['width'];
|
|
if (width is int) this.width = width;
|
|
final height = newFields['height'];
|
|
if (height is int) this.height = height;
|
|
final durationMillis = newFields['durationMillis'];
|
|
if (durationMillis is int) this.durationMillis = durationMillis;
|
|
|
|
final sizeBytes = newFields['sizeBytes'];
|
|
if (sizeBytes is int) this.sizeBytes = sizeBytes;
|
|
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;
|
|
|
|
if (persist) {
|
|
await metadataDb.saveEntries({this});
|
|
if (catalogMetadata != null) await metadataDb.saveCatalogMetadata({catalogMetadata!});
|
|
}
|
|
|
|
await _onVisualFieldChanged(oldMimeType, oldDateModifiedSecs, oldRotationDegrees, oldIsFlipped);
|
|
metadataChangeNotifier.notify();
|
|
}
|
|
|
|
Future<void> refresh({
|
|
required bool background,
|
|
required bool persist,
|
|
required Set<EntryDataType> dataTypes,
|
|
required Locale geocoderLocale,
|
|
}) async {
|
|
// clear derived fields
|
|
_bestDate = null;
|
|
_bestTitle = null;
|
|
_tags = null;
|
|
|
|
if (persist) {
|
|
await metadataDb.removeIds({id}, dataTypes: dataTypes);
|
|
}
|
|
|
|
final updatedEntry = await mediaFetchService.getEntry(uri, mimeType);
|
|
if (updatedEntry != null) {
|
|
await applyNewFields(updatedEntry.toMap(), persist: persist);
|
|
}
|
|
await catalog(background: background, force: dataTypes.contains(EntryDataType.catalog), persist: persist);
|
|
await locate(background: background, force: dataTypes.contains(EntryDataType.address), geocoderLocale: geocoderLocale);
|
|
}
|
|
|
|
Future<bool> delete() {
|
|
final completer = Completer<bool>();
|
|
mediaEditService.delete(entries: {this}).listen(
|
|
(event) => completer.complete(event.success && !event.skipped),
|
|
onError: completer.completeError,
|
|
onDone: () {
|
|
if (!completer.isCompleted) {
|
|
completer.complete(false);
|
|
}
|
|
},
|
|
);
|
|
return completer.future;
|
|
}
|
|
|
|
// when the MIME type or the image itself changed (e.g. after rotation)
|
|
Future<void> _onVisualFieldChanged(
|
|
String oldMimeType,
|
|
int? oldDateModifiedSecs,
|
|
int oldRotationDegrees,
|
|
bool oldIsFlipped,
|
|
) async {
|
|
if ((!MimeTypes.refersToSameType(oldMimeType, mimeType) && !MimeTypes.isVideo(oldMimeType)) || oldDateModifiedSecs != dateModifiedSecs || oldRotationDegrees != rotationDegrees || oldIsFlipped != isFlipped) {
|
|
await EntryCache.evict(uri, oldMimeType, oldDateModifiedSecs, oldRotationDegrees, oldIsFlipped);
|
|
visualChangeNotifier.notify();
|
|
}
|
|
}
|
|
|
|
// favourites
|
|
|
|
Future<void> toggleFavourite() async {
|
|
if (isFavourite) {
|
|
await removeFromFavourites();
|
|
} else {
|
|
await addToFavourites();
|
|
}
|
|
}
|
|
|
|
Future<void> addToFavourites() async {
|
|
if (!isFavourite) {
|
|
await favourites.add({this});
|
|
}
|
|
}
|
|
|
|
Future<void> removeFromFavourites() async {
|
|
if (isFavourite) {
|
|
await favourites.removeEntries({this});
|
|
}
|
|
}
|
|
|
|
// multipage
|
|
|
|
static final _burstFilenamePattern = RegExp(r'^(\d{8}_\d{6})_(\d+)$');
|
|
|
|
bool get isMultiPage => (_catalogMetadata?.isMultiPage ?? false) || isBurst;
|
|
|
|
bool get isBurst => burstEntries?.isNotEmpty == true;
|
|
|
|
// for backwards compatibility
|
|
bool get _isMotionPhotoLegacy => isMultiPage && !isBurst && mimeType == MimeTypes.jpeg;
|
|
|
|
bool get isMotionPhoto => (_catalogMetadata?.isMotionPhoto ?? false) || _isMotionPhotoLegacy;
|
|
|
|
String? get burstKey {
|
|
if (filenameWithoutExtension != null) {
|
|
final match = _burstFilenamePattern.firstMatch(filenameWithoutExtension!);
|
|
if (match != null) {
|
|
return '$directory/${match.group(1)}';
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
Future<MultiPageInfo?> getMultiPageInfo() async {
|
|
if (isBurst) {
|
|
return MultiPageInfo(
|
|
mainEntry: this,
|
|
pages: burstEntries!
|
|
.mapIndexed((index, entry) => SinglePageInfo(
|
|
index: index,
|
|
pageId: entry.id,
|
|
isDefault: index == 0,
|
|
uri: entry.uri,
|
|
mimeType: entry.mimeType,
|
|
width: entry.width,
|
|
height: entry.height,
|
|
rotationDegrees: entry.rotationDegrees,
|
|
durationMillis: entry.durationMillis,
|
|
))
|
|
.toList(),
|
|
);
|
|
} else {
|
|
return await metadataFetchService.getMultiPageInfo(this);
|
|
}
|
|
}
|
|
|
|
// sort
|
|
|
|
// compare by:
|
|
// 1) title ascending
|
|
// 2) extension ascending
|
|
static int compareByName(AvesEntry a, AvesEntry b) {
|
|
final c = compareAsciiUpperCaseNatural(a.bestTitle ?? '', b.bestTitle ?? '');
|
|
return c != 0 ? c : compareAsciiUpperCase(a.extension ?? '', b.extension ?? '');
|
|
}
|
|
|
|
// compare by:
|
|
// 1) date descending
|
|
// 2) name descending
|
|
static int compareByDate(AvesEntry a, AvesEntry b) {
|
|
var c = (b.bestDate ?? epoch).compareTo(a.bestDate ?? epoch);
|
|
if (c != 0) return c;
|
|
return compareByName(b, a);
|
|
}
|
|
|
|
// compare by:
|
|
// 1) rating descending
|
|
// 2) date descending
|
|
static int compareByRating(AvesEntry a, AvesEntry b) {
|
|
final c = b.rating.compareTo(a.rating);
|
|
return c != 0 ? c : compareByDate(a, b);
|
|
}
|
|
|
|
// compare by:
|
|
// 1) size descending
|
|
// 2) date descending
|
|
static int compareBySize(AvesEntry a, AvesEntry b) {
|
|
final c = (b.sizeBytes ?? 0).compareTo(a.sizeBytes ?? 0);
|
|
return c != 0 ? c : compareByDate(a, b);
|
|
}
|
|
}
|