at main 12 kB view raw
1import 'package:flutter/foundation.dart'; 2import 'package:flutter/material.dart'; 3 4import '../constants/app_colors.dart'; 5import '../constants/threading_colors.dart'; 6import '../models/comment.dart'; 7import 'comment_card.dart'; 8 9/// Comment thread widget for displaying comments and their nested replies 10/// 11/// Recursively displays a ThreadViewComment and its replies: 12/// - Renders the comment using CommentCard with optimistic voting 13/// via VoteProvider 14/// - Indents nested replies visually 15/// - Limits nesting depth to prevent excessive indentation 16/// - Shows "Load more replies" button when hasMore is true 17/// - Supports tap-to-reply via [onCommentTap] callback 18/// - Supports long-press to collapse threads via [onCollapseToggle] callback 19/// 20/// The [maxDepth] parameter controls how deeply nested comments can be 21/// before they're rendered at the same level to prevent UI overflow. 22/// 23/// When a comment is collapsed (via [collapsedComments]), its replies are 24/// hidden with a smooth animation and a badge shows the hidden count. 25class CommentThread extends StatelessWidget { 26 const CommentThread({ 27 required this.thread, 28 this.depth = 0, 29 this.maxDepth = 5, 30 this.currentTime, 31 this.onLoadMoreReplies, 32 this.onCommentTap, 33 this.collapsedComments = const {}, 34 this.onCollapseToggle, 35 this.onContinueThread, 36 this.ancestors = const [], 37 super.key, 38 }); 39 40 final ThreadViewComment thread; 41 final int depth; 42 final int maxDepth; 43 final DateTime? currentTime; 44 final VoidCallback? onLoadMoreReplies; 45 46 /// Callback when a comment is tapped (for reply functionality) 47 final void Function(ThreadViewComment)? onCommentTap; 48 49 /// Set of collapsed comment URIs 50 final Set<String> collapsedComments; 51 52 /// Callback when a comment collapse state is toggled 53 final void Function(String uri)? onCollapseToggle; 54 55 /// Callback when "Read more replies" is tapped at max depth 56 /// Passes the thread to continue and its ancestors for context 57 final void Function( 58 ThreadViewComment thread, 59 List<ThreadViewComment> ancestors, 60 )? 61 onContinueThread; 62 63 /// Ancestor comments leading to this thread (for continue thread context) 64 final List<ThreadViewComment> ancestors; 65 66 /// Count all descendants recursively 67 static int countDescendants(ThreadViewComment thread) { 68 if (thread.replies == null || thread.replies!.isEmpty) { 69 return 0; 70 } 71 var count = thread.replies!.length; 72 for (final reply in thread.replies!) { 73 count += countDescendants(reply); 74 } 75 return count; 76 } 77 78 @override 79 Widget build(BuildContext context) { 80 // Check if this comment is collapsed 81 final isCollapsed = collapsedComments.contains(thread.comment.uri); 82 final collapsedCount = isCollapsed ? countDescendants(thread) : 0; 83 84 // Check if there are replies to render 85 final hasReplies = thread.replies != null && thread.replies!.isNotEmpty; 86 87 // Check if we've hit max depth - stop threading here 88 final atMaxDepth = depth >= maxDepth; 89 90 // Only count descendants when needed (at max depth for continue link) 91 // Avoids O(n²) traversal on every render 92 final needsDescendantCount = hasReplies && atMaxDepth && !isCollapsed; 93 final replyCount = needsDescendantCount ? countDescendants(thread) : 0; 94 95 // Build updated ancestors list including current thread 96 final childAncestors = [...ancestors, thread]; 97 98 // Only build replies widget when NOT collapsed and NOT at max depth 99 // When at max depth, we show "Read more replies" link instead 100 final repliesWidget = 101 hasReplies && !isCollapsed && !atMaxDepth 102 ? Column( 103 key: const ValueKey('replies'), 104 crossAxisAlignment: CrossAxisAlignment.start, 105 children: 106 thread.replies!.map((reply) { 107 return CommentThread( 108 thread: reply, 109 depth: depth + 1, 110 maxDepth: maxDepth, 111 currentTime: currentTime, 112 onLoadMoreReplies: onLoadMoreReplies, 113 onCommentTap: onCommentTap, 114 collapsedComments: collapsedComments, 115 onCollapseToggle: onCollapseToggle, 116 onContinueThread: onContinueThread, 117 ancestors: childAncestors, 118 ); 119 }).toList(), 120 ) 121 : null; 122 123 return Column( 124 crossAxisAlignment: CrossAxisAlignment.start, 125 children: [ 126 // Render the comment with tap and long-press handlers 127 CommentCard( 128 comment: thread.comment, 129 depth: depth, 130 currentTime: currentTime, 131 onTap: onCommentTap != null ? () => onCommentTap!(thread) : null, 132 onLongPress: 133 onCollapseToggle != null 134 ? () => onCollapseToggle!(thread.comment.uri) 135 : null, 136 isCollapsed: isCollapsed, 137 collapsedCount: collapsedCount, 138 ), 139 140 // Render replies with animation (only when NOT at max depth) 141 if (hasReplies && !atMaxDepth) 142 AnimatedSwitcher( 143 duration: const Duration(milliseconds: 350), 144 reverseDuration: const Duration(milliseconds: 280), 145 switchInCurve: Curves.easeOutCubic, 146 switchOutCurve: Curves.easeInCubic, 147 transitionBuilder: (Widget child, Animation<double> animation) { 148 // Determine if we're expanding or collapsing based on key 149 final isExpanding = child.key == const ValueKey('replies'); 150 151 // Different fade curves for expand vs collapse 152 final fadeCurve = 153 isExpanding 154 ? const Interval(0, 0.7, curve: Curves.easeOut) 155 : const Interval(0, 0.5, curve: Curves.easeIn); 156 157 // Slide down from parent on expand, slide up on collapse 158 final slideOffset = 159 isExpanding 160 ? Tween<Offset>( 161 begin: const Offset(0, -0.15), 162 end: Offset.zero, 163 ).animate( 164 CurvedAnimation( 165 parent: animation, 166 curve: const Interval( 167 0.2, 168 1, 169 curve: Curves.easeOutCubic, 170 ), 171 ), 172 ) 173 : Tween<Offset>( 174 begin: Offset.zero, 175 end: const Offset(0, -0.05), 176 ).animate( 177 CurvedAnimation( 178 parent: animation, 179 curve: Curves.easeIn, 180 ), 181 ); 182 183 return FadeTransition( 184 opacity: CurvedAnimation(parent: animation, curve: fadeCurve), 185 child: ClipRect( 186 child: SizeTransition( 187 sizeFactor: animation, 188 axisAlignment: -1, 189 child: SlideTransition(position: slideOffset, child: child), 190 ), 191 ), 192 ); 193 }, 194 layoutBuilder: (currentChild, previousChildren) { 195 // Stack children during transition - ClipRect prevents 196 // overflow artifacts on deeply nested threads 197 return ClipRect( 198 child: Stack( 199 children: [ 200 ...previousChildren, 201 if (currentChild != null) currentChild, 202 ], 203 ), 204 ); 205 }, 206 child: 207 isCollapsed 208 ? const SizedBox.shrink(key: ValueKey('collapsed')) 209 : repliesWidget, 210 ), 211 212 // Show "Read more replies" link at max depth when there are replies 213 if (hasReplies && atMaxDepth && !isCollapsed) 214 _buildContinueThreadLink(context, replyCount), 215 216 // Show "Load more replies" button if there are more (and not collapsed) 217 if (thread.hasMore && !isCollapsed) _buildLoadMoreButton(context), 218 ], 219 ); 220 } 221 222 /// Builds the "Read X more replies" link for continuing deep threads 223 Widget _buildContinueThreadLink(BuildContext context, int replyCount) { 224 final replyText = replyCount == 1 ? 'reply' : 'replies'; 225 226 // Thread one level deeper than parent to feel like a child element 227 final threadingLineCount = depth + 2; 228 final leftPadding = (threadingLineCount * 6.0) + 14.0; 229 230 return InkWell( 231 onTap: () { 232 if (onContinueThread != null) { 233 // Pass thread and ancestors for context display 234 // Don't include thread - it's the anchor, not an ancestor 235 onContinueThread!(thread, ancestors); 236 } else { 237 if (kDebugMode) { 238 debugPrint('Continue thread tapped (no handler provided)'); 239 } 240 } 241 }, 242 child: Stack( 243 children: [ 244 // Threading lines (one deeper than parent comment) 245 Positioned.fill( 246 child: CustomPaint( 247 painter: _ContinueThreadPainter(depth: threadingLineCount), 248 ), 249 ), 250 // Content 251 Padding( 252 padding: EdgeInsets.fromLTRB(leftPadding, 10, 16, 10), 253 child: Row( 254 mainAxisSize: MainAxisSize.min, 255 children: [ 256 Text( 257 'Read $replyCount more $replyText', 258 style: TextStyle( 259 color: AppColors.primary.withValues(alpha: 0.9), 260 fontSize: 13, 261 fontWeight: FontWeight.w500, 262 ), 263 ), 264 const SizedBox(width: 6), 265 Icon( 266 Icons.arrow_forward_ios, 267 size: 11, 268 color: AppColors.primary.withValues(alpha: 0.7), 269 ), 270 ], 271 ), 272 ), 273 ], 274 ), 275 ); 276 } 277 278 /// Builds the "Load more replies" button 279 Widget _buildLoadMoreButton(BuildContext context) { 280 // Calculate left padding based on depth (align with replies) 281 final leftPadding = 16.0 + ((depth + 1) * 12.0); 282 283 return Container( 284 padding: EdgeInsets.fromLTRB(leftPadding, 8, 16, 8), 285 decoration: const BoxDecoration( 286 border: Border(bottom: BorderSide(color: AppColors.border)), 287 ), 288 child: InkWell( 289 onTap: () { 290 if (onLoadMoreReplies != null) { 291 onLoadMoreReplies!(); 292 } else { 293 if (kDebugMode) { 294 debugPrint('Load more replies tapped (no handler provided)'); 295 } 296 } 297 }, 298 child: Padding( 299 padding: const EdgeInsets.symmetric(vertical: 4), 300 child: Row( 301 children: [ 302 Icon( 303 Icons.add_circle_outline, 304 size: 16, 305 color: AppColors.primary.withValues(alpha: 0.8), 306 ), 307 const SizedBox(width: 6), 308 Text( 309 'Load more replies', 310 style: TextStyle( 311 color: AppColors.primary.withValues(alpha: 0.8), 312 fontSize: 13, 313 fontWeight: FontWeight.w500, 314 ), 315 ), 316 ], 317 ), 318 ), 319 ), 320 ); 321 } 322} 323 324/// Custom painter for drawing threading lines on continue thread link 325class _ContinueThreadPainter extends CustomPainter { 326 _ContinueThreadPainter({required this.depth}); 327 final int depth; 328 329 @override 330 void paint(Canvas canvas, Size size) { 331 final paint = 332 Paint() 333 ..strokeWidth = 2.0 334 ..style = PaintingStyle.stroke; 335 336 // Draw vertical line for each depth level with different colors 337 for (var i = 0; i < depth; i++) { 338 // Cycle through colors based on depth level 339 paint.color = kThreadingColors[i % kThreadingColors.length].withValues( 340 alpha: 0.5, 341 ); 342 343 final xPosition = (i + 1) * 6.0; 344 canvas.drawLine( 345 Offset(xPosition, 0), 346 Offset(xPosition, size.height), 347 paint, 348 ); 349 } 350 } 351 352 @override 353 bool shouldRepaint(_ContinueThreadPainter oldDelegate) { 354 return oldDelegate.depth != depth; 355 } 356}