this repo has no description
at main 626 lines 24 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/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}