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