at main 29 kB view raw
1import 'package:cached_network_image/cached_network_image.dart'; 2import 'package:flutter/foundation.dart'; 3import 'package:flutter/material.dart'; 4import 'package:flutter/services.dart'; 5import 'package:provider/provider.dart'; 6import 'package:share_plus/share_plus.dart'; 7 8import '../../constants/app_colors.dart'; 9import '../../models/comment.dart'; 10import '../../models/post.dart'; 11import '../../providers/auth_provider.dart'; 12import '../../providers/comments_provider.dart'; 13import '../../providers/vote_provider.dart'; 14import '../../services/comments_provider_cache.dart'; 15import '../../utils/community_handle_utils.dart'; 16import '../../utils/error_messages.dart'; 17import '../../widgets/comment_thread.dart'; 18import '../../widgets/comments_header.dart'; 19import '../../widgets/icons/share_icon.dart'; 20import '../../widgets/loading_error_states.dart'; 21import '../../widgets/post_action_bar.dart'; 22import '../../widgets/post_card.dart'; 23import '../../widgets/status_bar_overlay.dart'; 24import '../compose/reply_screen.dart'; 25import 'focused_thread_screen.dart'; 26 27/// Post Detail Screen 28/// 29/// Displays a full post with its comments. 30/// Architecture: Standalone screen for route destination and PageView child. 31/// 32/// Features: 33/// - Full post display (reuses PostCard widget) 34/// - Sort selector (Hot/Top/New) using dropdown 35/// - Comment list with ListView.builder for performance 36/// - Pull-to-refresh with RefreshIndicator 37/// - Loading, empty, and error states 38/// - Automatic comment loading on screen init 39class PostDetailScreen extends StatefulWidget { 40 const PostDetailScreen({ 41 required this.post, 42 this.isOptimistic = false, 43 super.key, 44 }); 45 46 /// Post to display (passed via route extras) 47 final FeedViewPost post; 48 49 /// Whether this is an optimistic post (just created, not yet indexed) 50 /// When true, skips initial comment load since we know there are no comments 51 final bool isOptimistic; 52 53 @override 54 State<PostDetailScreen> createState() => _PostDetailScreenState(); 55} 56 57class _PostDetailScreenState extends State<PostDetailScreen> { 58 // ScrollController created lazily with cached scroll position for instant restoration 59 late ScrollController _scrollController; 60 final GlobalKey _commentsHeaderKey = GlobalKey(); 61 62 // Cached provider from CommentsProviderCache 63 late CommentsProvider _commentsProvider; 64 CommentsProviderCache? _commentsCache; 65 66 // Track initialization state 67 bool _isInitialized = false; 68 69 // Track if provider has been invalidated (e.g., by sign-out) 70 bool _providerInvalidated = false; 71 72 @override 73 void initState() { 74 super.initState(); 75 // ScrollController and provider initialization moved to didChangeDependencies 76 // where we have access to context for synchronous provider acquisition 77 } 78 79 @override 80 void didChangeDependencies() { 81 super.didChangeDependencies(); 82 // Initialize provider synchronously on first call (has context access) 83 // This ensures cached data is available for the first build, avoiding 84 // the flash from loading state → content → scroll position jump 85 if (!_isInitialized) { 86 _initializeProviderSync(); 87 } 88 } 89 90 /// Listen for auth state changes to handle sign-out 91 void _setupAuthListener() { 92 final authProvider = context.read<AuthProvider>(); 93 authProvider.addListener(_onAuthChanged); 94 } 95 96 /// Handle auth state changes (specifically sign-out) 97 void _onAuthChanged() { 98 if (!mounted) return; 99 100 final authProvider = context.read<AuthProvider>(); 101 102 // If user signed out while viewing this screen, navigate back 103 // The CommentsProviderCache has already disposed our provider 104 if (!authProvider.isAuthenticated && 105 _isInitialized && 106 !_providerInvalidated) { 107 _providerInvalidated = true; 108 109 if (kDebugMode) { 110 debugPrint('🚪 User signed out - cleaning up PostDetailScreen'); 111 } 112 113 // Remove listener from provider (it's disposed but this is safe) 114 try { 115 _commentsProvider.removeListener(_onProviderChanged); 116 } on Exception { 117 // Provider already disposed - expected 118 } 119 120 // Navigate back to feed 121 if (mounted) { 122 Navigator.of(context).popUntil((route) => route.isFirst); 123 } 124 } 125 } 126 127 /// Initialize provider synchronously from cache 128 /// 129 /// Called from didChangeDependencies to ensure cached data is available 130 /// for the first build. Creates ScrollController with initialScrollOffset 131 /// set to cached position for instant scroll restoration without flicker. 132 void _initializeProviderSync() { 133 // Get or create provider from cache 134 final cache = context.read<CommentsProviderCache>(); 135 _commentsCache = cache; 136 _commentsProvider = cache.acquireProvider( 137 postUri: widget.post.post.uri, 138 postCid: widget.post.post.cid, 139 ); 140 141 // Create scroll controller with cached position for instant restoration 142 // This avoids the flash: loading → content at top → jump to cached position 143 final cachedScrollPosition = _commentsProvider.scrollPosition; 144 _scrollController = ScrollController( 145 initialScrollOffset: cachedScrollPosition, 146 ); 147 _scrollController.addListener(_onScroll); 148 149 if (kDebugMode && cachedScrollPosition > 0) { 150 debugPrint( 151 '📍 Created ScrollController with initial offset: $cachedScrollPosition', 152 ); 153 } 154 155 // Listen for changes to trigger rebuilds 156 _commentsProvider.addListener(_onProviderChanged); 157 158 // Setup auth listener 159 _setupAuthListener(); 160 161 // Mark as initialized before triggering any loads 162 // This ensures the first build shows content (not loading) when cached 163 _isInitialized = true; 164 165 // Skip loading for optimistic posts (just created, not yet indexed) 166 if (widget.isOptimistic) { 167 if (kDebugMode) { 168 debugPrint('✨ Optimistic post - skipping initial comment load'); 169 } 170 // Don't load comments - there won't be any yet 171 } else if (_commentsProvider.comments.isNotEmpty) { 172 // Already have cached data - it will render immediately 173 if (kDebugMode) { 174 debugPrint( 175 '📦 Using cached comments (${_commentsProvider.comments.length})', 176 ); 177 } 178 179 // Background refresh if data is stale (won't cause flicker) 180 if (_commentsProvider.isStale) { 181 if (kDebugMode) { 182 debugPrint('🔄 Data stale, refreshing in background'); 183 } 184 _commentsProvider.loadComments(refresh: true); 185 } 186 } else { 187 // No cached data - load fresh 188 _commentsProvider.loadComments(refresh: true); 189 } 190 } 191 192 @override 193 void dispose() { 194 // Remove auth listener 195 try { 196 context.read<AuthProvider>().removeListener(_onAuthChanged); 197 } on Exception { 198 // Context may not be valid during dispose 199 } 200 201 // Release provider pin in cache (prevents LRU eviction disposing an active 202 // provider while this screen is in the navigation stack). 203 if (_isInitialized) { 204 try { 205 _commentsCache?.releaseProvider(widget.post.post.uri); 206 } on Exception { 207 // Cache may already be disposed 208 } 209 } 210 211 // Remove provider listener if not already invalidated 212 if (_isInitialized && !_providerInvalidated) { 213 try { 214 _commentsProvider.removeListener(_onProviderChanged); 215 } on Exception { 216 // Provider may already be disposed 217 } 218 } 219 _scrollController.dispose(); 220 super.dispose(); 221 } 222 223 /// Handle provider changes 224 void _onProviderChanged() { 225 if (mounted) { 226 setState(() {}); 227 } 228 } 229 230 /// Handle sort changes from dropdown 231 Future<void> _onSortChanged(String newSort) async { 232 final success = await _commentsProvider.setSortOption(newSort); 233 234 // Show error snackbar if sort change failed 235 if (!success && mounted) { 236 ScaffoldMessenger.of(context).showSnackBar( 237 SnackBar( 238 content: const Text('Failed to change sort order. Please try again.'), 239 backgroundColor: AppColors.primary, 240 behavior: SnackBarBehavior.floating, 241 duration: const Duration(seconds: 3), 242 action: SnackBarAction( 243 label: 'Retry', 244 textColor: AppColors.textPrimary, 245 onPressed: () { 246 _onSortChanged(newSort); 247 }, 248 ), 249 ), 250 ); 251 } 252 } 253 254 /// Handle scroll for pagination 255 void _onScroll() { 256 // Don't interact with disposed provider 257 if (_providerInvalidated) return; 258 259 // Save scroll position to provider on every scroll event 260 if (_scrollController.hasClients) { 261 _commentsProvider.saveScrollPosition(_scrollController.position.pixels); 262 } 263 264 // Load more comments when near bottom 265 if (_scrollController.position.pixels >= 266 _scrollController.position.maxScrollExtent - 200) { 267 _commentsProvider.loadMoreComments(); 268 } 269 } 270 271 /// Handle pull-to-refresh 272 Future<void> _onRefresh() async { 273 // Don't interact with disposed provider 274 if (_providerInvalidated) return; 275 276 await _commentsProvider.refreshComments(); 277 } 278 279 @override 280 Widget build(BuildContext context) { 281 // Show loading until provider is initialized 282 if (!_isInitialized) { 283 return const Scaffold( 284 backgroundColor: AppColors.background, 285 body: FullScreenLoading(), 286 ); 287 } 288 289 // If provider was invalidated (sign-out), show loading while navigating away 290 if (_providerInvalidated) { 291 return const Scaffold( 292 backgroundColor: AppColors.background, 293 body: FullScreenLoading(), 294 ); 295 } 296 297 // Provide the cached CommentsProvider to descendant widgets 298 return ChangeNotifierProvider.value( 299 value: _commentsProvider, 300 child: Scaffold( 301 backgroundColor: AppColors.background, 302 body: _buildContent(), 303 bottomNavigationBar: _buildActionBar(), 304 ), 305 ); 306 } 307 308 /// Build community title with avatar and handle 309 Widget _buildCommunityTitle() { 310 final community = widget.post.post.community; 311 final displayHandle = CommunityHandleUtils.formatHandleForDisplay( 312 community.handle, 313 ); 314 315 return Row( 316 mainAxisSize: MainAxisSize.min, 317 children: [ 318 // Community avatar 319 if (community.avatar != null && community.avatar!.isNotEmpty) 320 ClipRRect( 321 borderRadius: BorderRadius.circular(16), 322 child: CachedNetworkImage( 323 imageUrl: community.avatar!, 324 width: 32, 325 height: 32, 326 fit: BoxFit.cover, 327 placeholder: (context, url) => _buildFallbackAvatar(community), 328 errorWidget: 329 (context, url, error) => _buildFallbackAvatar(community), 330 ), 331 ) 332 else 333 _buildFallbackAvatar(community), 334 const SizedBox(width: 8), 335 // Community handle with styled parts 336 if (displayHandle != null) 337 Flexible(child: _buildStyledHandle(displayHandle)) 338 else 339 Flexible( 340 child: Text( 341 community.name, 342 style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600), 343 overflow: TextOverflow.ellipsis, 344 ), 345 ), 346 ], 347 ); 348 } 349 350 /// Build styled community handle with color-coded parts 351 Widget _buildStyledHandle(String displayHandle) { 352 // Format: !gaming@coves.social 353 final atIndex = displayHandle.indexOf('@'); 354 final communityPart = displayHandle.substring(0, atIndex); 355 final instancePart = displayHandle.substring(atIndex); 356 357 return Text.rich( 358 TextSpan( 359 children: [ 360 TextSpan( 361 text: communityPart, 362 style: const TextStyle( 363 color: AppColors.communityName, 364 fontSize: 16, 365 fontWeight: FontWeight.w600, 366 ), 367 ), 368 TextSpan( 369 text: instancePart, 370 style: TextStyle( 371 color: AppColors.textSecondary.withValues(alpha: 0.8), 372 fontSize: 16, 373 fontWeight: FontWeight.w600, 374 ), 375 ), 376 ], 377 ), 378 overflow: TextOverflow.ellipsis, 379 ); 380 } 381 382 /// Build fallback avatar with first letter 383 Widget _buildFallbackAvatar(CommunityRef community) { 384 final firstLetter = community.name.isNotEmpty ? community.name[0] : '?'; 385 return Container( 386 width: 32, 387 height: 32, 388 decoration: BoxDecoration( 389 color: AppColors.primary, 390 borderRadius: BorderRadius.circular(16), 391 ), 392 child: Center( 393 child: Text( 394 firstLetter.toUpperCase(), 395 style: const TextStyle( 396 color: AppColors.textPrimary, 397 fontSize: 14, 398 fontWeight: FontWeight.bold, 399 ), 400 ), 401 ), 402 ); 403 } 404 405 /// Handle share button tap 406 Future<void> _handleShare() async { 407 // Add haptic feedback 408 await HapticFeedback.lightImpact(); 409 410 // TODO: Generate proper deep link URL when deep linking is implemented 411 final postUri = widget.post.post.uri; 412 final title = widget.post.post.title ?? 'Check out this post'; 413 414 await Share.share('$title\n\n$postUri', subject: title); 415 } 416 417 /// Build bottom action bar with vote, save, and comment actions 418 Widget _buildActionBar() { 419 return Consumer<VoteProvider>( 420 builder: (context, voteProvider, child) { 421 final isVoted = voteProvider.isLiked(widget.post.post.uri); 422 final adjustedScore = voteProvider.getAdjustedScore( 423 widget.post.post.uri, 424 widget.post.post.stats.score, 425 ); 426 427 // Create a modified post with adjusted score for display 428 final displayPost = FeedViewPost( 429 post: PostView( 430 uri: widget.post.post.uri, 431 cid: widget.post.post.cid, 432 rkey: widget.post.post.rkey, 433 author: widget.post.post.author, 434 community: widget.post.post.community, 435 createdAt: widget.post.post.createdAt, 436 indexedAt: widget.post.post.indexedAt, 437 text: widget.post.post.text, 438 title: widget.post.post.title, 439 stats: PostStats( 440 upvotes: widget.post.post.stats.upvotes, 441 downvotes: widget.post.post.stats.downvotes, 442 score: adjustedScore, 443 commentCount: widget.post.post.stats.commentCount, 444 ), 445 embed: widget.post.post.embed, 446 facets: widget.post.post.facets, 447 ), 448 reason: widget.post.reason, 449 ); 450 451 return PostActionBar( 452 post: displayPost, 453 isVoted: isVoted, 454 onCommentInputTap: _openCommentComposer, 455 onCommentCountTap: _scrollToComments, 456 onVoteTap: () async { 457 // Check authentication 458 final authProvider = context.read<AuthProvider>(); 459 if (!authProvider.isAuthenticated) { 460 ScaffoldMessenger.of(context).showSnackBar( 461 const SnackBar( 462 content: Text('Sign in to vote on posts'), 463 behavior: SnackBarBehavior.floating, 464 ), 465 ); 466 return; 467 } 468 469 // Capture messenger before async operations 470 final messenger = ScaffoldMessenger.of(context); 471 472 // Light haptic feedback on both like and unlike 473 await HapticFeedback.lightImpact(); 474 try { 475 await voteProvider.toggleVote( 476 postUri: widget.post.post.uri, 477 postCid: widget.post.post.cid, 478 ); 479 } on Exception catch (e) { 480 if (mounted) { 481 messenger.showSnackBar( 482 SnackBar( 483 content: Text('Failed to vote: $e'), 484 behavior: SnackBarBehavior.floating, 485 ), 486 ); 487 } 488 } 489 }, 490 onSaveTap: () { 491 // TODO: Add save functionality 492 ScaffoldMessenger.of(context).showSnackBar( 493 const SnackBar( 494 content: Text('Save feature coming soon!'), 495 behavior: SnackBarBehavior.floating, 496 ), 497 ); 498 }, 499 ); 500 }, 501 ); 502 } 503 504 /// Scroll to the comments section 505 void _scrollToComments() { 506 final context = _commentsHeaderKey.currentContext; 507 if (context != null) { 508 Scrollable.ensureVisible( 509 context, 510 duration: const Duration(milliseconds: 300), 511 curve: Curves.easeInOut, 512 ); 513 } 514 } 515 516 /// Open the reply screen for composing a comment 517 void _openCommentComposer() { 518 // Check authentication 519 final authProvider = context.read<AuthProvider>(); 520 if (!authProvider.isAuthenticated) { 521 ScaffoldMessenger.of(context).showSnackBar( 522 const SnackBar( 523 content: Text('Sign in to comment'), 524 behavior: SnackBarBehavior.floating, 525 ), 526 ); 527 return; 528 } 529 530 // Navigate to reply screen with full post context 531 Navigator.of(context).push( 532 MaterialPageRoute<void>( 533 builder: 534 (context) => ReplyScreen( 535 post: widget.post, 536 onSubmit: _handleCommentSubmit, 537 commentsProvider: _commentsProvider, 538 ), 539 ), 540 ); 541 } 542 543 /// Handle comment submission (reply to post) 544 Future<void> _handleCommentSubmit(String content) async { 545 final messenger = ScaffoldMessenger.of(context); 546 547 try { 548 await _commentsProvider.createComment(content: content); 549 550 if (mounted) { 551 messenger.showSnackBar( 552 const SnackBar( 553 content: Text('Comment posted'), 554 behavior: SnackBarBehavior.floating, 555 duration: Duration(seconds: 2), 556 ), 557 ); 558 } 559 } on Exception catch (e) { 560 if (mounted) { 561 messenger.showSnackBar( 562 SnackBar( 563 content: Text('Failed to post comment: $e'), 564 behavior: SnackBarBehavior.floating, 565 backgroundColor: AppColors.primary, 566 ), 567 ); 568 } 569 rethrow; // Let ReplyScreen know submission failed 570 } 571 } 572 573 /// Handle reply to a comment (nested reply) 574 Future<void> _handleCommentReply( 575 String content, 576 ThreadViewComment parentComment, 577 ) async { 578 final messenger = ScaffoldMessenger.of(context); 579 580 try { 581 await _commentsProvider.createComment( 582 content: content, 583 parentComment: parentComment, 584 ); 585 586 if (mounted) { 587 messenger.showSnackBar( 588 const SnackBar( 589 content: Text('Reply posted'), 590 behavior: SnackBarBehavior.floating, 591 duration: Duration(seconds: 2), 592 ), 593 ); 594 } 595 } on Exception catch (e) { 596 if (mounted) { 597 messenger.showSnackBar( 598 SnackBar( 599 content: Text('Failed to post reply: $e'), 600 behavior: SnackBarBehavior.floating, 601 backgroundColor: AppColors.primary, 602 ), 603 ); 604 } 605 rethrow; // Let ReplyScreen know submission failed 606 } 607 } 608 609 /// Open reply screen for replying to a comment 610 void _openReplyToComment(ThreadViewComment comment) { 611 // Check authentication 612 final authProvider = context.read<AuthProvider>(); 613 if (!authProvider.isAuthenticated) { 614 ScaffoldMessenger.of(context).showSnackBar( 615 const SnackBar( 616 content: Text('Sign in to reply'), 617 behavior: SnackBarBehavior.floating, 618 ), 619 ); 620 return; 621 } 622 623 // Navigate to reply screen with comment context 624 Navigator.of(context).push( 625 MaterialPageRoute<void>( 626 builder: 627 (context) => ReplyScreen( 628 comment: comment, 629 onSubmit: (content) => _handleCommentReply(content, comment), 630 commentsProvider: _commentsProvider, 631 ), 632 ), 633 ); 634 } 635 636 /// Navigate to focused thread screen for deep threads 637 void _onContinueThread( 638 ThreadViewComment thread, 639 List<ThreadViewComment> ancestors, 640 ) { 641 Navigator.of(context).push( 642 MaterialPageRoute<void>( 643 builder: 644 (context) => FocusedThreadScreen( 645 thread: thread, 646 ancestors: ancestors, 647 onReply: _handleCommentReply, 648 commentsProvider: _commentsProvider, 649 ), 650 ), 651 ); 652 } 653 654 /// Build main content area 655 Widget _buildContent() { 656 // Use Consumer to rebuild when comments provider changes 657 return Consumer<CommentsProvider>( 658 builder: (context, commentsProvider, child) { 659 final isLoading = commentsProvider.isLoading; 660 final error = commentsProvider.error; 661 final comments = commentsProvider.comments; 662 final isLoadingMore = commentsProvider.isLoadingMore; 663 664 // Loading state (only show full-screen loader for initial load) 665 if (isLoading && comments.isEmpty) { 666 return const FullScreenLoading(); 667 } 668 669 // Error state (only show full-screen error when no comments loaded yet) 670 if (error != null && comments.isEmpty) { 671 return FullScreenError( 672 title: 'Failed to load comments', 673 message: ErrorMessages.getUserFriendly(error), 674 onRetry: commentsProvider.retry, 675 ); 676 } 677 678 // Content with RefreshIndicator and floating SliverAppBar 679 // Wrapped in Stack to add solid status bar background overlay 680 return Stack( 681 children: [ 682 RefreshIndicator( 683 onRefresh: _onRefresh, 684 color: AppColors.primary, 685 child: CustomScrollView( 686 controller: _scrollController, 687 physics: const AlwaysScrollableScrollPhysics(), 688 slivers: [ 689 // Floating app bar that hides on scroll down, 690 // shows on scroll up 691 SliverAppBar( 692 backgroundColor: AppColors.background, 693 surfaceTintColor: Colors.transparent, 694 foregroundColor: AppColors.textPrimary, 695 title: _buildCommunityTitle(), 696 centerTitle: false, 697 elevation: 0, 698 floating: true, 699 snap: true, 700 actions: [ 701 IconButton( 702 icon: const ShareIcon(color: AppColors.textPrimary), 703 onPressed: _handleShare, 704 tooltip: 'Share', 705 ), 706 ], 707 ), 708 709 // Post + comments + loading indicator 710 SliverSafeArea( 711 top: false, 712 sliver: SliverList( 713 delegate: SliverChildBuilderDelegate( 714 (context, index) { 715 // Post card (index 0) 716 if (index == 0) { 717 return Column( 718 children: [ 719 // Reuse PostCard (hide comment button in 720 // detail view) 721 // Use ValueListenableBuilder to only rebuild 722 // when time changes 723 _PostHeader( 724 post: widget.post, 725 currentTimeNotifier: 726 commentsProvider.currentTimeNotifier, 727 ), 728 729 // Visual divider before comments section 730 Container( 731 margin: const EdgeInsets.symmetric( 732 vertical: 16, 733 ), 734 height: 1, 735 color: AppColors.border, 736 ), 737 738 // Comments header with sort dropdown 739 CommentsHeader( 740 key: _commentsHeaderKey, 741 commentCount: comments.length, 742 currentSort: commentsProvider.sort, 743 onSortChanged: _onSortChanged, 744 ), 745 ], 746 ); 747 } 748 749 // Loading indicator or error at the end 750 if (index == comments.length + 1) { 751 if (isLoadingMore) { 752 return const InlineLoading(); 753 } 754 if (error != null) { 755 return InlineError( 756 message: ErrorMessages.getUserFriendly(error), 757 onRetry: () { 758 commentsProvider 759 ..clearError() 760 ..loadMoreComments(); 761 }, 762 ); 763 } 764 } 765 766 // Comment item - use existing CommentThread widget 767 final comment = comments[index - 1]; 768 return _CommentItem( 769 comment: comment, 770 currentTimeNotifier: 771 commentsProvider.currentTimeNotifier, 772 onCommentTap: _openReplyToComment, 773 collapsedComments: 774 commentsProvider.collapsedComments, 775 onCollapseToggle: commentsProvider.toggleCollapsed, 776 onContinueThread: _onContinueThread, 777 ); 778 }, 779 childCount: 780 1 + 781 comments.length + 782 (isLoadingMore || error != null ? 1 : 0), 783 ), 784 ), 785 ), 786 ], 787 ), 788 ), 789 // Prevents content showing through transparent status bar 790 const StatusBarOverlay(), 791 ], 792 ); 793 }, 794 ); 795 } 796} 797 798/// Post header widget that only rebuilds when time changes 799/// 800/// Extracted to prevent unnecessary rebuilds when comment list changes. 801/// Uses ValueListenableBuilder to listen only to time updates. 802class _PostHeader extends StatelessWidget { 803 const _PostHeader({required this.post, required this.currentTimeNotifier}); 804 805 final FeedViewPost post; 806 final ValueNotifier<DateTime?> currentTimeNotifier; 807 808 @override 809 Widget build(BuildContext context) { 810 return ValueListenableBuilder<DateTime?>( 811 valueListenable: currentTimeNotifier, 812 builder: (context, currentTime, child) { 813 return PostCard( 814 post: post, 815 currentTime: currentTime, 816 showCommentButton: false, 817 disableNavigation: true, 818 showActions: false, 819 showHeader: false, 820 showBorder: false, 821 showFullText: true, 822 showAuthorFooter: true, 823 textFontSize: 16, 824 textLineHeight: 1.6, 825 embedHeight: 280, 826 titleFontSize: 20, 827 titleFontWeight: FontWeight.w600, 828 ); 829 }, 830 ); 831 } 832} 833 834/// Comment item wrapper that only rebuilds when time changes 835/// 836/// Uses ValueListenableBuilder to prevent rebuilds when unrelated 837/// provider state changes (like loading state or error state). 838class _CommentItem extends StatelessWidget { 839 const _CommentItem({ 840 required this.comment, 841 required this.currentTimeNotifier, 842 this.onCommentTap, 843 this.collapsedComments = const {}, 844 this.onCollapseToggle, 845 this.onContinueThread, 846 }); 847 848 final ThreadViewComment comment; 849 final ValueNotifier<DateTime?> currentTimeNotifier; 850 final void Function(ThreadViewComment)? onCommentTap; 851 final Set<String> collapsedComments; 852 final void Function(String uri)? onCollapseToggle; 853 final void Function(ThreadViewComment, List<ThreadViewComment>)? 854 onContinueThread; 855 856 @override 857 Widget build(BuildContext context) { 858 return ValueListenableBuilder<DateTime?>( 859 valueListenable: currentTimeNotifier, 860 builder: (context, currentTime, child) { 861 return CommentThread( 862 thread: comment, 863 currentTime: currentTime, 864 maxDepth: 6, 865 onCommentTap: onCommentTap, 866 collapsedComments: collapsedComments, 867 onCollapseToggle: onCollapseToggle, 868 onContinueThread: onContinueThread, 869 ); 870 }, 871 ); 872 } 873}