feat: Add EXIF data handling for photo uploads, including parsing and normalization, and update gallery creation to include metadata option

+62 -1
lib/api.dart
··· 23 import 'models/profile.dart'; 24 25 class ApiService { 26 - // ...existing code... 27 static const _storage = FlutterSecureStorage(); 28 String? _accessToken; 29 Profile? currentUser; ··· 861 } 862 appLogger.i('Gallery updated successfully'); 863 return true; 864 } 865 } 866
··· 23 import 'models/profile.dart'; 24 25 class ApiService { 26 static const _storage = FlutterSecureStorage(); 27 String? _accessToken; 28 Profile? currentUser; ··· 860 } 861 appLogger.i('Gallery updated successfully'); 862 return true; 863 + } 864 + 865 + /// Creates a photo EXIF record in the social.grain.photo.exif collection. 866 + /// Returns the record URI on success, or null on failure. 867 + Future<String?> createPhotoExif({ 868 + required String photo, 869 + String? createdAt, 870 + String? dateTimeOriginal, 871 + int? exposureTime, 872 + int? fNumber, 873 + String? flash, 874 + int? focalLengthIn35mmFormat, 875 + int? iSO, 876 + String? lensMake, 877 + String? lensModel, 878 + String? make, 879 + String? model, 880 + }) async { 881 + final session = await auth.getValidSession(); 882 + if (session == null) { 883 + appLogger.w('No valid session for createPhotoExif'); 884 + return null; 885 + } 886 + final dpopClient = DpopHttpClient(dpopKey: session.dpopJwk); 887 + final issuer = session.issuer; 888 + final did = session.subject; 889 + final url = Uri.parse('$issuer/xrpc/com.atproto.repo.createRecord'); 890 + final record = { 891 + 'collection': 'social.grain.photo.exif', 892 + 'repo': did, 893 + 'record': { 894 + 'photo': photo, 895 + 'createdAt': createdAt ?? DateTime.now().toUtc().toIso8601String(), 896 + if (dateTimeOriginal != null) 'dateTimeOriginal': dateTimeOriginal, 897 + if (exposureTime != null) 'exposureTime': exposureTime, 898 + if (fNumber != null) 'fNumber': fNumber, 899 + if (flash != null) 'flash': flash, 900 + if (focalLengthIn35mmFormat != null) 'focalLengthIn35mmFormat': focalLengthIn35mmFormat, 901 + if (iSO != null) 'iSO': iSO, 902 + if (lensMake != null) 'lensMake': lensMake, 903 + if (lensModel != null) 'lensModel': lensModel, 904 + if (make != null) 'make': make, 905 + if (model != null) 'model': model, 906 + }, 907 + }; 908 + appLogger.i('Creating photo exif record: $record'); 909 + final response = await dpopClient.send( 910 + method: 'POST', 911 + url: url, 912 + accessToken: session.accessToken, 913 + headers: {'Content-Type': 'application/json'}, 914 + body: jsonEncode(record), 915 + ); 916 + if (response.statusCode != 200 && response.statusCode != 201) { 917 + appLogger.w( 918 + 'Failed to create photo exif record: \\${response.statusCode} \\${response.body}', 919 + ); 920 + return null; 921 + } 922 + final result = jsonDecode(response.body) as Map<String, dynamic>; 923 + appLogger.i('Created photo exif record result: $result'); 924 + return result['uri'] as String?; 925 } 926 } 927
+24
lib/models/photo_exif.dart
···
··· 1 + import 'package:freezed_annotation/freezed_annotation.dart'; 2 + 3 + part 'photo_exif.freezed.dart'; 4 + part 'photo_exif.g.dart'; 5 + 6 + @freezed 7 + class PhotoExif with _$PhotoExif { 8 + const factory PhotoExif({ 9 + required String photo, // at-uri 10 + required String createdAt, // datetime 11 + String? dateTimeOriginal, // datetime 12 + int? exposureTime, 13 + int? fNumber, 14 + String? flash, 15 + int? focalLengthIn35mmFormat, 16 + int? iSO, 17 + String? lensMake, 18 + String? lensModel, 19 + String? make, 20 + String? model, 21 + }) = _PhotoExif; 22 + 23 + factory PhotoExif.fromJson(Map<String, dynamic> json) => _$PhotoExifFromJson(json); 24 + }
+424
lib/models/photo_exif.freezed.dart
···
··· 1 + // coverage:ignore-file 2 + // GENERATED CODE - DO NOT MODIFY BY HAND 3 + // ignore_for_file: type=lint 4 + // ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark 5 + 6 + part of 'photo_exif.dart'; 7 + 8 + // ************************************************************************** 9 + // FreezedGenerator 10 + // ************************************************************************** 11 + 12 + T _$identity<T>(T value) => value; 13 + 14 + final _privateConstructorUsedError = UnsupportedError( 15 + 'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#adding-getters-and-methods-to-our-models', 16 + ); 17 + 18 + PhotoExif _$PhotoExifFromJson(Map<String, dynamic> json) { 19 + return _PhotoExif.fromJson(json); 20 + } 21 + 22 + /// @nodoc 23 + mixin _$PhotoExif { 24 + String get photo => throw _privateConstructorUsedError; // at-uri 25 + String get createdAt => throw _privateConstructorUsedError; // datetime 26 + String? get dateTimeOriginal => 27 + throw _privateConstructorUsedError; // datetime 28 + int? get exposureTime => throw _privateConstructorUsedError; 29 + int? get fNumber => throw _privateConstructorUsedError; 30 + String? get flash => throw _privateConstructorUsedError; 31 + int? get focalLengthIn35mmFormat => throw _privateConstructorUsedError; 32 + int? get iSO => throw _privateConstructorUsedError; 33 + String? get lensMake => throw _privateConstructorUsedError; 34 + String? get lensModel => throw _privateConstructorUsedError; 35 + String? get make => throw _privateConstructorUsedError; 36 + String? get model => throw _privateConstructorUsedError; 37 + 38 + /// Serializes this PhotoExif to a JSON map. 39 + Map<String, dynamic> toJson() => throw _privateConstructorUsedError; 40 + 41 + /// Create a copy of PhotoExif 42 + /// with the given fields replaced by the non-null parameter values. 43 + @JsonKey(includeFromJson: false, includeToJson: false) 44 + $PhotoExifCopyWith<PhotoExif> get copyWith => 45 + throw _privateConstructorUsedError; 46 + } 47 + 48 + /// @nodoc 49 + abstract class $PhotoExifCopyWith<$Res> { 50 + factory $PhotoExifCopyWith(PhotoExif value, $Res Function(PhotoExif) then) = 51 + _$PhotoExifCopyWithImpl<$Res, PhotoExif>; 52 + @useResult 53 + $Res call({ 54 + String photo, 55 + String createdAt, 56 + String? dateTimeOriginal, 57 + int? exposureTime, 58 + int? fNumber, 59 + String? flash, 60 + int? focalLengthIn35mmFormat, 61 + int? iSO, 62 + String? lensMake, 63 + String? lensModel, 64 + String? make, 65 + String? model, 66 + }); 67 + } 68 + 69 + /// @nodoc 70 + class _$PhotoExifCopyWithImpl<$Res, $Val extends PhotoExif> 71 + implements $PhotoExifCopyWith<$Res> { 72 + _$PhotoExifCopyWithImpl(this._value, this._then); 73 + 74 + // ignore: unused_field 75 + final $Val _value; 76 + // ignore: unused_field 77 + final $Res Function($Val) _then; 78 + 79 + /// Create a copy of PhotoExif 80 + /// with the given fields replaced by the non-null parameter values. 81 + @pragma('vm:prefer-inline') 82 + @override 83 + $Res call({ 84 + Object? photo = null, 85 + Object? createdAt = null, 86 + Object? dateTimeOriginal = freezed, 87 + Object? exposureTime = freezed, 88 + Object? fNumber = freezed, 89 + Object? flash = freezed, 90 + Object? focalLengthIn35mmFormat = freezed, 91 + Object? iSO = freezed, 92 + Object? lensMake = freezed, 93 + Object? lensModel = freezed, 94 + Object? make = freezed, 95 + Object? model = freezed, 96 + }) { 97 + return _then( 98 + _value.copyWith( 99 + photo: null == photo 100 + ? _value.photo 101 + : photo // ignore: cast_nullable_to_non_nullable 102 + as String, 103 + createdAt: null == createdAt 104 + ? _value.createdAt 105 + : createdAt // ignore: cast_nullable_to_non_nullable 106 + as String, 107 + dateTimeOriginal: freezed == dateTimeOriginal 108 + ? _value.dateTimeOriginal 109 + : dateTimeOriginal // ignore: cast_nullable_to_non_nullable 110 + as String?, 111 + exposureTime: freezed == exposureTime 112 + ? _value.exposureTime 113 + : exposureTime // ignore: cast_nullable_to_non_nullable 114 + as int?, 115 + fNumber: freezed == fNumber 116 + ? _value.fNumber 117 + : fNumber // ignore: cast_nullable_to_non_nullable 118 + as int?, 119 + flash: freezed == flash 120 + ? _value.flash 121 + : flash // ignore: cast_nullable_to_non_nullable 122 + as String?, 123 + focalLengthIn35mmFormat: freezed == focalLengthIn35mmFormat 124 + ? _value.focalLengthIn35mmFormat 125 + : focalLengthIn35mmFormat // ignore: cast_nullable_to_non_nullable 126 + as int?, 127 + iSO: freezed == iSO 128 + ? _value.iSO 129 + : iSO // ignore: cast_nullable_to_non_nullable 130 + as int?, 131 + lensMake: freezed == lensMake 132 + ? _value.lensMake 133 + : lensMake // ignore: cast_nullable_to_non_nullable 134 + as String?, 135 + lensModel: freezed == lensModel 136 + ? _value.lensModel 137 + : lensModel // ignore: cast_nullable_to_non_nullable 138 + as String?, 139 + make: freezed == make 140 + ? _value.make 141 + : make // ignore: cast_nullable_to_non_nullable 142 + as String?, 143 + model: freezed == model 144 + ? _value.model 145 + : model // ignore: cast_nullable_to_non_nullable 146 + as String?, 147 + ) 148 + as $Val, 149 + ); 150 + } 151 + } 152 + 153 + /// @nodoc 154 + abstract class _$$PhotoExifImplCopyWith<$Res> 155 + implements $PhotoExifCopyWith<$Res> { 156 + factory _$$PhotoExifImplCopyWith( 157 + _$PhotoExifImpl value, 158 + $Res Function(_$PhotoExifImpl) then, 159 + ) = __$$PhotoExifImplCopyWithImpl<$Res>; 160 + @override 161 + @useResult 162 + $Res call({ 163 + String photo, 164 + String createdAt, 165 + String? dateTimeOriginal, 166 + int? exposureTime, 167 + int? fNumber, 168 + String? flash, 169 + int? focalLengthIn35mmFormat, 170 + int? iSO, 171 + String? lensMake, 172 + String? lensModel, 173 + String? make, 174 + String? model, 175 + }); 176 + } 177 + 178 + /// @nodoc 179 + class __$$PhotoExifImplCopyWithImpl<$Res> 180 + extends _$PhotoExifCopyWithImpl<$Res, _$PhotoExifImpl> 181 + implements _$$PhotoExifImplCopyWith<$Res> { 182 + __$$PhotoExifImplCopyWithImpl( 183 + _$PhotoExifImpl _value, 184 + $Res Function(_$PhotoExifImpl) _then, 185 + ) : super(_value, _then); 186 + 187 + /// Create a copy of PhotoExif 188 + /// with the given fields replaced by the non-null parameter values. 189 + @pragma('vm:prefer-inline') 190 + @override 191 + $Res call({ 192 + Object? photo = null, 193 + Object? createdAt = null, 194 + Object? dateTimeOriginal = freezed, 195 + Object? exposureTime = freezed, 196 + Object? fNumber = freezed, 197 + Object? flash = freezed, 198 + Object? focalLengthIn35mmFormat = freezed, 199 + Object? iSO = freezed, 200 + Object? lensMake = freezed, 201 + Object? lensModel = freezed, 202 + Object? make = freezed, 203 + Object? model = freezed, 204 + }) { 205 + return _then( 206 + _$PhotoExifImpl( 207 + photo: null == photo 208 + ? _value.photo 209 + : photo // ignore: cast_nullable_to_non_nullable 210 + as String, 211 + createdAt: null == createdAt 212 + ? _value.createdAt 213 + : createdAt // ignore: cast_nullable_to_non_nullable 214 + as String, 215 + dateTimeOriginal: freezed == dateTimeOriginal 216 + ? _value.dateTimeOriginal 217 + : dateTimeOriginal // ignore: cast_nullable_to_non_nullable 218 + as String?, 219 + exposureTime: freezed == exposureTime 220 + ? _value.exposureTime 221 + : exposureTime // ignore: cast_nullable_to_non_nullable 222 + as int?, 223 + fNumber: freezed == fNumber 224 + ? _value.fNumber 225 + : fNumber // ignore: cast_nullable_to_non_nullable 226 + as int?, 227 + flash: freezed == flash 228 + ? _value.flash 229 + : flash // ignore: cast_nullable_to_non_nullable 230 + as String?, 231 + focalLengthIn35mmFormat: freezed == focalLengthIn35mmFormat 232 + ? _value.focalLengthIn35mmFormat 233 + : focalLengthIn35mmFormat // ignore: cast_nullable_to_non_nullable 234 + as int?, 235 + iSO: freezed == iSO 236 + ? _value.iSO 237 + : iSO // ignore: cast_nullable_to_non_nullable 238 + as int?, 239 + lensMake: freezed == lensMake 240 + ? _value.lensMake 241 + : lensMake // ignore: cast_nullable_to_non_nullable 242 + as String?, 243 + lensModel: freezed == lensModel 244 + ? _value.lensModel 245 + : lensModel // ignore: cast_nullable_to_non_nullable 246 + as String?, 247 + make: freezed == make 248 + ? _value.make 249 + : make // ignore: cast_nullable_to_non_nullable 250 + as String?, 251 + model: freezed == model 252 + ? _value.model 253 + : model // ignore: cast_nullable_to_non_nullable 254 + as String?, 255 + ), 256 + ); 257 + } 258 + } 259 + 260 + /// @nodoc 261 + @JsonSerializable() 262 + class _$PhotoExifImpl implements _PhotoExif { 263 + const _$PhotoExifImpl({ 264 + required this.photo, 265 + required this.createdAt, 266 + this.dateTimeOriginal, 267 + this.exposureTime, 268 + this.fNumber, 269 + this.flash, 270 + this.focalLengthIn35mmFormat, 271 + this.iSO, 272 + this.lensMake, 273 + this.lensModel, 274 + this.make, 275 + this.model, 276 + }); 277 + 278 + factory _$PhotoExifImpl.fromJson(Map<String, dynamic> json) => 279 + _$$PhotoExifImplFromJson(json); 280 + 281 + @override 282 + final String photo; 283 + // at-uri 284 + @override 285 + final String createdAt; 286 + // datetime 287 + @override 288 + final String? dateTimeOriginal; 289 + // datetime 290 + @override 291 + final int? exposureTime; 292 + @override 293 + final int? fNumber; 294 + @override 295 + final String? flash; 296 + @override 297 + final int? focalLengthIn35mmFormat; 298 + @override 299 + final int? iSO; 300 + @override 301 + final String? lensMake; 302 + @override 303 + final String? lensModel; 304 + @override 305 + final String? make; 306 + @override 307 + final String? model; 308 + 309 + @override 310 + String toString() { 311 + return 'PhotoExif(photo: $photo, createdAt: $createdAt, dateTimeOriginal: $dateTimeOriginal, exposureTime: $exposureTime, fNumber: $fNumber, flash: $flash, focalLengthIn35mmFormat: $focalLengthIn35mmFormat, iSO: $iSO, lensMake: $lensMake, lensModel: $lensModel, make: $make, model: $model)'; 312 + } 313 + 314 + @override 315 + bool operator ==(Object other) { 316 + return identical(this, other) || 317 + (other.runtimeType == runtimeType && 318 + other is _$PhotoExifImpl && 319 + (identical(other.photo, photo) || other.photo == photo) && 320 + (identical(other.createdAt, createdAt) || 321 + other.createdAt == createdAt) && 322 + (identical(other.dateTimeOriginal, dateTimeOriginal) || 323 + other.dateTimeOriginal == dateTimeOriginal) && 324 + (identical(other.exposureTime, exposureTime) || 325 + other.exposureTime == exposureTime) && 326 + (identical(other.fNumber, fNumber) || other.fNumber == fNumber) && 327 + (identical(other.flash, flash) || other.flash == flash) && 328 + (identical( 329 + other.focalLengthIn35mmFormat, 330 + focalLengthIn35mmFormat, 331 + ) || 332 + other.focalLengthIn35mmFormat == focalLengthIn35mmFormat) && 333 + (identical(other.iSO, iSO) || other.iSO == iSO) && 334 + (identical(other.lensMake, lensMake) || 335 + other.lensMake == lensMake) && 336 + (identical(other.lensModel, lensModel) || 337 + other.lensModel == lensModel) && 338 + (identical(other.make, make) || other.make == make) && 339 + (identical(other.model, model) || other.model == model)); 340 + } 341 + 342 + @JsonKey(includeFromJson: false, includeToJson: false) 343 + @override 344 + int get hashCode => Object.hash( 345 + runtimeType, 346 + photo, 347 + createdAt, 348 + dateTimeOriginal, 349 + exposureTime, 350 + fNumber, 351 + flash, 352 + focalLengthIn35mmFormat, 353 + iSO, 354 + lensMake, 355 + lensModel, 356 + make, 357 + model, 358 + ); 359 + 360 + /// Create a copy of PhotoExif 361 + /// with the given fields replaced by the non-null parameter values. 362 + @JsonKey(includeFromJson: false, includeToJson: false) 363 + @override 364 + @pragma('vm:prefer-inline') 365 + _$$PhotoExifImplCopyWith<_$PhotoExifImpl> get copyWith => 366 + __$$PhotoExifImplCopyWithImpl<_$PhotoExifImpl>(this, _$identity); 367 + 368 + @override 369 + Map<String, dynamic> toJson() { 370 + return _$$PhotoExifImplToJson(this); 371 + } 372 + } 373 + 374 + abstract class _PhotoExif implements PhotoExif { 375 + const factory _PhotoExif({ 376 + required final String photo, 377 + required final String createdAt, 378 + final String? dateTimeOriginal, 379 + final int? exposureTime, 380 + final int? fNumber, 381 + final String? flash, 382 + final int? focalLengthIn35mmFormat, 383 + final int? iSO, 384 + final String? lensMake, 385 + final String? lensModel, 386 + final String? make, 387 + final String? model, 388 + }) = _$PhotoExifImpl; 389 + 390 + factory _PhotoExif.fromJson(Map<String, dynamic> json) = 391 + _$PhotoExifImpl.fromJson; 392 + 393 + @override 394 + String get photo; // at-uri 395 + @override 396 + String get createdAt; // datetime 397 + @override 398 + String? get dateTimeOriginal; // datetime 399 + @override 400 + int? get exposureTime; 401 + @override 402 + int? get fNumber; 403 + @override 404 + String? get flash; 405 + @override 406 + int? get focalLengthIn35mmFormat; 407 + @override 408 + int? get iSO; 409 + @override 410 + String? get lensMake; 411 + @override 412 + String? get lensModel; 413 + @override 414 + String? get make; 415 + @override 416 + String? get model; 417 + 418 + /// Create a copy of PhotoExif 419 + /// with the given fields replaced by the non-null parameter values. 420 + @override 421 + @JsonKey(includeFromJson: false, includeToJson: false) 422 + _$$PhotoExifImplCopyWith<_$PhotoExifImpl> get copyWith => 423 + throw _privateConstructorUsedError; 424 + }
+40
lib/models/photo_exif.g.dart
···
··· 1 + // GENERATED CODE - DO NOT MODIFY BY HAND 2 + 3 + part of 'photo_exif.dart'; 4 + 5 + // ************************************************************************** 6 + // JsonSerializableGenerator 7 + // ************************************************************************** 8 + 9 + _$PhotoExifImpl _$$PhotoExifImplFromJson(Map<String, dynamic> json) => 10 + _$PhotoExifImpl( 11 + photo: json['photo'] as String, 12 + createdAt: json['createdAt'] as String, 13 + dateTimeOriginal: json['dateTimeOriginal'] as String?, 14 + exposureTime: (json['exposureTime'] as num?)?.toInt(), 15 + fNumber: (json['fNumber'] as num?)?.toInt(), 16 + flash: json['flash'] as String?, 17 + focalLengthIn35mmFormat: (json['focalLengthIn35mmFormat'] as num?) 18 + ?.toInt(), 19 + iSO: (json['iSO'] as num?)?.toInt(), 20 + lensMake: json['lensMake'] as String?, 21 + lensModel: json['lensModel'] as String?, 22 + make: json['make'] as String?, 23 + model: json['model'] as String?, 24 + ); 25 + 26 + Map<String, dynamic> _$$PhotoExifImplToJson(_$PhotoExifImpl instance) => 27 + <String, dynamic>{ 28 + 'photo': instance.photo, 29 + 'createdAt': instance.createdAt, 30 + 'dateTimeOriginal': instance.dateTimeOriginal, 31 + 'exposureTime': instance.exposureTime, 32 + 'fNumber': instance.fNumber, 33 + 'flash': instance.flash, 34 + 'focalLengthIn35mmFormat': instance.focalLengthIn35mmFormat, 35 + 'iSO': instance.iSO, 36 + 'lensMake': instance.lensMake, 37 + 'lensModel': instance.lensModel, 38 + 'make': instance.make, 39 + 'model': instance.model, 40 + };
+29 -2
lib/providers/gallery_cache_provider.dart
··· 12 import '../models/gallery.dart'; 13 import '../models/gallery_item.dart'; 14 import '../photo_manip.dart'; 15 16 part 'gallery_cache_provider.g.dart'; 17 ··· 90 required String galleryUri, 91 required List<XFile> xfiles, 92 int? startPosition, 93 }) async { 94 // Fetch the latest gallery from the API to avoid stale state 95 final latestGallery = await apiService.getGallery(uri: galleryUri); ··· 102 final List<String> photoUris = []; 103 int position = positionOffset; 104 for (final xfile in xfiles) { 105 - // Resize the image 106 final file = File(xfile.path); 107 final resizedResult = await compute<File, ResizeResult>((f) => resizeImage(file: f), file); 108 // Upload the blob 109 final blobResult = await apiService.uploadBlob(resizedResult.file); ··· 122 height: dims['height']!, 123 ); 124 if (photoUri == null) continue; 125 // Create the gallery item 126 await apiService.createGalleryItem( 127 galleryUri: galleryUri, ··· 148 required String title, 149 required String description, 150 required List<XFile> xfiles, 151 }) async { 152 // Extract facets from description 153 final facetsList = await _extractFacets(description); ··· 160 ); 161 if (galleryUri == null) return (null, <String>[]); 162 // Upload and add photos 163 - final photoUris = await uploadAndAddPhotosToGallery(galleryUri: galleryUri, xfiles: xfiles); 164 return (galleryUri, photoUris); 165 } 166
··· 12 import '../models/gallery.dart'; 13 import '../models/gallery_item.dart'; 14 import '../photo_manip.dart'; 15 + import '../utils/exif_utils.dart'; 16 17 part 'gallery_cache_provider.g.dart'; 18 ··· 91 required String galleryUri, 92 required List<XFile> xfiles, 93 int? startPosition, 94 + bool includeExif = true, 95 }) async { 96 // Fetch the latest gallery from the API to avoid stale state 97 final latestGallery = await apiService.getGallery(uri: galleryUri); ··· 104 final List<String> photoUris = []; 105 int position = positionOffset; 106 for (final xfile in xfiles) { 107 final file = File(xfile.path); 108 + // Parse EXIF if requested 109 + final exif = includeExif ? await parseAndNormalizeExif(file: file) : null; 110 + // Resize the image 111 final resizedResult = await compute<File, ResizeResult>((f) => resizeImage(file: f), file); 112 // Upload the blob 113 final blobResult = await apiService.uploadBlob(resizedResult.file); ··· 126 height: dims['height']!, 127 ); 128 if (photoUri == null) continue; 129 + 130 + // If EXIF data was found, create photo exif record 131 + if (exif != null) { 132 + await apiService.createPhotoExif( 133 + photo: photoUri, 134 + dateTimeOriginal: exif['dateTimeOriginal'] as String?, 135 + exposureTime: exif['exposureTime'] as int?, 136 + fNumber: exif['fNumber'] as int?, 137 + flash: exif['flash'] as String?, 138 + focalLengthIn35mmFormat: exif['focalLengthIn35mmFilm'] as int?, 139 + iSO: exif['iSOSpeedRatings'] as int?, 140 + lensMake: exif['lensMake'] as String?, 141 + lensModel: exif['lensModel'] as String?, 142 + make: exif['make'] as String?, 143 + model: exif['model'] as String?, 144 + ); 145 + } 146 + 147 // Create the gallery item 148 await apiService.createGalleryItem( 149 galleryUri: galleryUri, ··· 170 required String title, 171 required String description, 172 required List<XFile> xfiles, 173 + bool includeExif = true, 174 }) async { 175 // Extract facets from description 176 final facetsList = await _extractFacets(description); ··· 183 ); 184 if (galleryUri == null) return (null, <String>[]); 185 // Upload and add photos 186 + final photoUris = await uploadAndAddPhotosToGallery( 187 + galleryUri: galleryUri, 188 + xfiles: xfiles, 189 + includeExif: includeExif, 190 + ); 191 return (galleryUri, photoUris); 192 } 193
+20
lib/screens/gallery_edit_photos_sheet.dart
··· 31 late List<GalleryPhoto> _photos; 32 bool _loading = false; 33 int? _deletingPhotoIndex; 34 35 @override 36 void initState() { ··· 190 await notifier.uploadAndAddPhotosToGallery( 191 galleryUri: widget.galleryUri, 192 xfiles: picked, 193 ); 194 // Fetch the updated gallery from provider state 195 final updatedGallery = ref.read( ··· 227 ); 228 }, 229 ), 230 ), 231 ], 232 ),
··· 31 late List<GalleryPhoto> _photos; 32 bool _loading = false; 33 int? _deletingPhotoIndex; 34 + bool _includeExif = true; 35 36 @override 37 void initState() { ··· 191 await notifier.uploadAndAddPhotosToGallery( 192 galleryUri: widget.galleryUri, 193 xfiles: picked, 194 + includeExif: _includeExif, 195 ); 196 // Fetch the updated gallery from provider state 197 final updatedGallery = ref.read( ··· 229 ); 230 }, 231 ), 232 + ), 233 + ], 234 + ), 235 + const SizedBox(height: 16), 236 + Row( 237 + children: [ 238 + Expanded( 239 + child: Text('Include image metadata (EXIF)', style: theme.textTheme.bodyMedium), 240 + ), 241 + Switch( 242 + value: _includeExif, 243 + onChanged: (_loading || _deletingPhotoIndex != null) 244 + ? null 245 + : (val) { 246 + setState(() { 247 + _includeExif = val; 248 + }); 249 + }, 250 ), 251 ], 252 ),
+113
lib/utils/exif_utils.dart
···
··· 1 + import 'dart:io'; 2 + 3 + import 'package:exif/exif.dart'; 4 + 5 + import '../app_logger.dart'; 6 + 7 + const List<String> exifTags = [ 8 + 'DateTimeOriginal', 9 + 'ExposureTime', 10 + 'FNumber', 11 + 'Flash', 12 + 'FocalLengthIn35mmFilm', 13 + 'ISOSpeedRatings', 14 + 'LensMake', 15 + 'LensModel', 16 + 'Make', 17 + 'Model', 18 + ]; 19 + 20 + const int scaleFactor = 1000000; 21 + 22 + Future<Map<String, dynamic>?> parseAndNormalizeExif({required File file}) async { 23 + try { 24 + final Map<String, IfdTag> exifData = await readExifFromBytes(await file.readAsBytes()); 25 + appLogger.i('Parsed EXIF data: $exifData'); 26 + 27 + if (exifData.isEmpty) return null; 28 + final Map<String, dynamic> normalized = {}; 29 + 30 + for (final entry in exifData.entries) { 31 + final fullTag = entry.key; 32 + String tag; 33 + if (fullTag.contains(' ')) { 34 + tag = fullTag.split(' ').last.trim(); 35 + } else { 36 + tag = fullTag.trim(); 37 + } 38 + final value = entry.value.printable; 39 + final camelKey = tag.isNotEmpty ? tag[0].toLowerCase() + tag.substring(1) : tag; 40 + switch (tag) { 41 + case 'DateTimeOriginal': 42 + normalized[camelKey] = _exifDateToIso(value); 43 + break; 44 + case 'ExposureTime': 45 + normalized[camelKey] = _parseScaledInt(value); 46 + break; 47 + case 'FNumber': 48 + normalized[camelKey] = _parseScaledInt(value); 49 + break; 50 + case 'Flash': 51 + normalized[camelKey] = value; 52 + break; 53 + case 'FocalLengthIn35mmFilm': 54 + normalized[camelKey] = _parseScaledInt(value); 55 + break; 56 + case 'ISOSpeedRatings': 57 + normalized[camelKey] = _parseInt(value); 58 + break; 59 + case 'LensMake': 60 + normalized[camelKey] = value; 61 + break; 62 + case 'LensModel': 63 + normalized[camelKey] = value; 64 + break; 65 + case 'Make': 66 + normalized[camelKey] = value; 67 + break; 68 + case 'Model': 69 + normalized[camelKey] = value; 70 + break; 71 + } 72 + } 73 + 74 + return normalized; 75 + } catch (e) { 76 + return null; 77 + } 78 + } 79 + 80 + int? _parseScaledInt(String? value) { 81 + if (value == null) return null; 82 + // Handle fraction like "1/40" 83 + if (value.contains('/')) { 84 + final parts = value.split('/'); 85 + if (parts.length == 2) { 86 + final numerator = double.tryParse(parts[0].trim()); 87 + final denominator = double.tryParse(parts[1].trim()); 88 + if (numerator != null && denominator != null && denominator != 0) { 89 + return (numerator / denominator * scaleFactor).round(); 90 + } 91 + } 92 + } 93 + final numVal = double.tryParse(value.replaceAll(RegExp(r'[^0-9.]'), '')); 94 + if (numVal == null) return null; 95 + return (numVal * scaleFactor).round(); 96 + } 97 + 98 + int? _parseInt(String? value) { 99 + if (value == null) return null; 100 + final intVal = int.tryParse(value.replaceAll(RegExp(r'[^0-9]'), '')); 101 + if (intVal == null) return null; 102 + return intVal * scaleFactor; 103 + } 104 + 105 + String? _exifDateToIso(String? exifDate) { 106 + if (exifDate == null) return null; 107 + // EXIF format: yyyy:MM:dd HH:mm:ss 108 + final match = RegExp(r'^(\d{4}):(\d{2}):(\d{2}) (\d{2}):(\d{2}):(\d{2})$').firstMatch(exifDate); 109 + if (match == null) return null; 110 + final formatted = 111 + '${match.group(1)}-${match.group(2)}-${match.group(3)}T${match.group(4)}:${match.group(5)}:${match.group(6)}'; 112 + return formatted; 113 + }
+8
pubspec.lock
··· 321 url: "https://pub.dev" 322 source: hosted 323 version: "0.2.3" 324 fake_async: 325 dependency: transitive 326 description:
··· 321 url: "https://pub.dev" 322 source: hosted 323 version: "0.2.3" 324 + exif: 325 + dependency: "direct main" 326 + description: 327 + name: exif 328 + sha256: a7980fdb3b7ffcd0b035e5b8a5e1eef7cadfe90ea6a4e85ebb62f87b96c7a172 329 + url: "https://pub.dev" 330 + source: hosted 331 + version: "3.3.0" 332 fake_async: 333 dependency: transitive 334 description:
+1
pubspec.yaml
··· 59 json_annotation: ^4.9.0 60 url_launcher: ^6.3.1 61 reorderables: ^0.6.0 62 63 dependency_overrides: 64 analyzer: 7.3.0
··· 59 json_annotation: ^4.9.0 60 url_launcher: ^6.3.1 61 reorderables: ^0.6.0 62 + exif: ^3.3.0 63 64 dependency_overrides: 65 analyzer: 7.3.0