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