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 = 80 hasReplies && !isCollapsed 81 ? Column( 82 key: const ValueKey('replies'), 83 crossAxisAlignment: CrossAxisAlignment.start, 84 children: 85 thread.replies!.map((reply) { 86 return CommentThread( 87 thread: reply, 88 depth: depth + 1, 89 maxDepth: maxDepth, 90 currentTime: currentTime, 91 onLoadMoreReplies: onLoadMoreReplies, 92 onCommentTap: onCommentTap, 93 collapsedComments: collapsedComments, 94 onCollapseToggle: onCollapseToggle, 95 ); 96 }).toList(), 97 ) 98 : null; 99 100 return Column( 101 crossAxisAlignment: CrossAxisAlignment.start, 102 children: [ 103 // Render the comment with tap and long-press handlers 104 CommentCard( 105 comment: thread.comment, 106 depth: effectiveDepth, 107 currentTime: currentTime, 108 onTap: onCommentTap != null ? () => onCommentTap!(thread) : null, 109 onLongPress: 110 onCollapseToggle != null 111 ? () => onCollapseToggle!(thread.comment.uri) 112 : null, 113 isCollapsed: isCollapsed, 114 collapsedCount: collapsedCount, 115 ), 116 117 // Render replies with animation 118 if (hasReplies) 119 AnimatedSwitcher( 120 duration: const Duration(milliseconds: 350), 121 reverseDuration: const Duration(milliseconds: 280), 122 switchInCurve: Curves.easeOutCubic, 123 switchOutCurve: Curves.easeInCubic, 124 transitionBuilder: (Widget child, Animation<double> animation) { 125 // Determine if we're expanding or collapsing based on key 126 final isExpanding = child.key == const ValueKey('replies'); 127 128 // Different fade curves for expand vs collapse 129 final fadeCurve = 130 isExpanding 131 ? const Interval(0, 0.7, curve: Curves.easeOut) 132 : const Interval(0, 0.5, curve: Curves.easeIn); 133 134 // Slide down from parent on expand, slide up on collapse 135 final slideOffset = 136 isExpanding 137 ? Tween<Offset>( 138 begin: const Offset(0, -0.15), 139 end: Offset.zero, 140 ).animate( 141 CurvedAnimation( 142 parent: animation, 143 curve: const Interval( 144 0.2, 145 1, 146 curve: Curves.easeOutCubic, 147 ), 148 ), 149 ) 150 : Tween<Offset>( 151 begin: Offset.zero, 152 end: const Offset(0, -0.05), 153 ).animate( 154 CurvedAnimation( 155 parent: animation, 156 curve: Curves.easeIn, 157 ), 158 ); 159 160 return FadeTransition( 161 opacity: CurvedAnimation(parent: animation, curve: fadeCurve), 162 child: ClipRect( 163 child: SizeTransition( 164 sizeFactor: animation, 165 axisAlignment: -1, 166 child: SlideTransition(position: slideOffset, child: child), 167 ), 168 ), 169 ); 170 }, 171 layoutBuilder: (currentChild, previousChildren) { 172 // Stack children during transition - ClipRect prevents 173 // overflow artifacts on deeply nested threads 174 return ClipRect( 175 child: Stack( 176 children: [ 177 ...previousChildren, 178 if (currentChild != null) currentChild, 179 ], 180 ), 181 ); 182 }, 183 child: 184 isCollapsed 185 ? const SizedBox.shrink(key: ValueKey('collapsed')) 186 : repliesWidget, 187 ), 188 189 // Show "Load more replies" button if there are more (and not collapsed) 190 if (thread.hasMore && !isCollapsed) _buildLoadMoreButton(context), 191 ], 192 ); 193 } 194 195 /// Builds the "Load more replies" button 196 Widget _buildLoadMoreButton(BuildContext context) { 197 // Calculate left padding based on depth (align with replies) 198 final effectiveDepth = depth > maxDepth ? maxDepth : depth; 199 final leftPadding = 16.0 + ((effectiveDepth + 1) * 12.0); 200 201 return Container( 202 padding: EdgeInsets.fromLTRB(leftPadding, 8, 16, 8), 203 decoration: const BoxDecoration( 204 border: Border(bottom: BorderSide(color: AppColors.border)), 205 ), 206 child: InkWell( 207 onTap: () { 208 if (onLoadMoreReplies != null) { 209 onLoadMoreReplies!(); 210 } else { 211 if (kDebugMode) { 212 debugPrint('Load more replies tapped (no handler provided)'); 213 } 214 } 215 }, 216 child: Padding( 217 padding: const EdgeInsets.symmetric(vertical: 4), 218 child: Row( 219 children: [ 220 Icon( 221 Icons.add_circle_outline, 222 size: 16, 223 color: AppColors.primary.withValues(alpha: 0.8), 224 ), 225 const SizedBox(width: 6), 226 Text( 227 'Load more replies', 228 style: TextStyle( 229 color: AppColors.primary.withValues(alpha: 0.8), 230 fontSize: 13, 231 fontWeight: FontWeight.w500, 232 ), 233 ), 234 ], 235 ), 236 ), 237 ), 238 ); 239 } 240}