feat: add PhotoLibraryPage to display all actor photos by date

+1
lib/models/photo_exif.dart
··· 20 String? lensModel, 21 String? make, 22 String? model, 23 }) = _PhotoExif; 24 25 factory PhotoExif.fromJson(Map<String, dynamic> json) => _$PhotoExifFromJson(json);
··· 20 String? lensModel, 21 String? make, 22 String? model, 23 + Map<String, dynamic>? record, 24 }) = _PhotoExif; 25 26 factory PhotoExif.fromJson(Map<String, dynamic> json) => _$PhotoExifFromJson(json);
+31 -3
lib/models/photo_exif.freezed.dart
··· 36 String? get lensModel => throw _privateConstructorUsedError; 37 String? get make => throw _privateConstructorUsedError; 38 String? get model => throw _privateConstructorUsedError; 39 40 /// Serializes this PhotoExif to a JSON map. 41 Map<String, dynamic> toJson() => throw _privateConstructorUsedError; ··· 67 String? lensModel, 68 String? make, 69 String? model, 70 }); 71 } 72 ··· 99 Object? lensModel = freezed, 100 Object? make = freezed, 101 Object? model = freezed, 102 }) { 103 return _then( 104 _value.copyWith( ··· 158 ? _value.model 159 : model // ignore: cast_nullable_to_non_nullable 160 as String?, 161 ) 162 as $Val, 163 ); ··· 188 String? lensModel, 189 String? make, 190 String? model, 191 }); 192 } 193 ··· 219 Object? lensModel = freezed, 220 Object? make = freezed, 221 Object? model = freezed, 222 }) { 223 return _then( 224 _$PhotoExifImpl( ··· 278 ? _value.model 279 : model // ignore: cast_nullable_to_non_nullable 280 as String?, 281 ), 282 ); 283 } ··· 301 this.lensModel, 302 this.make, 303 this.model, 304 - }); 305 306 factory _$PhotoExifImpl.fromJson(Map<String, dynamic> json) => 307 _$$PhotoExifImplFromJson(json); ··· 339 final String? make; 340 @override 341 final String? model; 342 343 @override 344 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)'; 346 } 347 348 @override ··· 372 (identical(other.lensModel, lensModel) || 373 other.lensModel == lensModel) && 374 (identical(other.make, make) || other.make == make) && 375 - (identical(other.model, model) || other.model == model)); 376 } 377 378 @JsonKey(includeFromJson: false, includeToJson: false) ··· 393 lensModel, 394 make, 395 model, 396 ); 397 398 /// Create a copy of PhotoExif ··· 425 final String? lensModel, 426 final String? make, 427 final String? model, 428 }) = _$PhotoExifImpl; 429 430 factory _PhotoExif.fromJson(Map<String, dynamic> json) = ··· 458 String? get make; 459 @override 460 String? get model; 461 462 /// Create a copy of PhotoExif 463 /// with the given fields replaced by the non-null parameter values.
··· 36 String? get lensModel => throw _privateConstructorUsedError; 37 String? get make => throw _privateConstructorUsedError; 38 String? get model => throw _privateConstructorUsedError; 39 + Map<String, dynamic>? get record => throw _privateConstructorUsedError; 40 41 /// Serializes this PhotoExif to a JSON map. 42 Map<String, dynamic> toJson() => throw _privateConstructorUsedError; ··· 68 String? lensModel, 69 String? make, 70 String? model, 71 + Map<String, dynamic>? record, 72 }); 73 } 74 ··· 101 Object? lensModel = freezed, 102 Object? make = freezed, 103 Object? model = freezed, 104 + Object? record = freezed, 105 }) { 106 return _then( 107 _value.copyWith( ··· 161 ? _value.model 162 : model // ignore: cast_nullable_to_non_nullable 163 as String?, 164 + record: freezed == record 165 + ? _value.record 166 + : record // ignore: cast_nullable_to_non_nullable 167 + as Map<String, dynamic>?, 168 ) 169 as $Val, 170 ); ··· 195 String? lensModel, 196 String? make, 197 String? model, 198 + Map<String, dynamic>? record, 199 }); 200 } 201 ··· 227 Object? lensModel = freezed, 228 Object? make = freezed, 229 Object? model = freezed, 230 + Object? record = freezed, 231 }) { 232 return _then( 233 _$PhotoExifImpl( ··· 287 ? _value.model 288 : model // ignore: cast_nullable_to_non_nullable 289 as String?, 290 + record: freezed == record 291 + ? _value._record 292 + : record // ignore: cast_nullable_to_non_nullable 293 + as Map<String, dynamic>?, 294 ), 295 ); 296 } ··· 314 this.lensModel, 315 this.make, 316 this.model, 317 + final Map<String, dynamic>? record, 318 + }) : _record = record; 319 320 factory _$PhotoExifImpl.fromJson(Map<String, dynamic> json) => 321 _$$PhotoExifImplFromJson(json); ··· 353 final String? make; 354 @override 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 + } 365 366 @override 367 String toString() { 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)'; 369 } 370 371 @override ··· 395 (identical(other.lensModel, lensModel) || 396 other.lensModel == lensModel) && 397 (identical(other.make, make) || other.make == make) && 398 + (identical(other.model, model) || other.model == model) && 399 + const DeepCollectionEquality().equals(other._record, _record)); 400 } 401 402 @JsonKey(includeFromJson: false, includeToJson: false) ··· 417 lensModel, 418 make, 419 model, 420 + const DeepCollectionEquality().hash(_record), 421 ); 422 423 /// Create a copy of PhotoExif ··· 450 final String? lensModel, 451 final String? make, 452 final String? model, 453 + final Map<String, dynamic>? record, 454 }) = _$PhotoExifImpl; 455 456 factory _PhotoExif.fromJson(Map<String, dynamic> json) = ··· 484 String? get make; 485 @override 486 String? get model; 487 + @override 488 + Map<String, dynamic>? get record; 489 490 /// Create a copy of PhotoExif 491 /// with the given fields replaced by the non-null parameter values.
+2
lib/models/photo_exif.g.dart
··· 22 lensModel: json['lensModel'] as String?, 23 make: json['make'] as String?, 24 model: json['model'] as String?, 25 ); 26 27 Map<String, dynamic> _$$PhotoExifImplToJson(_$PhotoExifImpl instance) => ··· 40 'lensModel': instance.lensModel, 41 'make': instance.make, 42 'model': instance.model, 43 };
··· 22 lensModel: json['lensModel'] as String?, 23 make: json['make'] as String?, 24 model: json['model'] as String?, 25 + record: json['record'] as Map<String, dynamic>?, 26 ); 27 28 Map<String, dynamic> _$$PhotoExifImplToJson(_$PhotoExifImpl instance) => ··· 41 'lensModel': instance.lensModel, 42 'make': instance.make, 43 'model': instance.model, 44 + 'record': instance.record, 45 };
+1 -1
lib/providers/gallery_cache_provider.g.dart
··· 6 // RiverpodGenerator 7 // ************************************************************************** 8 9 - String _$galleryCacheHash() => r'd74ced0d6fcf6369bed80f7f0219bd591c13db5a'; 10 11 /// Holds a cache of galleries by URI. 12 ///
··· 6 // RiverpodGenerator 7 // ************************************************************************** 8 9 + String _$galleryCacheHash() => r'd604bfc71f008251a36d7943b99294728c31de1f'; 10 11 /// Holds a cache of galleries by URI. 12 ///
+1 -1
lib/providers/profile_provider.g.dart
··· 6 // RiverpodGenerator 7 // ************************************************************************** 8 9 - String _$profileNotifierHash() => r'48159a8319bba2f2ec5462c50d80ba6a5b72d91e'; 10 11 /// Copied from Dart SDK 12 class _SystemHash {
··· 6 // RiverpodGenerator 7 // ************************************************************************** 8 9 + String _$profileNotifierHash() => r'4b8e3a8d4363beb885ead4ae7ce9c52101a6bf96'; 10 11 /// Copied from Dart SDK 12 class _SystemHash {
+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
··· 2 import 'package:grain/api.dart'; 3 import 'package:grain/app_icons.dart'; 4 import 'package:grain/screens/log_page.dart'; 5 import 'package:grain/widgets/app_version_text.dart'; 6 7 class AppDrawer extends StatelessWidget { ··· 176 onTap: () { 177 Navigator.pop(context); 178 onProfile(); 179 }, 180 ), 181 ListTile(
··· 2 import 'package:grain/api.dart'; 3 import 'package:grain/app_icons.dart'; 4 import 'package:grain/screens/log_page.dart'; 5 + import 'package:grain/screens/photo_library_page.dart'; 6 import 'package:grain/widgets/app_version_text.dart'; 7 8 class AppDrawer extends StatelessWidget { ··· 177 onTap: () { 178 Navigator.pop(context); 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())); 194 }, 195 ), 196 ListTile(
+19
lib/widgets/gallery_photo_view.dart
··· 168 ), 169 ), 170 ), 171 ], 172 ), 173 ),
··· 168 ), 169 ), 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 + ), 190 ], 191 ), 192 ),
+1 -1
pubspec.yaml
··· 16 # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html 17 # In Windows, build-name is used as the major, minor, and patch parts 18 # of the product and file versions while build-number is used as the build suffix. 19 - version: 1.0.0+23 20 21 environment: 22 sdk: ^3.8.1
··· 16 # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html 17 # In Windows, build-name is used as the major, minor, and patch parts 18 # of the product and file versions while build-number is used as the build suffix. 19 + version: 1.0.0+24 20 21 environment: 22 sdk: ^3.8.1