Main coves client
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}