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