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