this repo has no description
at main 416 lines 18 kB view raw
1import 'package:flutter/material.dart'; 2import 'package:flutter_riverpod/flutter_riverpod.dart'; 3import 'package:grain/api.dart'; 4import 'package:grain/app_icons.dart'; 5import 'package:grain/models/gallery_photo.dart'; 6import 'package:grain/providers/gallery_cache_provider.dart'; 7import 'package:grain/providers/profile_provider.dart'; 8import 'package:grain/screens/comments_page.dart'; 9import 'package:grain/screens/create_gallery_page.dart'; 10import 'package:grain/screens/edit_alt_text_sheet.dart'; 11import 'package:grain/screens/gallery_action_sheet.dart'; 12import 'package:grain/screens/gallery_edit_photos_sheet.dart'; 13import 'package:grain/screens/gallery_sort_order_sheet.dart'; 14import 'package:grain/screens/hashtag_page.dart'; 15import 'package:grain/screens/home_page.dart'; 16import 'package:grain/screens/profile_page.dart'; 17import 'package:grain/widgets/app_image.dart'; 18import 'package:grain/widgets/camera_pills.dart'; 19import 'package:grain/widgets/faceted_text.dart'; 20import 'package:grain/widgets/gallery_action_buttons.dart'; 21import 'package:grain/widgets/gallery_photo_view.dart'; 22import 'package:grain/widgets/justified_gallery_view.dart'; 23import 'package:url_launcher/url_launcher.dart'; 24 25class GalleryPage extends ConsumerStatefulWidget { 26 final String uri; 27 final String? currentUserDid; 28 const GalleryPage({super.key, required this.uri, this.currentUserDid}); 29 30 @override 31 ConsumerState<GalleryPage> createState() => _GalleryPageState(); 32} 33 34class _GalleryPageState extends ConsumerState<GalleryPage> { 35 bool _loading = true; 36 bool _error = false; 37 GalleryPhoto? _selectedPhoto; 38 int? _selectedPhotoIndex; 39 40 @override 41 void initState() { 42 super.initState(); 43 // Only fetch if not already in cache 44 final cached = ref.read(galleryCacheProvider)[widget.uri]; 45 if (cached == null) { 46 _maybeFetchGallery(); 47 } else { 48 setState(() { 49 _loading = false; 50 _error = false; 51 }); 52 } 53 } 54 55 Future<void> _maybeFetchGallery({bool forceRefresh = false}) async { 56 // Only fetch from API if not in cache or forceRefresh is true 57 if (!forceRefresh) { 58 final cached = ref.read(galleryCacheProvider)[widget.uri]; 59 if (cached != null) { 60 setState(() { 61 _loading = false; 62 _error = false; 63 }); 64 return; 65 } 66 } 67 setState(() { 68 _loading = true; 69 _error = false; 70 }); 71 try { 72 final gallery = await apiService.getGallery(uri: widget.uri); 73 if (gallery != null) { 74 ref.read(galleryCacheProvider.notifier).setGallery(gallery); 75 setState(() { 76 _loading = false; 77 }); 78 } else { 79 setState(() { 80 _error = true; 81 _loading = false; 82 }); 83 } 84 } catch (e) { 85 setState(() { 86 _error = true; 87 _loading = false; 88 }); 89 } 90 } 91 92 @override 93 Widget build(BuildContext context) { 94 final theme = Theme.of(context); 95 final gallery = ref.watch(galleryCacheProvider)[widget.uri]; 96 if (_loading) { 97 return Scaffold( 98 backgroundColor: theme.scaffoldBackgroundColor, 99 body: Center(child: CircularProgressIndicator(strokeWidth: 2, color: theme.primaryColor)), 100 ); 101 } 102 if (_error || gallery == null) { 103 return Scaffold( 104 backgroundColor: theme.scaffoldBackgroundColor, 105 body: const Center(child: Text('Failed to load gallery.')), 106 ); 107 } 108 final isLoggedIn = widget.currentUserDid != null; 109 final galleryItems = gallery.items.where((item) => item.thumb?.isNotEmpty ?? false).toList(); 110 111 return Stack( 112 children: [ 113 Scaffold( 114 backgroundColor: theme.scaffoldBackgroundColor, 115 appBar: AppBar( 116 backgroundColor: theme.appBarTheme.backgroundColor, 117 surfaceTintColor: theme.appBarTheme.backgroundColor, 118 title: Text('Gallery'), 119 iconTheme: theme.appBarTheme.iconTheme, 120 titleTextStyle: theme.appBarTheme.titleTextStyle, 121 actions: [ 122 if (gallery.creator?.did == widget.currentUserDid) 123 IconButton( 124 icon: const Icon(AppIcons.moreVertical), 125 tooltip: 'Gallery Actions', 126 onPressed: () async { 127 showModalBottomSheet( 128 context: context, 129 builder: (sheetContext) => GalleryActionSheet( 130 parentContext: context, 131 onEditDetails: () async { 132 await showCreateGallerySheet(context, gallery: gallery); 133 _maybeFetchGallery(); 134 }, 135 onEditPhotos: () { 136 showGalleryEditPhotosSheet( 137 context, 138 galleryUri: gallery.uri, 139 allPhotos: gallery.items, 140 onSave: (newSelection) { 141 // TODO: Save new selection to backend and refresh gallery 142 }, 143 ); 144 }, 145 onEditAltText: () { 146 showEditAltTextSheet( 147 context, 148 photos: gallery.items, 149 onSave: (altTexts) async { 150 // altTexts: Map<String, String?> (photoUri -> alt) 151 final altUpdates = altTexts.entries 152 .map((e) => {'photoUri': e.key, 'alt': e.value}) 153 .toList(); 154 await ref 155 .read(galleryCacheProvider.notifier) 156 .updatePhotoAltTexts( 157 galleryUri: gallery.uri, 158 altUpdates: altUpdates, 159 ); 160 }, 161 ); 162 }, 163 onChangeSortOrder: () { 164 showGallerySortOrderSheet( 165 context, 166 photos: galleryItems, 167 onReorderDone: (newOrder, sheetContext) async { 168 await ref 169 .read(galleryCacheProvider.notifier) 170 .reorderGalleryItems(galleryUri: gallery.uri, newOrder: newOrder); 171 if (!sheetContext.mounted) return; 172 Navigator.of(sheetContext).pop(); 173 if (!mounted) return; 174 Navigator.of(context).pop(); 175 }, 176 ); 177 }, 178 onDeleteGallery: (sheetContext) async { 179 await ref.read(galleryCacheProvider.notifier).deleteGallery(gallery.uri); 180 ref 181 .read(profileNotifierProvider(widget.currentUserDid!).notifier) 182 .removeGalleryFromProfile(gallery.uri); 183 if (!sheetContext.mounted) return; 184 Navigator.of(sheetContext).pop(); // Close the action sheet 185 if (!mounted) return; 186 Navigator.of(context).pushAndRemoveUntil( 187 MaterialPageRoute( 188 builder: (_) => MyHomePage( 189 title: 'Grain', 190 initialTab: 3, // Profile tab 191 did: widget.currentUserDid, 192 ), 193 ), 194 (route) => false, 195 ); 196 return; 197 }, 198 ), 199 ); 200 }, 201 ), 202 ], 203 ), 204 body: RefreshIndicator( 205 onRefresh: () => _maybeFetchGallery(forceRefresh: true), 206 child: ListView( 207 children: [ 208 Padding( 209 padding: const EdgeInsets.symmetric(horizontal: 8), 210 child: Column( 211 crossAxisAlignment: CrossAxisAlignment.start, 212 children: [ 213 const SizedBox(height: 10), 214 Text( 215 gallery.title?.isNotEmpty == true ? gallery.title! : 'Gallery', 216 style: theme.textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.w600), 217 ), 218 const SizedBox(height: 10), 219 Row( 220 crossAxisAlignment: CrossAxisAlignment.center, 221 children: [ 222 GestureDetector( 223 onTap: gallery.creator != null && gallery.creator!.did.isNotEmpty 224 ? () { 225 Navigator.of(context).push( 226 MaterialPageRoute( 227 builder: (context) => ProfilePage( 228 did: gallery.creator!.did, 229 showAppBar: true, 230 ), 231 ), 232 ); 233 } 234 : null, 235 child: CircleAvatar( 236 radius: 18, 237 backgroundColor: theme.colorScheme.surfaceContainerHighest, 238 backgroundImage: 239 gallery.creator?.avatar != null && 240 gallery.creator!.avatar?.isNotEmpty == true 241 ? null 242 : null, 243 child: 244 (gallery.creator == null || 245 (gallery.creator!.avatar?.isNotEmpty != true)) 246 ? Icon( 247 AppIcons.accountCircle, 248 size: 24, 249 color: theme.colorScheme.onSurface.withOpacity(0.4), 250 ) 251 : ClipOval( 252 child: AppImage( 253 url: gallery.creator!.avatar!, 254 width: 36, 255 height: 36, 256 fit: BoxFit.cover, 257 ), 258 ), 259 ), 260 ), 261 const SizedBox(width: 12), 262 Expanded( 263 child: GestureDetector( 264 onTap: gallery.creator != null && gallery.creator!.did.isNotEmpty 265 ? () { 266 Navigator.of(context).push( 267 MaterialPageRoute( 268 builder: (context) => ProfilePage( 269 did: gallery.creator!.did, 270 showAppBar: true, 271 ), 272 ), 273 ); 274 } 275 : null, 276 child: Column( 277 crossAxisAlignment: CrossAxisAlignment.start, 278 children: [ 279 Text( 280 gallery.creator?.displayName ?? '', 281 style: theme.textTheme.bodyLarge?.copyWith( 282 fontWeight: FontWeight.w600, 283 ), 284 ), 285 if ((gallery.creator?.handle ?? '').isNotEmpty) 286 Text( 287 '@${gallery.creator?.handle ?? ''}', 288 style: theme.textTheme.bodyMedium?.copyWith( 289 color: theme.hintColor, 290 ), 291 ), 292 ], 293 ), 294 ), 295 ), 296 ], 297 ), 298 ], 299 ), 300 ), 301 const SizedBox(height: 12), 302 if ((gallery.description?.isNotEmpty ?? false)) 303 Padding( 304 padding: const EdgeInsets.symmetric(horizontal: 8).copyWith(bottom: 8), 305 child: FacetedText( 306 text: gallery.description ?? '', 307 facets: gallery.facets, 308 style: theme.textTheme.bodyMedium?.copyWith( 309 color: theme.colorScheme.onSurface, 310 ), 311 linkStyle: theme.textTheme.bodyMedium?.copyWith( 312 color: theme.colorScheme.primary, 313 fontWeight: FontWeight.w600, 314 ), 315 onMentionTap: (did) { 316 Navigator.of(context).push( 317 MaterialPageRoute( 318 builder: (context) => ProfilePage(did: did, showAppBar: true), 319 ), 320 ); 321 }, 322 onLinkTap: (url) async { 323 final uri = Uri.parse(url); 324 if (!await launchUrl(uri)) { 325 throw Exception('Could not launch $url'); 326 } 327 }, 328 onTagTap: (tag) => Navigator.push( 329 context, 330 MaterialPageRoute(builder: (_) => HashtagPage(hashtag: tag)), 331 ), 332 ), 333 ), 334 if ((gallery.cameras?.isNotEmpty ?? false)) 335 Padding( 336 padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8), 337 child: CameraPills(cameras: gallery.cameras!), 338 ), 339 if (isLoggedIn) 340 Padding( 341 padding: const EdgeInsets.symmetric(horizontal: 8), 342 child: GalleryActionButtons( 343 gallery: gallery, 344 parentContext: context, 345 currentUserDid: widget.currentUserDid, 346 isLoggedIn: isLoggedIn, 347 ), 348 ), 349 const SizedBox(height: 8), 350 // Gallery items grid (edge-to-edge) 351 if (galleryItems.isNotEmpty) 352 JustifiedGalleryView( 353 items: galleryItems, 354 onImageTap: (index) { 355 if (index >= 0 && index < galleryItems.length) { 356 setState(() { 357 _selectedPhoto = galleryItems[index]; 358 _selectedPhotoIndex = index; 359 }); 360 } 361 }, 362 ), 363 if (galleryItems.isEmpty) 364 Center( 365 child: Text('No photos in this gallery.', style: theme.textTheme.bodyMedium), 366 ), 367 ], 368 ), 369 ), 370 ), 371 if (_selectedPhoto != null && _selectedPhotoIndex != null) 372 Positioned.fill( 373 child: Stack( 374 children: [ 375 GestureDetector( 376 onTap: () { 377 setState(() { 378 _selectedPhoto = null; 379 _selectedPhotoIndex = null; 380 }); 381 }, 382 child: Container(color: Colors.black.withOpacity(0.85)), 383 ), 384 Center( 385 child: GalleryPhotoView( 386 photos: galleryItems, 387 initialIndex: _selectedPhotoIndex!, 388 onClose: () { 389 setState(() { 390 _selectedPhoto = null; 391 _selectedPhotoIndex = null; 392 }); 393 }, 394 onCommentPosted: (galleryUri) async { 395 setState(() => _selectedPhoto = null); // Remove overlay 396 await Future.delayed(const Duration(milliseconds: 200)); 397 WidgetsBinding.instance.addPostFrameCallback((_) { 398 if (mounted) { 399 Navigator.of(context).push( 400 MaterialPageRoute( 401 builder: (context) => CommentsPage(galleryUri: galleryUri), 402 ), 403 ); 404 } 405 }); 406 }, 407 gallery: gallery, // Pass the gallery object 408 ), 409 ), 410 ], 411 ), 412 ), 413 ], 414 ); 415 } 416}