1import 'package:cached_network_image/cached_network_image.dart'; 2import 'package:flutter/material.dart'; 3import 'package:flutter/services.dart'; 4import 'package:provider/provider.dart'; 5import 'package:share_plus/share_plus.dart'; 6 7import '../../constants/app_colors.dart'; 8import '../../models/comment.dart'; 9import '../../models/post.dart'; 10import '../../providers/auth_provider.dart'; 11import '../../providers/comments_provider.dart'; 12import '../../providers/vote_provider.dart'; 13import '../../utils/community_handle_utils.dart'; 14import '../../utils/error_messages.dart'; 15import '../../widgets/comment_thread.dart'; 16import '../../widgets/comments_header.dart'; 17import '../../widgets/icons/share_icon.dart'; 18import '../../widgets/loading_error_states.dart'; 19import '../../widgets/post_action_bar.dart'; 20import '../../widgets/post_card.dart'; 21import '../../widgets/status_bar_overlay.dart'; 22import '../compose/reply_screen.dart'; 23import 'focused_thread_screen.dart'; 24 25/// Post Detail Screen 26/// 27/// Displays a full post with its comments. 28/// Architecture: Standalone screen for route destination and PageView child. 29/// 30/// Features: 31/// - Full post display (reuses PostCard widget) 32/// - Sort selector (Hot/Top/New) using dropdown 33/// - Comment list with ListView.builder for performance 34/// - Pull-to-refresh with RefreshIndicator 35/// - Loading, empty, and error states 36/// - Automatic comment loading on screen init 37class PostDetailScreen extends StatefulWidget { 38 const PostDetailScreen({required this.post, super.key}); 39 40 /// Post to display (passed via route extras) 41 final FeedViewPost post; 42 43 @override 44 State<PostDetailScreen> createState() => _PostDetailScreenState(); 45} 46 47class _PostDetailScreenState extends State<PostDetailScreen> { 48 final ScrollController _scrollController = ScrollController(); 49 final GlobalKey _commentsHeaderKey = GlobalKey(); 50 51 // Current sort option 52 String _currentSort = 'hot'; 53 54 @override 55 void initState() { 56 super.initState(); 57 58 // Initialize scroll controller for pagination 59 _scrollController.addListener(_onScroll); 60 61 // Load comments after frame is built using provider from tree 62 WidgetsBinding.instance.addPostFrameCallback((_) { 63 if (mounted) { 64 _loadComments(); 65 } 66 }); 67 } 68 69 @override 70 void dispose() { 71 _scrollController.dispose(); 72 super.dispose(); 73 } 74 75 /// Load comments for the current post 76 void _loadComments() { 77 context.read<CommentsProvider>().loadComments( 78 postUri: widget.post.post.uri, 79 postCid: widget.post.post.cid, 80 refresh: true, 81 ); 82 } 83 84 /// Handle sort changes from dropdown 85 Future<void> _onSortChanged(String newSort) async { 86 final previousSort = _currentSort; 87 88 setState(() { 89 _currentSort = newSort; 90 }); 91 92 final commentsProvider = context.read<CommentsProvider>(); 93 final success = await commentsProvider.setSortOption(newSort); 94 95 // Show error snackbar and revert UI if sort change failed 96 if (!success && mounted) { 97 setState(() { 98 _currentSort = previousSort; 99 }); 100 101 ScaffoldMessenger.of(context).showSnackBar( 102 SnackBar( 103 content: const Text('Failed to change sort order. Please try again.'), 104 backgroundColor: AppColors.primary, 105 behavior: SnackBarBehavior.floating, 106 duration: const Duration(seconds: 3), 107 action: SnackBarAction( 108 label: 'Retry', 109 textColor: AppColors.textPrimary, 110 onPressed: () { 111 _onSortChanged(newSort); 112 }, 113 ), 114 ), 115 ); 116 } 117 } 118 119 /// Handle scroll for pagination 120 void _onScroll() { 121 if (_scrollController.position.pixels >= 122 _scrollController.position.maxScrollExtent - 200) { 123 context.read<CommentsProvider>().loadMoreComments(); 124 } 125 } 126 127 /// Handle pull-to-refresh 128 Future<void> _onRefresh() async { 129 final commentsProvider = context.read<CommentsProvider>(); 130 await commentsProvider.refreshComments(); 131 } 132 133 @override 134 Widget build(BuildContext context) { 135 return Scaffold( 136 backgroundColor: AppColors.background, 137 body: _buildContent(), 138 bottomNavigationBar: _buildActionBar(), 139 ); 140 } 141 142 /// Build community title with avatar and handle 143 Widget _buildCommunityTitle() { 144 final community = widget.post.post.community; 145 final displayHandle = CommunityHandleUtils.formatHandleForDisplay( 146 community.handle, 147 ); 148 149 return Row( 150 mainAxisSize: MainAxisSize.min, 151 children: [ 152 // Community avatar 153 if (community.avatar != null && community.avatar!.isNotEmpty) 154 ClipRRect( 155 borderRadius: BorderRadius.circular(16), 156 child: CachedNetworkImage( 157 imageUrl: community.avatar!, 158 width: 32, 159 height: 32, 160 fit: BoxFit.cover, 161 placeholder: (context, url) => _buildFallbackAvatar(community), 162 errorWidget: 163 (context, url, error) => _buildFallbackAvatar(community), 164 ), 165 ) 166 else 167 _buildFallbackAvatar(community), 168 const SizedBox(width: 8), 169 // Community handle with styled parts 170 if (displayHandle != null) 171 Flexible(child: _buildStyledHandle(displayHandle)) 172 else 173 Flexible( 174 child: Text( 175 community.name, 176 style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600), 177 overflow: TextOverflow.ellipsis, 178 ), 179 ), 180 ], 181 ); 182 } 183 184 /// Build styled community handle with color-coded parts 185 Widget _buildStyledHandle(String displayHandle) { 186 // Format: !gaming@coves.social 187 final atIndex = displayHandle.indexOf('@'); 188 final communityPart = displayHandle.substring(0, atIndex); 189 final instancePart = displayHandle.substring(atIndex); 190 191 return Text.rich( 192 TextSpan( 193 children: [ 194 TextSpan( 195 text: communityPart, 196 style: const TextStyle( 197 color: AppColors.communityName, 198 fontSize: 16, 199 fontWeight: FontWeight.w600, 200 ), 201 ), 202 TextSpan( 203 text: instancePart, 204 style: TextStyle( 205 color: AppColors.textSecondary.withValues(alpha: 0.8), 206 fontSize: 16, 207 fontWeight: FontWeight.w600, 208 ), 209 ), 210 ], 211 ), 212 overflow: TextOverflow.ellipsis, 213 ); 214 } 215 216 /// Build fallback avatar with first letter 217 Widget _buildFallbackAvatar(CommunityRef community) { 218 final firstLetter = community.name.isNotEmpty ? community.name[0] : '?'; 219 return Container( 220 width: 32, 221 height: 32, 222 decoration: BoxDecoration( 223 color: AppColors.primary, 224 borderRadius: BorderRadius.circular(16), 225 ), 226 child: Center( 227 child: Text( 228 firstLetter.toUpperCase(), 229 style: const TextStyle( 230 color: AppColors.textPrimary, 231 fontSize: 14, 232 fontWeight: FontWeight.bold, 233 ), 234 ), 235 ), 236 ); 237 } 238 239 /// Handle share button tap 240 Future<void> _handleShare() async { 241 // Add haptic feedback 242 await HapticFeedback.lightImpact(); 243 244 // TODO: Generate proper deep link URL when deep linking is implemented 245 final postUri = widget.post.post.uri; 246 final title = widget.post.post.title ?? 'Check out this post'; 247 248 await Share.share('$title\n\n$postUri', subject: title); 249 } 250 251 /// Build bottom action bar with vote, save, and comment actions 252 Widget _buildActionBar() { 253 return Consumer<VoteProvider>( 254 builder: (context, voteProvider, child) { 255 final isVoted = voteProvider.isLiked(widget.post.post.uri); 256 final adjustedScore = voteProvider.getAdjustedScore( 257 widget.post.post.uri, 258 widget.post.post.stats.score, 259 ); 260 261 // Create a modified post with adjusted score for display 262 final displayPost = FeedViewPost( 263 post: PostView( 264 uri: widget.post.post.uri, 265 cid: widget.post.post.cid, 266 rkey: widget.post.post.rkey, 267 author: widget.post.post.author, 268 community: widget.post.post.community, 269 createdAt: widget.post.post.createdAt, 270 indexedAt: widget.post.post.indexedAt, 271 text: widget.post.post.text, 272 title: widget.post.post.title, 273 stats: PostStats( 274 upvotes: widget.post.post.stats.upvotes, 275 downvotes: widget.post.post.stats.downvotes, 276 score: adjustedScore, 277 commentCount: widget.post.post.stats.commentCount, 278 ), 279 embed: widget.post.post.embed, 280 facets: widget.post.post.facets, 281 ), 282 reason: widget.post.reason, 283 ); 284 285 return PostActionBar( 286 post: displayPost, 287 isVoted: isVoted, 288 onCommentInputTap: _openCommentComposer, 289 onCommentCountTap: _scrollToComments, 290 onVoteTap: () async { 291 // Check authentication 292 final authProvider = context.read<AuthProvider>(); 293 if (!authProvider.isAuthenticated) { 294 ScaffoldMessenger.of(context).showSnackBar( 295 const SnackBar( 296 content: Text('Sign in to vote on posts'), 297 behavior: SnackBarBehavior.floating, 298 ), 299 ); 300 return; 301 } 302 303 // Capture messenger before async operations 304 final messenger = ScaffoldMessenger.of(context); 305 306 // Light haptic feedback on both like and unlike 307 await HapticFeedback.lightImpact(); 308 try { 309 await voteProvider.toggleVote( 310 postUri: widget.post.post.uri, 311 postCid: widget.post.post.cid, 312 ); 313 } on Exception catch (e) { 314 if (mounted) { 315 messenger.showSnackBar( 316 SnackBar( 317 content: Text('Failed to vote: $e'), 318 behavior: SnackBarBehavior.floating, 319 ), 320 ); 321 } 322 } 323 }, 324 onSaveTap: () { 325 // TODO: Add save functionality 326 ScaffoldMessenger.of(context).showSnackBar( 327 const SnackBar( 328 content: Text('Save feature coming soon!'), 329 behavior: SnackBarBehavior.floating, 330 ), 331 ); 332 }, 333 ); 334 }, 335 ); 336 } 337 338 /// Scroll to the comments section 339 void _scrollToComments() { 340 final context = _commentsHeaderKey.currentContext; 341 if (context != null) { 342 Scrollable.ensureVisible( 343 context, 344 duration: const Duration(milliseconds: 300), 345 curve: Curves.easeInOut, 346 ); 347 } 348 } 349 350 /// Open the reply screen for composing a comment 351 void _openCommentComposer() { 352 // Check authentication 353 final authProvider = context.read<AuthProvider>(); 354 if (!authProvider.isAuthenticated) { 355 ScaffoldMessenger.of(context).showSnackBar( 356 const SnackBar( 357 content: Text('Sign in to comment'), 358 behavior: SnackBarBehavior.floating, 359 ), 360 ); 361 return; 362 } 363 364 // Navigate to reply screen with full post context 365 Navigator.of(context).push( 366 MaterialPageRoute<void>( 367 builder: 368 (context) => 369 ReplyScreen(post: widget.post, onSubmit: _handleCommentSubmit), 370 ), 371 ); 372 } 373 374 /// Handle comment submission (reply to post) 375 Future<void> _handleCommentSubmit(String content) async { 376 final commentsProvider = context.read<CommentsProvider>(); 377 final messenger = ScaffoldMessenger.of(context); 378 379 try { 380 await commentsProvider.createComment(content: content); 381 382 if (mounted) { 383 messenger.showSnackBar( 384 const SnackBar( 385 content: Text('Comment posted'), 386 behavior: SnackBarBehavior.floating, 387 duration: Duration(seconds: 2), 388 ), 389 ); 390 } 391 } on Exception catch (e) { 392 if (mounted) { 393 messenger.showSnackBar( 394 SnackBar( 395 content: Text('Failed to post comment: $e'), 396 behavior: SnackBarBehavior.floating, 397 backgroundColor: AppColors.primary, 398 ), 399 ); 400 } 401 rethrow; // Let ReplyScreen know submission failed 402 } 403 } 404 405 /// Handle reply to a comment (nested reply) 406 Future<void> _handleCommentReply( 407 String content, 408 ThreadViewComment parentComment, 409 ) async { 410 final commentsProvider = context.read<CommentsProvider>(); 411 final messenger = ScaffoldMessenger.of(context); 412 413 try { 414 await commentsProvider.createComment( 415 content: content, 416 parentComment: parentComment, 417 ); 418 419 if (mounted) { 420 messenger.showSnackBar( 421 const SnackBar( 422 content: Text('Reply posted'), 423 behavior: SnackBarBehavior.floating, 424 duration: Duration(seconds: 2), 425 ), 426 ); 427 } 428 } on Exception catch (e) { 429 if (mounted) { 430 messenger.showSnackBar( 431 SnackBar( 432 content: Text('Failed to post reply: $e'), 433 behavior: SnackBarBehavior.floating, 434 backgroundColor: AppColors.primary, 435 ), 436 ); 437 } 438 rethrow; // Let ReplyScreen know submission failed 439 } 440 } 441 442 /// Open reply screen for replying to a comment 443 void _openReplyToComment(ThreadViewComment comment) { 444 // Check authentication 445 final authProvider = context.read<AuthProvider>(); 446 if (!authProvider.isAuthenticated) { 447 ScaffoldMessenger.of(context).showSnackBar( 448 const SnackBar( 449 content: Text('Sign in to reply'), 450 behavior: SnackBarBehavior.floating, 451 ), 452 ); 453 return; 454 } 455 456 // Navigate to reply screen with comment context 457 Navigator.of(context).push( 458 MaterialPageRoute<void>( 459 builder: 460 (context) => ReplyScreen( 461 comment: comment, 462 onSubmit: (content) => _handleCommentReply(content, comment), 463 ), 464 ), 465 ); 466 } 467 468 /// Navigate to focused thread screen for deep threads 469 void _onContinueThread( 470 ThreadViewComment thread, 471 List<ThreadViewComment> ancestors, 472 ) { 473 Navigator.of(context).push( 474 MaterialPageRoute<void>( 475 builder: (context) => FocusedThreadScreen( 476 thread: thread, 477 ancestors: ancestors, 478 onReply: _handleCommentReply, 479 ), 480 ), 481 ); 482 } 483 484 /// Build main content area 485 Widget _buildContent() { 486 // Use Consumer to rebuild when comments provider changes 487 return Consumer<CommentsProvider>( 488 builder: (context, commentsProvider, child) { 489 final isLoading = commentsProvider.isLoading; 490 final error = commentsProvider.error; 491 final comments = commentsProvider.comments; 492 final isLoadingMore = commentsProvider.isLoadingMore; 493 494 // Loading state (only show full-screen loader for initial load) 495 if (isLoading && comments.isEmpty) { 496 return const FullScreenLoading(); 497 } 498 499 // Error state (only show full-screen error when no comments loaded yet) 500 if (error != null && comments.isEmpty) { 501 return FullScreenError( 502 title: 'Failed to load comments', 503 message: ErrorMessages.getUserFriendly(error), 504 onRetry: commentsProvider.retry, 505 ); 506 } 507 508 // Content with RefreshIndicator and floating SliverAppBar 509 // Wrapped in Stack to add solid status bar background overlay 510 return Stack( 511 children: [ 512 RefreshIndicator( 513 onRefresh: _onRefresh, 514 color: AppColors.primary, 515 child: CustomScrollView( 516 controller: _scrollController, 517 slivers: [ 518 // Floating app bar that hides on scroll down, 519 // shows on scroll up 520 SliverAppBar( 521 backgroundColor: AppColors.background, 522 surfaceTintColor: Colors.transparent, 523 foregroundColor: AppColors.textPrimary, 524 title: _buildCommunityTitle(), 525 centerTitle: false, 526 elevation: 0, 527 floating: true, 528 snap: true, 529 actions: [ 530 IconButton( 531 icon: const ShareIcon(color: AppColors.textPrimary), 532 onPressed: _handleShare, 533 tooltip: 'Share', 534 ), 535 ], 536 ), 537 538 // Post + comments + loading indicator 539 SliverSafeArea( 540 top: false, 541 sliver: SliverList( 542 delegate: SliverChildBuilderDelegate( 543 (context, index) { 544 // Post card (index 0) 545 if (index == 0) { 546 return Column( 547 children: [ 548 // Reuse PostCard (hide comment button in 549 // detail view) 550 // Use ValueListenableBuilder to only rebuild 551 // when time changes 552 _PostHeader( 553 post: widget.post, 554 currentTimeNotifier: 555 commentsProvider.currentTimeNotifier, 556 ), 557 558 // Visual divider before comments section 559 Container( 560 margin: const EdgeInsets.symmetric(vertical: 16), 561 height: 1, 562 color: AppColors.border, 563 ), 564 565 // Comments header with sort dropdown 566 CommentsHeader( 567 key: _commentsHeaderKey, 568 commentCount: comments.length, 569 currentSort: _currentSort, 570 onSortChanged: _onSortChanged, 571 ), 572 ], 573 ); 574 } 575 576 // Loading indicator or error at the end 577 if (index == comments.length + 1) { 578 if (isLoadingMore) { 579 return const InlineLoading(); 580 } 581 if (error != null) { 582 return InlineError( 583 message: ErrorMessages.getUserFriendly(error), 584 onRetry: () { 585 commentsProvider 586 ..clearError() 587 ..loadMoreComments(); 588 }, 589 ); 590 } 591 } 592 593 // Comment item - use existing CommentThread widget 594 final comment = comments[index - 1]; 595 return _CommentItem( 596 comment: comment, 597 currentTimeNotifier: 598 commentsProvider.currentTimeNotifier, 599 onCommentTap: _openReplyToComment, 600 collapsedComments: commentsProvider.collapsedComments, 601 onCollapseToggle: commentsProvider.toggleCollapsed, 602 onContinueThread: _onContinueThread, 603 ); 604 }, 605 childCount: 606 1 + 607 comments.length + 608 (isLoadingMore || error != null ? 1 : 0), 609 ), 610 ), 611 ), 612 ], 613 ), 614 ), 615 // Prevents content showing through transparent status bar 616 const StatusBarOverlay(), 617 ], 618 ); 619 }, 620 ); 621 } 622} 623 624/// Post header widget that only rebuilds when time changes 625/// 626/// Extracted to prevent unnecessary rebuilds when comment list changes. 627/// Uses ValueListenableBuilder to listen only to time updates. 628class _PostHeader extends StatelessWidget { 629 const _PostHeader({required this.post, required this.currentTimeNotifier}); 630 631 final FeedViewPost post; 632 final ValueNotifier<DateTime?> currentTimeNotifier; 633 634 @override 635 Widget build(BuildContext context) { 636 return ValueListenableBuilder<DateTime?>( 637 valueListenable: currentTimeNotifier, 638 builder: (context, currentTime, child) { 639 return PostCard( 640 post: post, 641 currentTime: currentTime, 642 showCommentButton: false, 643 disableNavigation: true, 644 showActions: false, 645 showHeader: false, 646 showBorder: false, 647 showFullText: true, 648 showAuthorFooter: true, 649 textFontSize: 16, 650 textLineHeight: 1.6, 651 embedHeight: 280, 652 titleFontSize: 20, 653 titleFontWeight: FontWeight.w600, 654 ); 655 }, 656 ); 657 } 658} 659 660/// Comment item wrapper that only rebuilds when time changes 661/// 662/// Uses ValueListenableBuilder to prevent rebuilds when unrelated 663/// provider state changes (like loading state or error state). 664class _CommentItem extends StatelessWidget { 665 const _CommentItem({ 666 required this.comment, 667 required this.currentTimeNotifier, 668 this.onCommentTap, 669 this.collapsedComments = const {}, 670 this.onCollapseToggle, 671 this.onContinueThread, 672 }); 673 674 final ThreadViewComment comment; 675 final ValueNotifier<DateTime?> currentTimeNotifier; 676 final void Function(ThreadViewComment)? onCommentTap; 677 final Set<String> collapsedComments; 678 final void Function(String uri)? onCollapseToggle; 679 final void Function(ThreadViewComment, List<ThreadViewComment>)? 680 onContinueThread; 681 682 @override 683 Widget build(BuildContext context) { 684 return ValueListenableBuilder<DateTime?>( 685 valueListenable: currentTimeNotifier, 686 builder: (context, currentTime, child) { 687 return CommentThread( 688 thread: comment, 689 currentTime: currentTime, 690 maxDepth: 6, 691 onCommentTap: onCommentTap, 692 collapsedComments: collapsedComments, 693 onCollapseToggle: onCollapseToggle, 694 onContinueThread: onContinueThread, 695 ); 696 }, 697 ); 698 } 699}