forked from
grain.social/native
this repo has no description
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}