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/app_theme.dart';
6import 'package:grain/models/gallery.dart';
7import 'package:grain/models/profile_with_galleries.dart';
8import 'package:grain/providers/profile_provider.dart';
9import 'package:grain/screens/hashtag_page.dart';
10import 'package:grain/widgets/app_button.dart';
11import 'package:grain/widgets/app_image.dart';
12import 'package:grain/widgets/camera_pills.dart';
13import 'package:grain/widgets/edit_profile_sheet.dart';
14import 'package:grain/widgets/faceted_text.dart';
15import 'package:url_launcher/url_launcher.dart';
16
17import 'followers_page.dart';
18import 'follows_page.dart';
19import 'gallery_page.dart';
20
21class ProfilePage extends ConsumerStatefulWidget {
22 final dynamic profile;
23 final String? did;
24 final bool showAppBar;
25 const ProfilePage({super.key, this.profile, this.did, this.showAppBar = false});
26
27 @override
28 ConsumerState<ProfilePage> createState() => _ProfilePageState();
29}
30
31class _ProfilePageState extends ConsumerState<ProfilePage> {
32 int _selectedSection = 0; // 0 = Galleries, 1 = Favs
33
34 // Refactored: Just pop the sheet after save, don't return edited values
35 Future<void> _handleProfileSave(
36 String did,
37 String displayName,
38 String description,
39 dynamic avatarFile,
40 ) async {
41 final notifier = ref.read(profileNotifierProvider(did).notifier);
42 final success = await notifier.updateProfile(
43 displayName: displayName,
44 description: description,
45 avatarFile: avatarFile,
46 );
47 if (!mounted) return;
48 if (success) {
49 Navigator.of(context).pop();
50 if (mounted) setState(() {}); // Force widget rebuild after modal closes
51 } else {
52 if (!mounted) return;
53 ScaffoldMessenger.of(
54 context,
55 ).showSnackBar(const SnackBar(content: Text('Failed to update profile')));
56 }
57 }
58
59 void _showAvatarFullscreen(String avatarUrl) {
60 showDialog(
61 context: context,
62 barrierColor: Colors.black.withOpacity(0.95),
63 builder: (context) {
64 final size = MediaQuery.of(context).size;
65 final diameter = size.width;
66 return Stack(
67 children: [
68 GestureDetector(
69 onTap: () => Navigator.of(context).pop(),
70 child: Center(
71 child: Hero(
72 tag: 'profile-avatar',
73 child: ClipOval(
74 child: SizedBox(
75 width: diameter,
76 height: diameter,
77 child: AppImage(
78 url: avatarUrl,
79 fit: BoxFit.cover,
80 width: diameter,
81 height: diameter,
82 ),
83 ),
84 ),
85 ),
86 ),
87 ),
88 Positioned(
89 top: 40,
90 right: 24,
91 child: SafeArea(
92 child: IconButton(
93 icon: Icon(AppIcons.closeRounded, color: Colors.white, size: 36),
94 onPressed: () => Navigator.of(context).pop(),
95 tooltip: 'Close',
96 ),
97 ),
98 ),
99 ],
100 );
101 },
102 );
103 }
104
105 @override
106 Widget build(BuildContext context) {
107 final theme = Theme.of(context);
108 final did = widget.did ?? widget.profile?.did;
109 final asyncProfile = did != null
110 ? ref.watch(profileNotifierProvider(did))
111 : const AsyncValue<ProfileWithGalleries?>.loading();
112
113 Future<void> refreshProfile() async {
114 if (did != null) {
115 final _ = await ref.refresh(profileNotifierProvider(did).future);
116 setState(() {});
117 }
118 }
119
120 return asyncProfile.when(
121 loading: () => Scaffold(
122 backgroundColor: theme.scaffoldBackgroundColor,
123 body: Center(
124 child: CircularProgressIndicator(strokeWidth: 2, color: theme.colorScheme.primary),
125 ),
126 ),
127 error: (err, stack) => Scaffold(
128 backgroundColor: theme.scaffoldBackgroundColor,
129 body: Center(child: Text('Failed to load profile')),
130 ),
131 data: (profileWithGalleries) {
132 if (profileWithGalleries == null) {
133 return Scaffold(
134 backgroundColor: theme.scaffoldBackgroundColor,
135 body: Center(child: Text('Profile not found')),
136 );
137 }
138 final profile = profileWithGalleries.profile;
139 final galleries = profileWithGalleries.galleries;
140 final favs = profileWithGalleries.favs;
141
142 return Scaffold(
143 backgroundColor: theme.scaffoldBackgroundColor,
144 appBar: widget.showAppBar
145 ? AppBar(
146 backgroundColor: theme.appBarTheme.backgroundColor,
147 surfaceTintColor: theme.appBarTheme.backgroundColor,
148 leading: const BackButton(),
149 )
150 : null,
151 body: SafeArea(
152 bottom: false,
153 child: RefreshIndicator(
154 onRefresh: refreshProfile,
155 child: SingleChildScrollView(
156 padding: EdgeInsets.zero,
157 physics: const AlwaysScrollableScrollPhysics(),
158 child: Column(
159 crossAxisAlignment: CrossAxisAlignment.start,
160 children: [
161 Padding(
162 padding: const EdgeInsets.symmetric(horizontal: 8),
163 child: Column(
164 crossAxisAlignment: CrossAxisAlignment.start,
165 children: [
166 const SizedBox(height: 16),
167 Row(
168 crossAxisAlignment: CrossAxisAlignment.start,
169 children: [
170 // Avatar
171 if (profile.avatar != null)
172 GestureDetector(
173 onTap: () => _showAvatarFullscreen(profile.avatar!),
174 child: ClipOval(
175 child: AppImage(
176 url: profile.avatar,
177 width: 64,
178 height: 64,
179 fit: BoxFit.cover,
180 ),
181 ),
182 )
183 else
184 Icon(AppIcons.accountCircle, size: 64, color: Colors.grey),
185 const Spacer(),
186 // Follow/Unfollow button
187 if (profile.did != apiService.currentUser?.did)
188 SizedBox(
189 child: AppButton(
190 size: AppButtonSize.small,
191 variant: profile.viewer?.following?.isNotEmpty == true
192 ? AppButtonVariant.secondary
193 : AppButtonVariant.primary,
194 onPressed: () async {
195 await ref
196 .read(profileNotifierProvider(profile.did).notifier)
197 .toggleFollow(apiService.currentUser?.did);
198 },
199 label: (profile.viewer?.following?.isNotEmpty == true)
200 ? 'Following'
201 : 'Follow',
202 ),
203 )
204 // Edit Profile button for current user
205 else
206 SizedBox(
207 child: AppButton(
208 size: AppButtonSize.small,
209 variant: AppButtonVariant.secondary,
210 onPressed: () async {
211 showEditProfileSheet(
212 context,
213 initialDisplayName: profile.displayName,
214 initialDescription: profile.description,
215 initialAvatarUrl: profile.avatar,
216 onSave: (displayName, description, avatarFile) async {
217 await _handleProfileSave(
218 profile.did,
219 displayName,
220 description,
221 avatarFile,
222 );
223 },
224 onCancel: () {
225 Navigator.of(context).maybePop();
226 },
227 );
228 },
229 label: 'Edit profile',
230 ),
231 ),
232 ],
233 ),
234 const SizedBox(height: 8),
235 Text(
236 profile.displayName ?? '',
237 style: const TextStyle(fontSize: 28, fontWeight: FontWeight.w800),
238 textAlign: TextAlign.left,
239 ),
240 const SizedBox(height: 2),
241 Text(
242 '@${profile.handle}',
243 style: TextStyle(
244 fontSize: 14,
245 color: Theme.of(context).brightness == Brightness.dark
246 ? Colors.grey[400]
247 : Colors.grey[700],
248 ),
249 textAlign: TextAlign.left,
250 ),
251 const SizedBox(height: 12),
252 _ProfileStatsRow(
253 followers:
254 (profile.followersCount is int
255 ? profile.followersCount
256 : int.tryParse(profile.followersCount?.toString() ?? '0') ??
257 0)
258 .toString(),
259 following:
260 (profile.followsCount is int
261 ? profile.followsCount
262 : int.tryParse(profile.followsCount?.toString() ?? '0') ??
263 0)
264 .toString(),
265 galleries:
266 (profile.galleryCount is int
267 ? profile.galleryCount
268 : int.tryParse(profile.galleryCount?.toString() ?? '0') ??
269 0)
270 .toString(),
271 did: profile.did,
272 ),
273 if ((profile.cameras?.isNotEmpty ?? false)) ...[
274 const SizedBox(height: 16),
275 CameraPills(cameras: profile.cameras!),
276 ],
277 if ((profile.description ?? '').isNotEmpty) ...[
278 const SizedBox(height: 16),
279 FacetedText(
280 text: profile.description ?? '',
281 facets: profile.descriptionFacets,
282 onMentionTap: (didOrHandle) {
283 Navigator.of(context).push(
284 MaterialPageRoute(
285 builder: (context) =>
286 ProfilePage(did: didOrHandle, showAppBar: true),
287 ),
288 );
289 },
290 onLinkTap: (url) async {
291 final uri = Uri.parse(url);
292 if (!await launchUrl(uri)) {
293 throw Exception('Could not launch $url');
294 }
295 },
296 onTagTap: (tag) => Navigator.push(
297 context,
298 MaterialPageRoute(builder: (_) => HashtagPage(hashtag: tag)),
299 ),
300 linkStyle: TextStyle(
301 color: Theme.of(context).colorScheme.primary,
302 fontWeight: FontWeight.w600,
303 ),
304 ),
305 ],
306 const SizedBox(height: 24),
307 // REMOVE the Stack (tab row + divider) from inside Padding COMPLETELY
308 SizedBox(height: 12), // Add bottom padding before grid
309 ],
310 ),
311 ),
312 // Place Stack (tab row + divider) OUTSIDE Padding for true edge-to-edge
313 Stack(
314 children: [
315 Positioned.fill(
316 child: Align(
317 alignment: Alignment.bottomCenter,
318 child: Container(height: 1, color: Theme.of(context).dividerColor),
319 ),
320 ),
321 Row(
322 children: [
323 Expanded(
324 child: _ProfileTabButton(
325 label: 'Galleries',
326 selected: _selectedSection == 0,
327 onTap: () {
328 setState(() => _selectedSection = 0);
329 },
330 ),
331 ),
332 if (apiService.currentUser?.did == profile.did)
333 Expanded(
334 child: _ProfileTabButton(
335 label: 'Favs',
336 selected: _selectedSection == 1,
337 onTap: () {
338 setState(() => _selectedSection = 1);
339 },
340 ),
341 ),
342 ],
343 ),
344 ],
345 ),
346 // SizedBox(height: 1), // Add bottom padding before grid
347 // Directly show the grid, not inside Expanded/NestedScrollView
348 _selectedSection == 0
349 ? _buildGalleryGrid(theme, galleries)
350 : _buildFavsGrid(theme, favs),
351 ],
352 ),
353 ),
354 ),
355 ),
356 );
357 },
358 );
359 }
360
361 Widget _buildGalleryGrid(ThemeData theme, List<Gallery> galleries) {
362 if (galleries.isEmpty) {
363 return GridView.builder(
364 shrinkWrap: true,
365 physics: const NeverScrollableScrollPhysics(),
366 padding: EdgeInsets.zero,
367 gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
368 crossAxisCount: 3,
369 childAspectRatio: 3 / 4,
370 crossAxisSpacing: 2,
371 mainAxisSpacing: 2,
372 ),
373 itemCount: 12,
374 itemBuilder: (context, index) {
375 return Container(color: theme.colorScheme.surfaceContainerHighest);
376 },
377 );
378 }
379 return GridView.builder(
380 shrinkWrap: true,
381 physics: const NeverScrollableScrollPhysics(),
382 padding: EdgeInsets.zero,
383 gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
384 crossAxisCount: 3,
385 childAspectRatio: 3 / 4,
386 crossAxisSpacing: 2,
387 mainAxisSpacing: 2,
388 ),
389 itemCount: (galleries.length < 12 ? 12 : galleries.length),
390 itemBuilder: (context, index) {
391 if (galleries.isNotEmpty && index < galleries.length) {
392 final gallery = galleries[index];
393 final hasPhoto =
394 gallery.items.isNotEmpty && (gallery.items[0].thumb?.isNotEmpty ?? false);
395 return GestureDetector(
396 onTap: () {
397 if (gallery.uri.isNotEmpty) {
398 Navigator.of(context).push(
399 MaterialPageRoute(
400 builder: (context) => GalleryPage(
401 uri: gallery.uri,
402 currentUserDid: apiService.currentUser?.did ?? '',
403 ),
404 ),
405 );
406 }
407 },
408 child: Container(
409 decoration: BoxDecoration(
410 color: Theme.of(context).colorScheme.surfaceContainerHighest,
411 ),
412 clipBehavior: Clip.antiAlias,
413 child: hasPhoto
414 ? AppImage(url: gallery.items[0].thumb, fit: BoxFit.cover)
415 : Center(
416 child: Text(
417 gallery.title ?? '',
418 style: TextStyle(fontSize: 12, color: theme.colorScheme.onSurfaceVariant),
419 textAlign: TextAlign.center,
420 ),
421 ),
422 ),
423 );
424 }
425 return Container(color: theme.colorScheme.surfaceContainerHighest);
426 },
427 );
428 }
429
430 Widget _buildFavsGrid(ThemeData theme, List<Gallery>? favs) {
431 // Handle null favs more defensively
432 final safeList = favs ?? [];
433 final itemCount = safeList.length < 12 ? 12 : safeList.length;
434 if (safeList.isEmpty) {
435 return GridView.builder(
436 shrinkWrap: true,
437 physics: const NeverScrollableScrollPhysics(),
438 padding: EdgeInsets.zero,
439 gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
440 crossAxisCount: 3,
441 childAspectRatio: 3 / 4,
442 crossAxisSpacing: 2,
443 mainAxisSpacing: 2,
444 ),
445 itemCount: 12,
446 itemBuilder: (context, index) {
447 return Container(color: theme.colorScheme.surfaceContainerHighest);
448 },
449 );
450 }
451 return GridView.builder(
452 shrinkWrap: true,
453 physics: const NeverScrollableScrollPhysics(),
454 padding: EdgeInsets.zero,
455 gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
456 crossAxisCount: 3,
457 childAspectRatio: 3 / 4,
458 crossAxisSpacing: 2,
459 mainAxisSpacing: 2,
460 ),
461 itemCount: itemCount,
462 itemBuilder: (context, index) {
463 if (safeList.isNotEmpty && index < safeList.length) {
464 final gallery = safeList[index];
465 final hasPhoto =
466 gallery.items.isNotEmpty && (gallery.items[0].thumb?.isNotEmpty ?? false);
467 return GestureDetector(
468 onTap: () {
469 if (gallery.uri.isNotEmpty) {
470 Navigator.of(context).push(
471 MaterialPageRoute(
472 builder: (context) => GalleryPage(
473 uri: gallery.uri,
474 currentUserDid: apiService.currentUser?.did ?? '',
475 ),
476 ),
477 );
478 }
479 },
480 child: Container(
481 decoration: BoxDecoration(color: theme.colorScheme.surfaceContainerHighest),
482 clipBehavior: Clip.antiAlias,
483 child: hasPhoto
484 ? AppImage(url: gallery.items[0].thumb, fit: BoxFit.cover)
485 : Center(
486 child: Text(
487 gallery.title ?? '',
488 style: TextStyle(fontSize: 12, color: theme.colorScheme.onSurfaceVariant),
489 textAlign: TextAlign.center,
490 ),
491 ),
492 ),
493 );
494 }
495 return Container(color: theme.colorScheme.surfaceContainerHighest);
496 },
497 );
498 }
499}
500
501class _ProfileStatsRow extends StatelessWidget {
502 final String followers;
503 final String following;
504 final String galleries;
505 final String did;
506 const _ProfileStatsRow({
507 required this.followers,
508 required this.following,
509 required this.galleries,
510 required this.did,
511 });
512
513 @override
514 Widget build(BuildContext context) {
515 final theme = Theme.of(context);
516 final styleCount = const TextStyle(
517 fontWeight: FontWeight.bold,
518 fontSize: 14, // Set to 14
519 );
520 final styleLabel = TextStyle(
521 color: theme.brightness == Brightness.dark ? Colors.grey[400] : Colors.grey[700],
522 fontSize: 14, // Set to 14
523 );
524 return Row(
525 mainAxisAlignment: MainAxisAlignment.start,
526 children: [
527 GestureDetector(
528 onTap: () {
529 Navigator.of(
530 context,
531 ).push(MaterialPageRoute(builder: (_) => FollowersPage(actorDid: did)));
532 },
533 child: Row(
534 children: [
535 Text(followers, style: styleCount),
536 const SizedBox(width: 4),
537 Text('followers', style: styleLabel),
538 ],
539 ),
540 ),
541 const SizedBox(width: 16),
542 GestureDetector(
543 onTap: () {
544 Navigator.of(
545 context,
546 ).push(MaterialPageRoute(builder: (_) => FollowsPage(actorDid: did)));
547 },
548 child: Row(
549 children: [
550 Text(following, style: styleCount),
551 const SizedBox(width: 4),
552 Text('following', style: styleLabel),
553 ],
554 ),
555 ),
556 const SizedBox(width: 16),
557 Text(galleries, style: styleCount),
558 const SizedBox(width: 4),
559 Text('galleries', style: styleLabel),
560 ],
561 );
562 }
563}
564
565class _ProfileTabButton extends StatelessWidget {
566 final String label;
567 final bool selected;
568 final VoidCallback onTap;
569
570 const _ProfileTabButton({required this.label, required this.selected, required this.onTap});
571
572 @override
573 Widget build(BuildContext context) {
574 final theme = Theme.of(context);
575 return GestureDetector(
576 onTap: onTap,
577 behavior: HitTestBehavior.opaque,
578 child: Container(
579 height: 44,
580 alignment: Alignment.center,
581 child: Stack(
582 alignment: Alignment.bottomCenter,
583 children: [
584 Center(
585 child: Text(
586 label,
587 style: TextStyle(
588 fontWeight: FontWeight.w600,
589 fontSize: 16,
590 color: selected
591 ? theme.colorScheme.onSurface
592 : theme.colorScheme.onSurfaceVariant,
593 ),
594 ),
595 ),
596 if (selected)
597 Positioned(
598 bottom: 0,
599 left: 0,
600 right: 0,
601 child: Center(
602 child: Container(
603 height: 3,
604 width: _textWidth(context, label),
605 color: AppTheme.primaryColor,
606 ),
607 ),
608 ),
609 ],
610 ),
611 ),
612 );
613 }
614
615 double _textWidth(BuildContext context, String text) {
616 final TextPainter textPainter = TextPainter(
617 text: TextSpan(
618 text: text,
619 style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 16),
620 ),
621 maxLines: 1,
622 textDirection: TextDirection.ltr,
623 )..layout();
624 return textPainter.width;
625 }
626}