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