+1
lib/models/photo_exif.dart
+1
lib/models/photo_exif.dart
+31
-3
lib/models/photo_exif.freezed.dart
+31
-3
lib/models/photo_exif.freezed.dart
···
36
36
String? get lensModel => throw _privateConstructorUsedError;
37
37
String? get make => throw _privateConstructorUsedError;
38
38
String? get model => throw _privateConstructorUsedError;
39
+
Map<String, dynamic>? get record => throw _privateConstructorUsedError;
39
40
40
41
/// Serializes this PhotoExif to a JSON map.
41
42
Map<String, dynamic> toJson() => throw _privateConstructorUsedError;
···
67
68
String? lensModel,
68
69
String? make,
69
70
String? model,
71
+
Map<String, dynamic>? record,
70
72
});
71
73
}
72
74
···
99
101
Object? lensModel = freezed,
100
102
Object? make = freezed,
101
103
Object? model = freezed,
104
+
Object? record = freezed,
102
105
}) {
103
106
return _then(
104
107
_value.copyWith(
···
158
161
? _value.model
159
162
: model // ignore: cast_nullable_to_non_nullable
160
163
as String?,
164
+
record: freezed == record
165
+
? _value.record
166
+
: record // ignore: cast_nullable_to_non_nullable
167
+
as Map<String, dynamic>?,
161
168
)
162
169
as $Val,
163
170
);
···
188
195
String? lensModel,
189
196
String? make,
190
197
String? model,
198
+
Map<String, dynamic>? record,
191
199
});
192
200
}
193
201
···
219
227
Object? lensModel = freezed,
220
228
Object? make = freezed,
221
229
Object? model = freezed,
230
+
Object? record = freezed,
222
231
}) {
223
232
return _then(
224
233
_$PhotoExifImpl(
···
278
287
? _value.model
279
288
: model // ignore: cast_nullable_to_non_nullable
280
289
as String?,
290
+
record: freezed == record
291
+
? _value._record
292
+
: record // ignore: cast_nullable_to_non_nullable
293
+
as Map<String, dynamic>?,
281
294
),
282
295
);
283
296
}
···
301
314
this.lensModel,
302
315
this.make,
303
316
this.model,
304
-
});
317
+
final Map<String, dynamic>? record,
318
+
}) : _record = record;
305
319
306
320
factory _$PhotoExifImpl.fromJson(Map<String, dynamic> json) =>
307
321
_$$PhotoExifImplFromJson(json);
···
339
353
final String? make;
340
354
@override
341
355
final String? model;
356
+
final Map<String, dynamic>? _record;
357
+
@override
358
+
Map<String, dynamic>? get record {
359
+
final value = _record;
360
+
if (value == null) return null;
361
+
if (_record is EqualUnmodifiableMapView) return _record;
362
+
// ignore: implicit_dynamic_type
363
+
return EqualUnmodifiableMapView(value);
364
+
}
342
365
343
366
@override
344
367
String toString() {
345
-
return 'PhotoExif(photo: $photo, createdAt: $createdAt, uri: $uri, cid: $cid, dateTimeOriginal: $dateTimeOriginal, exposureTime: $exposureTime, fNumber: $fNumber, flash: $flash, focalLengthIn35mmFormat: $focalLengthIn35mmFormat, iSO: $iSO, lensMake: $lensMake, lensModel: $lensModel, make: $make, model: $model)';
368
+
return 'PhotoExif(photo: $photo, createdAt: $createdAt, uri: $uri, cid: $cid, dateTimeOriginal: $dateTimeOriginal, exposureTime: $exposureTime, fNumber: $fNumber, flash: $flash, focalLengthIn35mmFormat: $focalLengthIn35mmFormat, iSO: $iSO, lensMake: $lensMake, lensModel: $lensModel, make: $make, model: $model, record: $record)';
346
369
}
347
370
348
371
@override
···
372
395
(identical(other.lensModel, lensModel) ||
373
396
other.lensModel == lensModel) &&
374
397
(identical(other.make, make) || other.make == make) &&
375
-
(identical(other.model, model) || other.model == model));
398
+
(identical(other.model, model) || other.model == model) &&
399
+
const DeepCollectionEquality().equals(other._record, _record));
376
400
}
377
401
378
402
@JsonKey(includeFromJson: false, includeToJson: false)
···
393
417
lensModel,
394
418
make,
395
419
model,
420
+
const DeepCollectionEquality().hash(_record),
396
421
);
397
422
398
423
/// Create a copy of PhotoExif
···
425
450
final String? lensModel,
426
451
final String? make,
427
452
final String? model,
453
+
final Map<String, dynamic>? record,
428
454
}) = _$PhotoExifImpl;
429
455
430
456
factory _PhotoExif.fromJson(Map<String, dynamic> json) =
···
458
484
String? get make;
459
485
@override
460
486
String? get model;
487
+
@override
488
+
Map<String, dynamic>? get record;
461
489
462
490
/// Create a copy of PhotoExif
463
491
/// with the given fields replaced by the non-null parameter values.
+2
lib/models/photo_exif.g.dart
+2
lib/models/photo_exif.g.dart
···
22
22
lensModel: json['lensModel'] as String?,
23
23
make: json['make'] as String?,
24
24
model: json['model'] as String?,
25
+
record: json['record'] as Map<String, dynamic>?,
25
26
);
26
27
27
28
Map<String, dynamic> _$$PhotoExifImplToJson(_$PhotoExifImpl instance) =>
···
40
41
'lensModel': instance.lensModel,
41
42
'make': instance.make,
42
43
'model': instance.model,
44
+
'record': instance.record,
43
45
};
+1
-1
lib/providers/gallery_cache_provider.g.dart
+1
-1
lib/providers/gallery_cache_provider.g.dart
···
6
6
// RiverpodGenerator
7
7
// **************************************************************************
8
8
9
-
String _$galleryCacheHash() => r'd74ced0d6fcf6369bed80f7f0219bd591c13db5a';
9
+
String _$galleryCacheHash() => r'd604bfc71f008251a36d7943b99294728c31de1f';
10
10
11
11
/// Holds a cache of galleries by URI.
12
12
///
+1
-1
lib/providers/profile_provider.g.dart
+1
-1
lib/providers/profile_provider.g.dart
···
6
6
// RiverpodGenerator
7
7
// **************************************************************************
8
8
9
-
String _$profileNotifierHash() => r'48159a8319bba2f2ec5462c50d80ba6a5b72d91e';
9
+
String _$profileNotifierHash() => r'4b8e3a8d4363beb885ead4ae7ce9c52101a6bf96';
10
10
11
11
/// Copied from Dart SDK
12
12
class _SystemHash {
+601
lib/screens/photo_library_page.dart
+601
lib/screens/photo_library_page.dart
···
1
+
import 'package:flutter/material.dart';
2
+
import 'package:grain/api.dart';
3
+
import 'package:grain/app_icons.dart';
4
+
import 'package:grain/models/gallery_photo.dart';
5
+
import 'package:grain/widgets/app_image.dart';
6
+
import 'package:grain/widgets/gallery_photo_view.dart';
7
+
8
+
class PhotoGroup {
9
+
final String title;
10
+
final List<GalleryPhoto> photos;
11
+
final DateTime? sortDate;
12
+
13
+
PhotoGroup({required this.title, required this.photos, this.sortDate});
14
+
}
15
+
16
+
class PhotoLibraryPage extends StatefulWidget {
17
+
const PhotoLibraryPage({super.key});
18
+
19
+
@override
20
+
State<PhotoLibraryPage> createState() => _PhotoLibraryPageState();
21
+
}
22
+
23
+
class _PhotoLibraryPageState extends State<PhotoLibraryPage> {
24
+
List<GalleryPhoto> _photos = [];
25
+
List<PhotoGroup> _photoGroups = [];
26
+
bool _isLoading = true;
27
+
String? _error;
28
+
final ScrollController _scrollController = ScrollController();
29
+
double _scrollPosition = 0.0;
30
+
31
+
@override
32
+
void initState() {
33
+
super.initState();
34
+
_loadPhotos();
35
+
_scrollController.addListener(_onScroll);
36
+
}
37
+
38
+
@override
39
+
void dispose() {
40
+
_scrollController.removeListener(_onScroll);
41
+
_scrollController.dispose();
42
+
super.dispose();
43
+
}
44
+
45
+
void _onScroll() {
46
+
if (_scrollController.hasClients) {
47
+
setState(() {
48
+
_scrollPosition = _scrollController.offset;
49
+
});
50
+
}
51
+
}
52
+
53
+
// Calculate which group is currently in view based on scroll position
54
+
int _getCurrentGroupIndex() {
55
+
if (!_scrollController.hasClients || _photoGroups.isEmpty) return 0;
56
+
57
+
final scrollOffset = _scrollController.offset;
58
+
final padding = 16.0; // ListView padding
59
+
double currentOffset = padding;
60
+
61
+
for (int i = 0; i < _photoGroups.length; i++) {
62
+
final group = _photoGroups[i];
63
+
64
+
// Add space for group title
65
+
final titleHeight = 24.0 + 12.0 + (i == 0 ? 0 : 24.0); // title + padding + top margin
66
+
currentOffset += titleHeight;
67
+
68
+
// Calculate grid height for this group
69
+
final photos = group.photos;
70
+
final crossAxisCount = photos.length == 1 ? 1 : (photos.length == 2 ? 2 : 3);
71
+
final aspectRatio = photos.length <= 2 ? 1.5 : 1.0;
72
+
final rows = (photos.length / crossAxisCount).ceil();
73
+
74
+
// Estimate grid item size based on screen width
75
+
final screenWidth = MediaQuery.of(context).size.width;
76
+
final gridPadding = 30.0 + 32.0; // right padding + left/right margins
77
+
final availableWidth = screenWidth - gridPadding;
78
+
final itemWidth = (availableWidth - (crossAxisCount - 1) * 4) / crossAxisCount;
79
+
final itemHeight = itemWidth / aspectRatio;
80
+
final gridHeight = rows * itemHeight + (rows - 1) * 4; // include spacing
81
+
82
+
currentOffset += gridHeight;
83
+
84
+
// Check if we're currently viewing this group
85
+
if (scrollOffset < currentOffset) {
86
+
return i;
87
+
}
88
+
}
89
+
90
+
return _photoGroups.length - 1; // Return last group if we're at the bottom
91
+
}
92
+
93
+
Future<void> _loadPhotos() async {
94
+
setState(() {
95
+
_isLoading = true;
96
+
_error = null;
97
+
});
98
+
99
+
try {
100
+
final currentUser = apiService.currentUser;
101
+
if (currentUser == null || currentUser.did.isEmpty) {
102
+
setState(() {
103
+
_error = 'No current user found';
104
+
_isLoading = false;
105
+
});
106
+
return;
107
+
}
108
+
109
+
final photos = await apiService.fetchActorPhotos(did: currentUser.did);
110
+
111
+
if (mounted) {
112
+
setState(() {
113
+
_photos = photos;
114
+
_photoGroups = _groupPhotosByDate(photos);
115
+
_isLoading = false;
116
+
});
117
+
118
+
// Force update scroll indicator after layout is complete
119
+
WidgetsBinding.instance.addPostFrameCallback((_) {
120
+
if (_scrollController.hasClients && mounted) {
121
+
setState(() {
122
+
_scrollPosition = _scrollController.offset;
123
+
});
124
+
}
125
+
});
126
+
}
127
+
} catch (e) {
128
+
if (mounted) {
129
+
setState(() {
130
+
_error = 'Failed to load photos: $e';
131
+
_isLoading = false;
132
+
});
133
+
}
134
+
}
135
+
}
136
+
137
+
List<PhotoGroup> _groupPhotosByDate(List<GalleryPhoto> photos) {
138
+
final now = DateTime.now();
139
+
final today = DateTime(now.year, now.month, now.day);
140
+
final yesterday = today.subtract(const Duration(days: 1));
141
+
142
+
final Map<String, List<GalleryPhoto>> groupedPhotos = {};
143
+
final List<GalleryPhoto> noExifPhotos = [];
144
+
145
+
for (final photo in photos) {
146
+
DateTime? photoDate;
147
+
// Try to parse the dateTimeOriginal from EXIF record data
148
+
if (photo.exif?.record?['dateTimeOriginal'] != null) {
149
+
try {
150
+
final dateTimeOriginal = photo.exif!.record!['dateTimeOriginal'] as String;
151
+
photoDate = DateTime.parse(dateTimeOriginal);
152
+
} catch (e) {
153
+
// If parsing fails, add to no EXIF group
154
+
noExifPhotos.add(photo);
155
+
continue;
156
+
}
157
+
} else {
158
+
noExifPhotos.add(photo);
159
+
continue;
160
+
}
161
+
162
+
final photoDay = DateTime(photoDate.year, photoDate.month, photoDate.day);
163
+
String groupKey;
164
+
165
+
if (photoDay.isAtSameMomentAs(today)) {
166
+
groupKey = 'Today';
167
+
} else if (photoDay.isAtSameMomentAs(yesterday)) {
168
+
groupKey = 'Yesterday';
169
+
} else {
170
+
final daysDifference = today.difference(photoDay).inDays;
171
+
172
+
if (daysDifference <= 30) {
173
+
// Group by week for last 30 days
174
+
final weekStart = photoDay.subtract(Duration(days: photoDay.weekday - 1));
175
+
groupKey = 'Week of ${_formatDate(weekStart)}';
176
+
} else {
177
+
// Group by month for older photos
178
+
groupKey = '${_getMonthName(photoDate.month)} ${photoDate.year}';
179
+
}
180
+
}
181
+
182
+
groupedPhotos.putIfAbsent(groupKey, () => []).add(photo);
183
+
}
184
+
185
+
final List<PhotoGroup> groups = [];
186
+
187
+
// Sort and create PhotoGroup objects
188
+
final sortedEntries = groupedPhotos.entries.toList()
189
+
..sort((a, b) {
190
+
final aDate = _getGroupSortDate(a.key, a.value);
191
+
final bDate = _getGroupSortDate(b.key, b.value);
192
+
return bDate.compareTo(aDate); // Most recent first
193
+
});
194
+
195
+
for (final entry in sortedEntries) {
196
+
final sortedPhotos = entry.value
197
+
..sort((a, b) {
198
+
final aDate = _getPhotoDate(a);
199
+
final bDate = _getPhotoDate(b);
200
+
return bDate.compareTo(aDate); // Most recent first within group
201
+
});
202
+
203
+
groups.add(
204
+
PhotoGroup(
205
+
title: entry.key,
206
+
photos: sortedPhotos,
207
+
sortDate: _getGroupSortDate(entry.key, entry.value),
208
+
),
209
+
);
210
+
}
211
+
212
+
// Add photos without EXIF data at the end
213
+
if (noExifPhotos.isNotEmpty) {
214
+
groups.add(
215
+
PhotoGroup(
216
+
title: 'Photos without date info',
217
+
photos: noExifPhotos,
218
+
sortDate: DateTime(1970), // Very old date to sort at bottom
219
+
),
220
+
);
221
+
}
222
+
223
+
return groups;
224
+
}
225
+
226
+
DateTime _getGroupSortDate(String groupKey, List<GalleryPhoto> photos) {
227
+
if (groupKey == 'Today') return DateTime.now();
228
+
if (groupKey == 'Yesterday') return DateTime.now().subtract(const Duration(days: 1));
229
+
230
+
// For other groups, use the most recent photo date in the group
231
+
DateTime? latestDate;
232
+
for (final photo in photos) {
233
+
final photoDate = _getPhotoDate(photo);
234
+
if (latestDate == null || photoDate.isAfter(latestDate)) {
235
+
latestDate = photoDate;
236
+
}
237
+
}
238
+
return latestDate ?? DateTime(1970);
239
+
}
240
+
241
+
DateTime _getPhotoDate(GalleryPhoto photo) {
242
+
if (photo.exif?.record?['dateTimeOriginal'] != null) {
243
+
try {
244
+
final dateTimeOriginal = photo.exif!.record!['dateTimeOriginal'] as String;
245
+
return DateTime.parse(dateTimeOriginal);
246
+
} catch (e) {
247
+
// Fall back to a very old date if parsing fails
248
+
return DateTime(1970);
249
+
}
250
+
}
251
+
return DateTime(1970);
252
+
}
253
+
254
+
String _formatDate(DateTime date) {
255
+
const months = [
256
+
'Jan',
257
+
'Feb',
258
+
'Mar',
259
+
'Apr',
260
+
'May',
261
+
'Jun',
262
+
'Jul',
263
+
'Aug',
264
+
'Sep',
265
+
'Oct',
266
+
'Nov',
267
+
'Dec',
268
+
];
269
+
return '${months[date.month - 1]} ${date.day}';
270
+
}
271
+
272
+
String _getMonthName(int month) {
273
+
const months = [
274
+
'January',
275
+
'February',
276
+
'March',
277
+
'April',
278
+
'May',
279
+
'June',
280
+
'July',
281
+
'August',
282
+
'September',
283
+
'October',
284
+
'November',
285
+
'December',
286
+
];
287
+
return months[month - 1];
288
+
}
289
+
290
+
Future<void> _onRefresh() async {
291
+
await _loadPhotos();
292
+
}
293
+
294
+
void _showPhotoDetail(GalleryPhoto photo) {
295
+
// Create a flattened list of photos in the same order they appear on the page
296
+
final List<GalleryPhoto> orderedPhotos = [];
297
+
for (final group in _photoGroups) {
298
+
orderedPhotos.addAll(group.photos);
299
+
}
300
+
301
+
// Find the index of the photo in the ordered list
302
+
final photoIndex = orderedPhotos.indexOf(photo);
303
+
if (photoIndex == -1) return; // Photo not found, shouldn't happen
304
+
305
+
Navigator.of(context).push(
306
+
PageRouteBuilder(
307
+
pageBuilder: (context, animation, secondaryAnimation) => GalleryPhotoView(
308
+
photos: orderedPhotos,
309
+
initialIndex: photoIndex,
310
+
showAddCommentButton: false,
311
+
onClose: () => Navigator.of(context).pop(),
312
+
),
313
+
transitionDuration: const Duration(milliseconds: 200),
314
+
reverseTransitionDuration: const Duration(milliseconds: 200),
315
+
transitionsBuilder: (context, animation, secondaryAnimation, child) {
316
+
return FadeTransition(opacity: animation, child: child);
317
+
},
318
+
),
319
+
);
320
+
}
321
+
322
+
@override
323
+
Widget build(BuildContext context) {
324
+
final theme = Theme.of(context);
325
+
326
+
return Scaffold(
327
+
backgroundColor: theme.scaffoldBackgroundColor,
328
+
appBar: AppBar(
329
+
title: const Text('Photo Library'),
330
+
backgroundColor: theme.appBarTheme.backgroundColor,
331
+
surfaceTintColor: theme.appBarTheme.backgroundColor,
332
+
elevation: 0,
333
+
),
334
+
body: RefreshIndicator(onRefresh: _onRefresh, child: _buildBodyWithScrollbar(theme)),
335
+
);
336
+
}
337
+
338
+
Widget _buildBodyWithScrollbar(ThemeData theme) {
339
+
return Stack(
340
+
children: [
341
+
Padding(
342
+
padding: const EdgeInsets.only(right: 30), // Make room for scroll indicator
343
+
child: _buildBody(theme),
344
+
),
345
+
if (!_isLoading && _error == null && _photos.isNotEmpty) _buildScrollIndicator(theme),
346
+
],
347
+
);
348
+
}
349
+
350
+
Widget _buildScrollIndicator(ThemeData theme) {
351
+
return Positioned(
352
+
right: 4,
353
+
top: 0,
354
+
bottom: 0,
355
+
child: GestureDetector(
356
+
onPanUpdate: (details) {
357
+
if (_scrollController.hasClients) {
358
+
final RenderBox renderBox = context.findRenderObject() as RenderBox;
359
+
final localPosition = renderBox.globalToLocal(details.globalPosition);
360
+
final screenHeight = renderBox.size.height;
361
+
final maxScrollExtent = _scrollController.position.maxScrollExtent;
362
+
final relativePosition = (localPosition.dy / screenHeight).clamp(0.0, 1.0);
363
+
final newPosition = relativePosition * maxScrollExtent;
364
+
_scrollController.jumpTo(newPosition.clamp(0.0, maxScrollExtent));
365
+
}
366
+
},
367
+
onTapDown: (details) {
368
+
if (_scrollController.hasClients) {
369
+
final RenderBox renderBox = context.findRenderObject() as RenderBox;
370
+
final localPosition = renderBox.globalToLocal(details.globalPosition);
371
+
final screenHeight = renderBox.size.height;
372
+
final maxScrollExtent = _scrollController.position.maxScrollExtent;
373
+
final relativePosition = (localPosition.dy / screenHeight).clamp(0.0, 1.0);
374
+
final newPosition = relativePosition * maxScrollExtent;
375
+
_scrollController.animateTo(
376
+
newPosition.clamp(0.0, maxScrollExtent),
377
+
duration: const Duration(milliseconds: 200),
378
+
curve: Curves.easeInOut,
379
+
);
380
+
}
381
+
},
382
+
child: Container(
383
+
width: 24,
384
+
decoration: BoxDecoration(
385
+
color: theme.scaffoldBackgroundColor.withValues(alpha: 0.8),
386
+
borderRadius: BorderRadius.circular(12),
387
+
),
388
+
child: CustomPaint(
389
+
painter: ScrollIndicatorPainter(
390
+
scrollPosition: _scrollPosition,
391
+
maxScrollExtent: _scrollController.hasClients
392
+
? _scrollController.position.maxScrollExtent
393
+
: 0,
394
+
viewportHeight: _scrollController.hasClients
395
+
? _scrollController.position.viewportDimension
396
+
: 0,
397
+
color: theme.colorScheme.onSurface.withValues(alpha: 0.4),
398
+
activeColor: theme.colorScheme.primary,
399
+
currentGroupIndex: _getCurrentGroupIndex(),
400
+
totalGroups: _photoGroups.length,
401
+
),
402
+
),
403
+
),
404
+
),
405
+
);
406
+
}
407
+
408
+
Widget _buildBody(ThemeData theme) {
409
+
if (_isLoading) {
410
+
return const Center(child: CircularProgressIndicator());
411
+
}
412
+
413
+
if (_error != null) {
414
+
return Center(
415
+
child: Column(
416
+
mainAxisAlignment: MainAxisAlignment.center,
417
+
children: [
418
+
Icon(AppIcons.brokenImage, size: 64, color: theme.hintColor),
419
+
const SizedBox(height: 16),
420
+
Text(
421
+
_error!,
422
+
style: theme.textTheme.bodyLarge?.copyWith(color: theme.hintColor),
423
+
textAlign: TextAlign.center,
424
+
),
425
+
const SizedBox(height: 16),
426
+
ElevatedButton(onPressed: _loadPhotos, child: const Text('Retry')),
427
+
],
428
+
),
429
+
);
430
+
}
431
+
432
+
if (_photos.isEmpty) {
433
+
return Center(
434
+
child: Column(
435
+
mainAxisAlignment: MainAxisAlignment.center,
436
+
children: [
437
+
Icon(AppIcons.photoLibrary, size: 64, color: theme.hintColor),
438
+
const SizedBox(height: 16),
439
+
Text(
440
+
'No photos yet',
441
+
style: theme.textTheme.headlineSmall?.copyWith(color: theme.hintColor),
442
+
),
443
+
const SizedBox(height: 8),
444
+
Text(
445
+
'Upload some photos to see them here',
446
+
style: theme.textTheme.bodyLarge?.copyWith(color: theme.hintColor),
447
+
textAlign: TextAlign.center,
448
+
),
449
+
],
450
+
),
451
+
);
452
+
}
453
+
454
+
return ListView.builder(
455
+
controller: _scrollController,
456
+
padding: const EdgeInsets.all(16),
457
+
itemCount: _photoGroups.length,
458
+
itemBuilder: (context, index) {
459
+
final group = _photoGroups[index];
460
+
return _buildPhotoGroup(group, theme, index);
461
+
},
462
+
);
463
+
}
464
+
465
+
Widget _buildPhotoGroup(PhotoGroup group, ThemeData theme, int index) {
466
+
return Column(
467
+
crossAxisAlignment: CrossAxisAlignment.start,
468
+
children: [
469
+
Padding(
470
+
padding: EdgeInsets.only(bottom: 12, top: index == 0 ? 0 : 24),
471
+
child: Text(
472
+
group.title,
473
+
style: theme.textTheme.headlineSmall?.copyWith(
474
+
fontWeight: FontWeight.bold,
475
+
color: theme.colorScheme.onSurface,
476
+
),
477
+
),
478
+
),
479
+
_buildPhotoGrid(group.photos, theme),
480
+
],
481
+
);
482
+
}
483
+
484
+
Widget _buildPhotoGrid(List<GalleryPhoto> photos, ThemeData theme) {
485
+
final crossAxisCount = photos.length == 1 ? 1 : (photos.length == 2 ? 2 : 3);
486
+
final aspectRatio = photos.length <= 2 ? 1.5 : 1.0;
487
+
488
+
return GridView.builder(
489
+
shrinkWrap: true,
490
+
physics: const NeverScrollableScrollPhysics(),
491
+
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
492
+
crossAxisCount: crossAxisCount,
493
+
crossAxisSpacing: 4,
494
+
mainAxisSpacing: 4,
495
+
childAspectRatio: aspectRatio,
496
+
),
497
+
itemCount: photos.length,
498
+
itemBuilder: (context, index) {
499
+
final photo = photos[index];
500
+
return _buildPhotoTile(photo, theme);
501
+
},
502
+
);
503
+
}
504
+
505
+
Widget _buildPhotoTile(GalleryPhoto photo, ThemeData theme) {
506
+
return GestureDetector(
507
+
onTap: () => _showPhotoDetail(photo),
508
+
child: Hero(
509
+
tag: 'photo-${photo.uri}',
510
+
child: Container(
511
+
decoration: BoxDecoration(borderRadius: BorderRadius.circular(8), color: theme.cardColor),
512
+
clipBehavior: Clip.antiAlias,
513
+
child: AppImage(
514
+
url: photo.thumb ?? photo.fullsize,
515
+
fit: BoxFit.cover,
516
+
width: double.infinity,
517
+
height: double.infinity,
518
+
placeholder: Container(
519
+
color: theme.hintColor.withValues(alpha: 0.1),
520
+
child: Icon(AppIcons.photo, color: theme.hintColor, size: 32),
521
+
),
522
+
errorWidget: Container(
523
+
color: theme.hintColor.withValues(alpha: 0.1),
524
+
child: Icon(AppIcons.brokenImage, color: theme.hintColor, size: 32),
525
+
),
526
+
),
527
+
),
528
+
),
529
+
);
530
+
}
531
+
}
532
+
533
+
class ScrollIndicatorPainter extends CustomPainter {
534
+
final double scrollPosition;
535
+
final double maxScrollExtent;
536
+
final double viewportHeight;
537
+
final Color color;
538
+
final Color activeColor;
539
+
final int currentGroupIndex;
540
+
final int totalGroups;
541
+
542
+
ScrollIndicatorPainter({
543
+
required this.scrollPosition,
544
+
required this.maxScrollExtent,
545
+
required this.viewportHeight,
546
+
required this.color,
547
+
required this.activeColor,
548
+
required this.currentGroupIndex,
549
+
required this.totalGroups,
550
+
});
551
+
552
+
@override
553
+
void paint(Canvas canvas, Size size) {
554
+
const dashCount = 60; // Number of dashes to show (doubled from 30)
555
+
const dashHeight = 2.0; // Height when vertical (now width)
556
+
const dashWidth = 12.0; // Width when vertical (now height)
557
+
558
+
// Calculate spacing to fill the full height
559
+
final availableHeight = size.height;
560
+
final totalDashHeight = dashCount * dashHeight;
561
+
final totalSpacing = availableHeight - totalDashHeight;
562
+
final dashSpacing = totalSpacing / (dashCount - 1);
563
+
564
+
// Calculate which dash should be active based on current group and total groups
565
+
int activeDashIndex;
566
+
if (totalGroups > 0) {
567
+
// Map current group to dash index (more accurate than scroll position)
568
+
final groupProgress = currentGroupIndex / (totalGroups - 1).clamp(1, totalGroups);
569
+
activeDashIndex = (groupProgress * (dashCount - 1)).round().clamp(0, dashCount - 1);
570
+
} else {
571
+
// Fallback to scroll position if no groups
572
+
final scrollProgress = maxScrollExtent > 0
573
+
? (scrollPosition / maxScrollExtent).clamp(0.0, 1.0)
574
+
: 0.0;
575
+
activeDashIndex = (scrollProgress * (dashCount - 1)).round();
576
+
}
577
+
578
+
for (int i = 0; i < dashCount; i++) {
579
+
final y = i * (dashHeight + dashSpacing);
580
+
final isActive = i == activeDashIndex;
581
+
582
+
final paint = Paint()
583
+
..color = isActive ? activeColor : color
584
+
..style = PaintingStyle.fill;
585
+
586
+
// Create vertical dashes (rotated 90 degrees)
587
+
final rect = Rect.fromLTWH((size.width - dashWidth) / 2, y, dashWidth, dashHeight);
588
+
589
+
canvas.drawRRect(RRect.fromRectAndRadius(rect, const Radius.circular(1)), paint);
590
+
}
591
+
}
592
+
593
+
@override
594
+
bool shouldRepaint(ScrollIndicatorPainter oldDelegate) {
595
+
return scrollPosition != oldDelegate.scrollPosition ||
596
+
maxScrollExtent != oldDelegate.maxScrollExtent ||
597
+
viewportHeight != oldDelegate.viewportHeight ||
598
+
currentGroupIndex != oldDelegate.currentGroupIndex ||
599
+
totalGroups != oldDelegate.totalGroups;
600
+
}
601
+
}
+15
lib/widgets/app_drawer.dart
+15
lib/widgets/app_drawer.dart
···
2
2
import 'package:grain/api.dart';
3
3
import 'package:grain/app_icons.dart';
4
4
import 'package:grain/screens/log_page.dart';
5
+
import 'package:grain/screens/photo_library_page.dart';
5
6
import 'package:grain/widgets/app_version_text.dart';
6
7
7
8
class AppDrawer extends StatelessWidget {
···
176
177
onTap: () {
177
178
Navigator.pop(context);
178
179
onProfile();
180
+
},
181
+
),
182
+
ListTile(
183
+
leading: Icon(
184
+
AppIcons.photoLibrary,
185
+
size: 18,
186
+
color: activeIndex == 4 ? theme.colorScheme.primary : theme.iconTheme.color,
187
+
),
188
+
title: const Text('Photo Library'),
189
+
onTap: () {
190
+
Navigator.pop(context);
191
+
Navigator.of(
192
+
context,
193
+
).push(MaterialPageRoute(builder: (context) => const PhotoLibraryPage()));
179
194
},
180
195
),
181
196
ListTile(
+19
lib/widgets/gallery_photo_view.dart
+19
lib/widgets/gallery_photo_view.dart
···
168
168
),
169
169
),
170
170
),
171
+
if (!widget.showAddCommentButton && photo.exif != null)
172
+
SafeArea(
173
+
top: false,
174
+
child: Padding(
175
+
padding: const EdgeInsets.all(16),
176
+
child: Align(
177
+
alignment: Alignment.centerRight,
178
+
child: IconButton(
179
+
icon: Icon(Icons.camera_alt, color: Colors.white),
180
+
onPressed: () {
181
+
showDialog(
182
+
context: context,
183
+
builder: (context) => PhotoExifDialog(exif: photo.exif!),
184
+
);
185
+
},
186
+
),
187
+
),
188
+
),
189
+
),
171
190
],
172
191
),
173
192
),
+1
-1
pubspec.yaml
+1
-1
pubspec.yaml
···
16
16
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
17
17
# In Windows, build-name is used as the major, minor, and patch parts
18
18
# of the product and file versions while build-number is used as the build suffix.
19
-
version: 1.0.0+23
19
+
version: 1.0.0+24
20
20
21
21
environment:
22
22
sdk: ^3.8.1