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