diff --git a/mobile/lib/main.dart b/mobile/lib/main.dart index 3ad2f4b629356..13eda9d6eac51 100644 --- a/mobile/lib/main.dart +++ b/mobile/lib/main.dart @@ -7,6 +7,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_displaymode/flutter_displaymode.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:timezone/data/latest.dart'; import 'package:immich_mobile/constants/locales.dart'; import 'package:immich_mobile/modules/backup/background_service/background.service.dart'; import 'package:immich_mobile/modules/backup/models/backup_album.model.dart'; @@ -77,6 +78,8 @@ Future initApp() async { log.severe('Catch all error: ${error.toString()} - $error', error, stack); return true; }; + + initializeTimeZones(); } Future loadDb() async { diff --git a/mobile/lib/modules/asset_viewer/ui/exif_bottom_sheet.dart b/mobile/lib/modules/asset_viewer/ui/exif_bottom_sheet.dart index df1c8ba6fb74f..f194738a2cd5a 100644 --- a/mobile/lib/modules/asset_viewer/ui/exif_bottom_sheet.dart +++ b/mobile/lib/modules/asset_viewer/ui/exif_bottom_sheet.dart @@ -4,6 +4,7 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter_map/flutter_map.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:timezone/timezone.dart'; import 'package:immich_mobile/modules/asset_viewer/ui/description_input.dart'; import 'package:immich_mobile/modules/map/ui/map_thumbnail.dart'; import 'package:immich_mobile/shared/models/asset.dart'; @@ -26,12 +27,36 @@ class ExifBottomSheet extends HookConsumerWidget { exifInfo.latitude != 0 && exifInfo.longitude != 0; - String get formattedDateTime { - final fileCreatedAt = asset.fileCreatedAt.toLocal(); - final date = DateFormat.yMMMEd().format(fileCreatedAt); - final time = DateFormat.jm().format(fileCreatedAt); + String formatTimeZone(Duration d) => + "GMT${d.isNegative ? '-': '+'}${d.inHours.abs().toString().padLeft(2, '0')}:${d.inMinutes.abs().remainder(60).toString().padLeft(2, '0')}"; - return '$date • $time'; + String get formattedDateTime { + DateTime dt = asset.fileCreatedAt.toLocal(); + String? timeZone; + if (asset.exifInfo?.dateTimeOriginal != null) { + dt = asset.exifInfo!.dateTimeOriginal!; + if (asset.exifInfo?.timeZone != null) { + dt = dt.toUtc(); + try { + final location = getLocation(asset.exifInfo!.timeZone!); + dt = TZDateTime.from(dt, location); + } on LocationNotFoundException { + RegExp re = RegExp(r'^utc(?:([+-]\d{1,2})(?::(\d{2}))?)?$', caseSensitive: false); + final m = re.firstMatch(asset.exifInfo!.timeZone!); + if (m != null) { + final duration = Duration(hours: int.parse(m.group(1) ?? '0'), minutes: int.parse(m.group(2) ?? '0')); + dt = dt.add(duration); + timeZone = formatTimeZone(duration); + } + } + } + } + + final date = DateFormat.yMMMEd().format(dt); + final time = DateFormat.jm().format(dt); + timeZone ??= formatTimeZone(dt.timeZoneOffset); + + return '$date • $time $timeZone'; } Future _createCoordinatesUri(ExifInfo? exifInfo) async { diff --git a/mobile/lib/shared/models/exif_info.dart b/mobile/lib/shared/models/exif_info.dart index 568e4ce13a79f..a61fd2c289855 100644 --- a/mobile/lib/shared/models/exif_info.dart +++ b/mobile/lib/shared/models/exif_info.dart @@ -8,6 +8,8 @@ part 'exif_info.g.dart'; class ExifInfo { Id? id; int? fileSize; + DateTime? dateTimeOriginal; + String? timeZone; String? make; String? model; String? lens; @@ -47,6 +49,8 @@ class ExifInfo { ExifInfo.fromDto(ExifResponseDto dto) : fileSize = dto.fileSizeInByte, + dateTimeOriginal = dto.dateTimeOriginal, + timeZone = dto.timeZone, make = dto.make, model = dto.model, lens = dto.lensModel, @@ -64,6 +68,8 @@ class ExifInfo { ExifInfo({ this.id, this.fileSize, + this.dateTimeOriginal, + this.timeZone, this.make, this.model, this.lens, @@ -82,6 +88,8 @@ class ExifInfo { ExifInfo copyWith({ Id? id, int? fileSize, + DateTime? dateTimeOriginal, + String? timeZone, String? make, String? model, String? lens, @@ -99,6 +107,8 @@ class ExifInfo { ExifInfo( id: id ?? this.id, fileSize: fileSize ?? this.fileSize, + dateTimeOriginal: dateTimeOriginal ?? this.dateTimeOriginal, + timeZone: timeZone ?? this.timeZone, make: make ?? this.make, model: model ?? this.model, lens: lens ?? this.lens, @@ -119,6 +129,8 @@ class ExifInfo { if (other is! ExifInfo) return false; return id == other.id && fileSize == other.fileSize && + dateTimeOriginal == other.dateTimeOriginal && + timeZone == other.timeZone && make == other.make && model == other.model && lens == other.lens && @@ -139,6 +151,8 @@ class ExifInfo { int get hashCode => id.hashCode ^ fileSize.hashCode ^ + dateTimeOriginal.hashCode ^ + timeZone.hashCode ^ make.hashCode ^ model.hashCode ^ lens.hashCode ^ diff --git a/mobile/lib/shared/models/exif_info.g.dart b/mobile/lib/shared/models/exif_info.g.dart index 9122942bd983d..138e386c79286 100644 --- a/mobile/lib/shared/models/exif_info.g.dart +++ b/mobile/lib/shared/models/exif_info.g.dart @@ -27,65 +27,75 @@ const ExifInfoSchema = CollectionSchema( name: r'country', type: IsarType.string, ), - r'description': PropertySchema( + r'dateTimeOriginal': PropertySchema( id: 2, + name: r'dateTimeOriginal', + type: IsarType.dateTime, + ), + r'description': PropertySchema( + id: 3, name: r'description', type: IsarType.string, ), r'exposureSeconds': PropertySchema( - id: 3, + id: 4, name: r'exposureSeconds', type: IsarType.float, ), r'f': PropertySchema( - id: 4, + id: 5, name: r'f', type: IsarType.float, ), r'fileSize': PropertySchema( - id: 5, + id: 6, name: r'fileSize', type: IsarType.long, ), r'iso': PropertySchema( - id: 6, + id: 7, name: r'iso', type: IsarType.int, ), r'lat': PropertySchema( - id: 7, + id: 8, name: r'lat', type: IsarType.float, ), r'lens': PropertySchema( - id: 8, + id: 9, name: r'lens', type: IsarType.string, ), r'long': PropertySchema( - id: 9, + id: 10, name: r'long', type: IsarType.float, ), r'make': PropertySchema( - id: 10, + id: 11, name: r'make', type: IsarType.string, ), r'mm': PropertySchema( - id: 11, + id: 12, name: r'mm', type: IsarType.float, ), r'model': PropertySchema( - id: 12, + id: 13, name: r'model', type: IsarType.string, ), r'state': PropertySchema( - id: 13, + id: 14, name: r'state', type: IsarType.string, + ), + r'timeZone': PropertySchema( + id: 15, + name: r'timeZone', + type: IsarType.string, ) }, estimateSize: _exifInfoEstimateSize, @@ -150,6 +160,12 @@ int _exifInfoEstimateSize( bytesCount += 3 + value.length * 3; } } + { + final value = object.timeZone; + if (value != null) { + bytesCount += 3 + value.length * 3; + } + } return bytesCount; } @@ -161,18 +177,20 @@ void _exifInfoSerialize( ) { writer.writeString(offsets[0], object.city); writer.writeString(offsets[1], object.country); - writer.writeString(offsets[2], object.description); - writer.writeFloat(offsets[3], object.exposureSeconds); - writer.writeFloat(offsets[4], object.f); - writer.writeLong(offsets[5], object.fileSize); - writer.writeInt(offsets[6], object.iso); - writer.writeFloat(offsets[7], object.lat); - writer.writeString(offsets[8], object.lens); - writer.writeFloat(offsets[9], object.long); - writer.writeString(offsets[10], object.make); - writer.writeFloat(offsets[11], object.mm); - writer.writeString(offsets[12], object.model); - writer.writeString(offsets[13], object.state); + writer.writeDateTime(offsets[2], object.dateTimeOriginal); + writer.writeString(offsets[3], object.description); + writer.writeFloat(offsets[4], object.exposureSeconds); + writer.writeFloat(offsets[5], object.f); + writer.writeLong(offsets[6], object.fileSize); + writer.writeInt(offsets[7], object.iso); + writer.writeFloat(offsets[8], object.lat); + writer.writeString(offsets[9], object.lens); + writer.writeFloat(offsets[10], object.long); + writer.writeString(offsets[11], object.make); + writer.writeFloat(offsets[12], object.mm); + writer.writeString(offsets[13], object.model); + writer.writeString(offsets[14], object.state); + writer.writeString(offsets[15], object.timeZone); } ExifInfo _exifInfoDeserialize( @@ -184,19 +202,21 @@ ExifInfo _exifInfoDeserialize( final object = ExifInfo( city: reader.readStringOrNull(offsets[0]), country: reader.readStringOrNull(offsets[1]), - description: reader.readStringOrNull(offsets[2]), - exposureSeconds: reader.readFloatOrNull(offsets[3]), - f: reader.readFloatOrNull(offsets[4]), - fileSize: reader.readLongOrNull(offsets[5]), + dateTimeOriginal: reader.readDateTimeOrNull(offsets[2]), + description: reader.readStringOrNull(offsets[3]), + exposureSeconds: reader.readFloatOrNull(offsets[4]), + f: reader.readFloatOrNull(offsets[5]), + fileSize: reader.readLongOrNull(offsets[6]), id: id, - iso: reader.readIntOrNull(offsets[6]), - lat: reader.readFloatOrNull(offsets[7]), - lens: reader.readStringOrNull(offsets[8]), - long: reader.readFloatOrNull(offsets[9]), - make: reader.readStringOrNull(offsets[10]), - mm: reader.readFloatOrNull(offsets[11]), - model: reader.readStringOrNull(offsets[12]), - state: reader.readStringOrNull(offsets[13]), + iso: reader.readIntOrNull(offsets[7]), + lat: reader.readFloatOrNull(offsets[8]), + lens: reader.readStringOrNull(offsets[9]), + long: reader.readFloatOrNull(offsets[10]), + make: reader.readStringOrNull(offsets[11]), + mm: reader.readFloatOrNull(offsets[12]), + model: reader.readStringOrNull(offsets[13]), + state: reader.readStringOrNull(offsets[14]), + timeZone: reader.readStringOrNull(offsets[15]), ); return object; } @@ -213,29 +233,33 @@ P _exifInfoDeserializeProp

( case 1: return (reader.readStringOrNull(offset)) as P; case 2: - return (reader.readStringOrNull(offset)) as P; + return (reader.readDateTimeOrNull(offset)) as P; case 3: - return (reader.readFloatOrNull(offset)) as P; + return (reader.readStringOrNull(offset)) as P; case 4: return (reader.readFloatOrNull(offset)) as P; case 5: - return (reader.readLongOrNull(offset)) as P; + return (reader.readFloatOrNull(offset)) as P; case 6: - return (reader.readIntOrNull(offset)) as P; + return (reader.readLongOrNull(offset)) as P; case 7: - return (reader.readFloatOrNull(offset)) as P; + return (reader.readIntOrNull(offset)) as P; case 8: - return (reader.readStringOrNull(offset)) as P; + return (reader.readFloatOrNull(offset)) as P; case 9: - return (reader.readFloatOrNull(offset)) as P; + return (reader.readStringOrNull(offset)) as P; case 10: - return (reader.readStringOrNull(offset)) as P; - case 11: return (reader.readFloatOrNull(offset)) as P; - case 12: + case 11: return (reader.readStringOrNull(offset)) as P; + case 12: + return (reader.readFloatOrNull(offset)) as P; case 13: return (reader.readStringOrNull(offset)) as P; + case 14: + return (reader.readStringOrNull(offset)) as P; + case 15: + return (reader.readStringOrNull(offset)) as P; default: throw IsarError('Unknown property with id $propertyId'); } @@ -622,6 +646,80 @@ extension ExifInfoQueryFilter }); } + QueryBuilder + dateTimeOriginalIsNull() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(const FilterCondition.isNull( + property: r'dateTimeOriginal', + )); + }); + } + + QueryBuilder + dateTimeOriginalIsNotNull() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(const FilterCondition.isNotNull( + property: r'dateTimeOriginal', + )); + }); + } + + QueryBuilder + dateTimeOriginalEqualTo(DateTime? value) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.equalTo( + property: r'dateTimeOriginal', + value: value, + )); + }); + } + + QueryBuilder + dateTimeOriginalGreaterThan( + DateTime? value, { + bool include = false, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.greaterThan( + include: include, + property: r'dateTimeOriginal', + value: value, + )); + }); + } + + QueryBuilder + dateTimeOriginalLessThan( + DateTime? value, { + bool include = false, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.lessThan( + include: include, + property: r'dateTimeOriginal', + value: value, + )); + }); + } + + QueryBuilder + dateTimeOriginalBetween( + DateTime? lower, + DateTime? upper, { + bool includeLower = true, + bool includeUpper = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.between( + property: r'dateTimeOriginal', + lower: lower, + includeLower: includeLower, + upper: upper, + includeUpper: includeUpper, + )); + }); + } + QueryBuilder descriptionIsNull() { return QueryBuilder.apply(this, (query) { return query.addFilterCondition(const FilterCondition.isNull( @@ -1956,6 +2054,152 @@ extension ExifInfoQueryFilter )); }); } + + QueryBuilder timeZoneIsNull() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(const FilterCondition.isNull( + property: r'timeZone', + )); + }); + } + + QueryBuilder timeZoneIsNotNull() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(const FilterCondition.isNotNull( + property: r'timeZone', + )); + }); + } + + QueryBuilder timeZoneEqualTo( + String? value, { + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.equalTo( + property: r'timeZone', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder timeZoneGreaterThan( + String? value, { + bool include = false, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.greaterThan( + include: include, + property: r'timeZone', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder timeZoneLessThan( + String? value, { + bool include = false, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.lessThan( + include: include, + property: r'timeZone', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder timeZoneBetween( + String? lower, + String? upper, { + bool includeLower = true, + bool includeUpper = true, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.between( + property: r'timeZone', + lower: lower, + includeLower: includeLower, + upper: upper, + includeUpper: includeUpper, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder timeZoneStartsWith( + String value, { + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.startsWith( + property: r'timeZone', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder timeZoneEndsWith( + String value, { + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.endsWith( + property: r'timeZone', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder timeZoneContains( + String value, + {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.contains( + property: r'timeZone', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder timeZoneMatches( + String pattern, + {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.matches( + property: r'timeZone', + wildcard: pattern, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder timeZoneIsEmpty() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.equalTo( + property: r'timeZone', + value: '', + )); + }); + } + + QueryBuilder timeZoneIsNotEmpty() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.greaterThan( + property: r'timeZone', + value: '', + )); + }); + } } extension ExifInfoQueryObject @@ -1989,6 +2233,18 @@ extension ExifInfoQuerySortBy on QueryBuilder { }); } + QueryBuilder sortByDateTimeOriginal() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'dateTimeOriginal', Sort.asc); + }); + } + + QueryBuilder sortByDateTimeOriginalDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'dateTimeOriginal', Sort.desc); + }); + } + QueryBuilder sortByDescription() { return QueryBuilder.apply(this, (query) { return query.addSortBy(r'description', Sort.asc); @@ -2132,6 +2388,18 @@ extension ExifInfoQuerySortBy on QueryBuilder { return query.addSortBy(r'state', Sort.desc); }); } + + QueryBuilder sortByTimeZone() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'timeZone', Sort.asc); + }); + } + + QueryBuilder sortByTimeZoneDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'timeZone', Sort.desc); + }); + } } extension ExifInfoQuerySortThenBy @@ -2160,6 +2428,18 @@ extension ExifInfoQuerySortThenBy }); } + QueryBuilder thenByDateTimeOriginal() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'dateTimeOriginal', Sort.asc); + }); + } + + QueryBuilder thenByDateTimeOriginalDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'dateTimeOriginal', Sort.desc); + }); + } + QueryBuilder thenByDescription() { return QueryBuilder.apply(this, (query) { return query.addSortBy(r'description', Sort.asc); @@ -2315,6 +2595,18 @@ extension ExifInfoQuerySortThenBy return query.addSortBy(r'state', Sort.desc); }); } + + QueryBuilder thenByTimeZone() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'timeZone', Sort.asc); + }); + } + + QueryBuilder thenByTimeZoneDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'timeZone', Sort.desc); + }); + } } extension ExifInfoQueryWhereDistinct @@ -2333,6 +2625,12 @@ extension ExifInfoQueryWhereDistinct }); } + QueryBuilder distinctByDateTimeOriginal() { + return QueryBuilder.apply(this, (query) { + return query.addDistinctBy(r'dateTimeOriginal'); + }); + } + QueryBuilder distinctByDescription( {bool caseSensitive = true}) { return QueryBuilder.apply(this, (query) { @@ -2409,6 +2707,13 @@ extension ExifInfoQueryWhereDistinct return query.addDistinctBy(r'state', caseSensitive: caseSensitive); }); } + + QueryBuilder distinctByTimeZone( + {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addDistinctBy(r'timeZone', caseSensitive: caseSensitive); + }); + } } extension ExifInfoQueryProperty @@ -2431,6 +2736,13 @@ extension ExifInfoQueryProperty }); } + QueryBuilder + dateTimeOriginalProperty() { + return QueryBuilder.apply(this, (query) { + return query.addPropertyName(r'dateTimeOriginal'); + }); + } + QueryBuilder descriptionProperty() { return QueryBuilder.apply(this, (query) { return query.addPropertyName(r'description'); @@ -2502,4 +2814,10 @@ extension ExifInfoQueryProperty return query.addPropertyName(r'state'); }); } + + QueryBuilder timeZoneProperty() { + return QueryBuilder.apply(this, (query) { + return query.addPropertyName(r'timeZone'); + }); + } } diff --git a/mobile/pubspec.lock b/mobile/pubspec.lock index 73bfd11b08bd4..3fc34f62e1293 100644 --- a/mobile/pubspec.lock +++ b/mobile/pubspec.lock @@ -1401,7 +1401,7 @@ packages: source: hosted version: "2.1.3" timezone: - dependency: transitive + dependency: "direct main" description: name: timezone sha256: "1cfd8ddc2d1cfd836bc93e67b9be88c3adaeca6f40a00ca999104c30693cdca0" diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index 63c6f312fb5c1..6d5aa735d6118 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -52,6 +52,7 @@ dependencies: crypto: ^3.0.3 # TODO remove once native crypto is used on iOS wakelock_plus: ^1.1.1 flutter_local_notifications: ^15.1.0+1 + timezone: ^0.9.2 openapi: path: openapi