1import 'package:flutter/foundation.dart'; 2import 'package:flutter/material.dart'; 3 4import '../constants/app_colors.dart'; 5import '../models/comment.dart'; 6import 'comment_card.dart'; 7 8/// Comment thread widget for displaying comments and their nested replies 9/// 10/// Recursively displays a ThreadViewComment and its replies: 11/// - Renders the comment using CommentCard with optimistic voting 12/// via VoteProvider 13/// - Indents nested replies visually 14/// - Limits nesting depth to prevent excessive indentation 15/// - Shows "Load more replies" button when hasMore is true 16/// - Supports tap-to-reply via [onCommentTap] callback 17/// - Supports long-press to collapse threads via [onCollapseToggle] callback 18/// 19/// The [maxDepth] parameter controls how deeply nested comments can be 20/// before they're rendered at the same level to prevent UI overflow. 21/// 22/// When a comment is collapsed (via [collapsedComments]), its replies are 23/// hidden with a smooth animation and a badge shows the hidden count. 24class CommentThread extends StatelessWidget { 25 const CommentThread({ 26 required this.thread, 27 this.depth = 0, 28 this.maxDepth = 5, 29 this.currentTime, 30 this.onLoadMoreReplies, 31 this.onCommentTap, 32 this.collapsedComments = const {}, 33 this.onCollapseToggle, 34 super.key, 35 }); 36 37 final ThreadViewComment thread; 38 final int depth; 39 final int maxDepth; 40 final DateTime? currentTime; 41 final VoidCallback? onLoadMoreReplies; 42 43 /// Callback when a comment is tapped (for reply functionality) 44 final void Function(ThreadViewComment)? onCommentTap; 45 46 /// Set of collapsed comment URIs 47 final Set<String> collapsedComments; 48 49 /// Callback when a comment collapse state is toggled 50 final void Function(String uri)? onCollapseToggle; 51 52 /// Count all descendants recursively 53 static int countDescendants(ThreadViewComment thread) { 54 if (thread.replies == null || thread.replies!.isEmpty) { 55 return 0; 56 } 57 var count = thread.replies!.length; 58 for (final reply in thread.replies!) { 59 count += countDescendants(reply); 60 } 61 return count; 62 } 63 64 @override 65 Widget build(BuildContext context) { 66 // Calculate effective depth (flatten after maxDepth) 67 final effectiveDepth = depth > maxDepth ? maxDepth : depth; 68 69 // Check if this comment is collapsed 70 final isCollapsed = collapsedComments.contains(thread.comment.uri); 71 final collapsedCount = isCollapsed ? countDescendants(thread) : 0; 72 73 // Check if there are replies to render 74 final hasReplies = thread.replies != null && thread.replies!.isNotEmpty; 75 76 // Only build replies widget when NOT collapsed (optimization) 77 // When collapsed, AnimatedSwitcher shows SizedBox.shrink() so children 78 // are never mounted - no need to build them at all 79 final repliesWidget = hasReplies && !isCollapsed 80 ? Column( 81 key: const ValueKey('replies'), 82 crossAxisAlignment: CrossAxisAlignment.start, 83 children: thread.replies!.map((reply) { 84 return CommentThread( 85 thread: reply, 86 depth: depth + 1, 87 maxDepth: maxDepth, 88 currentTime: currentTime, 89 onLoadMoreReplies: onLoadMoreReplies, 90 onCommentTap: onCommentTap, 91 collapsedComments: collapsedComments, 92 onCollapseToggle: onCollapseToggle, 93 ); 94 }).toList(), 95 ) 96 : null; 97 98 return Column( 99 crossAxisAlignment: CrossAxisAlignment.start, 100 children: [ 101 // Render the comment with tap and long-press handlers 102 CommentCard( 103 comment: thread.comment, 104 depth: effectiveDepth, 105 currentTime: currentTime, 106 onTap: onCommentTap != null ? () => onCommentTap!(thread) : null, 107 onLongPress: onCollapseToggle != null 108 ? () => onCollapseToggle!(thread.comment.uri) 109 : null, 110 isCollapsed: isCollapsed, 111 collapsedCount: collapsedCount, 112 ), 113 114 // Render replies with animation 115 if (hasReplies) 116 AnimatedSwitcher( 117 duration: const Duration(milliseconds: 200), 118 switchInCurve: Curves.easeInOutCubicEmphasized, 119 switchOutCurve: Curves.easeInOutCubicEmphasized, 120 transitionBuilder: (Widget child, Animation<double> animation) { 121 return SizeTransition( 122 sizeFactor: animation, 123 axisAlignment: -1, 124 child: child, 125 ); 126 }, 127 child: isCollapsed 128 ? const SizedBox.shrink(key: ValueKey('collapsed')) 129 : repliesWidget, 130 ), 131 132 // Show "Load more replies" button if there are more (and not collapsed) 133 if (thread.hasMore && !isCollapsed) _buildLoadMoreButton(context), 134 ], 135 ); 136 } 137 138 /// Builds the "Load more replies" button 139 Widget _buildLoadMoreButton(BuildContext context) { 140 // Calculate left padding based on depth (align with replies) 141 final effectiveDepth = depth > maxDepth ? maxDepth : depth; 142 final leftPadding = 16.0 + ((effectiveDepth + 1) * 12.0); 143 144 return Container( 145 padding: EdgeInsets.fromLTRB(leftPadding, 8, 16, 8), 146 decoration: const BoxDecoration( 147 border: Border(bottom: BorderSide(color: AppColors.border)), 148 ), 149 child: InkWell( 150 onTap: () { 151 if (onLoadMoreReplies != null) { 152 onLoadMoreReplies!(); 153 } else { 154 if (kDebugMode) { 155 debugPrint('Load more replies tapped (no handler provided)'); 156 } 157 } 158 }, 159 child: Padding( 160 padding: const EdgeInsets.symmetric(vertical: 4), 161 child: Row( 162 children: [ 163 Icon( 164 Icons.add_circle_outline, 165 size: 16, 166 color: AppColors.primary.withValues(alpha: 0.8), 167 ), 168 const SizedBox(width: 6), 169 Text( 170 'Load more replies', 171 style: TextStyle( 172 color: AppColors.primary.withValues(alpha: 0.8), 173 fontSize: 13, 174 fontWeight: FontWeight.w500, 175 ), 176 ), 177 ], 178 ), 179 ), 180 ), 181 ); 182 } 183}