+62
-1
lib/api.dart
+62
-1
lib/api.dart
···
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
+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
+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
+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
+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
+35
-10
lib/screens/create_gallery_page.dart
+35
-10
lib/screens/create_gallery_page.dart
···
37
final _descController = TextEditingController();
38
final List<GalleryImage> _images = [];
39
bool _submitting = false;
40
41
@override
42
void initState() {
···
45
_titleController.text = widget.gallery?.title ?? '';
46
_descController.text = widget.gallery?.description ?? '';
47
}
48
}
49
50
Future<String> _computeMd5(XFile xfile) async {
···
125
title: _titleController.text.trim(),
126
description: _descController.text.trim(),
127
xfiles: newImages.map((img) => img.file).toList(),
128
);
129
galleryUri = createdUri;
130
// Update profile provider state to include new gallery
···
188
),
189
trailing: CupertinoButton(
190
padding: EdgeInsets.zero,
191
-
onPressed: _submitting ? null : _submit,
192
child: Row(
193
mainAxisSize: MainAxisSize.min,
194
children: [
195
Text(
196
widget.gallery == null ? 'Create' : 'Save',
197
style: TextStyle(
198
-
color: _submitting ? theme.disabledColor : theme.colorScheme.primary,
199
fontWeight: FontWeight.w600,
200
),
201
),
···
236
hintText: 'Enter a description',
237
),
238
const SizedBox(height: 16),
239
if (widget.gallery == null)
240
Row(
241
children: [
242
-
AppButton(
243
-
label: 'Upload photos',
244
-
onPressed: _pickImages,
245
-
icon: Icons.photo_library,
246
-
variant: AppButtonVariant.primary,
247
-
height: 40,
248
-
fontSize: 15,
249
-
borderRadius: 6,
250
),
251
],
252
),
···
37
final _descController = TextEditingController();
38
final List<GalleryImage> _images = [];
39
bool _submitting = false;
40
+
bool _includeExif = true;
41
42
@override
43
void initState() {
···
46
_titleController.text = widget.gallery?.title ?? '';
47
_descController.text = widget.gallery?.description ?? '';
48
}
49
+
_titleController.addListener(() {
50
+
setState(() {});
51
+
});
52
}
53
54
Future<String> _computeMd5(XFile xfile) async {
···
129
title: _titleController.text.trim(),
130
description: _descController.text.trim(),
131
xfiles: newImages.map((img) => img.file).toList(),
132
+
includeExif: _includeExif,
133
);
134
galleryUri = createdUri;
135
// Update profile provider state to include new gallery
···
193
),
194
trailing: CupertinoButton(
195
padding: EdgeInsets.zero,
196
+
onPressed: _submitting || _titleController.text.trim().isEmpty ? null : _submit,
197
child: Row(
198
mainAxisSize: MainAxisSize.min,
199
children: [
200
Text(
201
widget.gallery == null ? 'Create' : 'Save',
202
style: TextStyle(
203
+
color: (_submitting || _titleController.text.trim().isEmpty)
204
+
? theme.disabledColor
205
+
: theme.colorScheme.primary,
206
fontWeight: FontWeight.w600,
207
),
208
),
···
243
hintText: 'Enter a description',
244
),
245
const SizedBox(height: 16),
246
+
Row(
247
+
children: [
248
+
Expanded(
249
+
child: Text('Include image metadata (EXIF)', style: theme.textTheme.bodyMedium),
250
+
),
251
+
Switch(
252
+
value: _includeExif,
253
+
onChanged: (val) {
254
+
setState(() {
255
+
_includeExif = val;
256
+
});
257
+
},
258
+
),
259
+
],
260
+
),
261
+
const SizedBox(height: 16),
262
if (widget.gallery == null)
263
Row(
264
children: [
265
+
Expanded(
266
+
child: AppButton(
267
+
label: 'Upload photos',
268
+
onPressed: _pickImages,
269
+
icon: Icons.photo_library,
270
+
variant: AppButtonVariant.primary,
271
+
height: 40,
272
+
fontSize: 15,
273
+
borderRadius: 6,
274
+
),
275
),
276
],
277
),
+20
lib/screens/gallery_edit_photos_sheet.dart
+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
+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
+8
pubspec.lock
···
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: