Main coves client
1import 'package:cached_network_image/cached_network_image.dart';
2import 'package:flutter/foundation.dart';
3import 'package:flutter/material.dart';
4import 'package:flutter/services.dart';
5import 'package:provider/provider.dart';
6
7import '../constants/app_colors.dart';
8import '../constants/threading_colors.dart';
9import '../models/comment.dart';
10import '../models/post.dart';
11import '../providers/auth_provider.dart';
12import '../providers/vote_provider.dart';
13import '../utils/date_time_utils.dart';
14import 'icons/animated_heart_icon.dart';
15import 'sign_in_dialog.dart';
16
17/// Comment card widget for displaying individual comments
18///
19/// Displays a comment with:
20/// - Author information (avatar, handle, timestamp)
21/// - Comment content (supports facets for links/mentions)
22/// - Heart vote button with optimistic updates via VoteProvider
23/// - Visual threading indicator based on nesting depth
24/// - Tap-to-reply functionality via [onTap] callback
25/// - Long-press to collapse thread via [onLongPress] callback
26///
27/// The [currentTime] parameter allows passing the current time for
28/// time-ago calculations, enabling periodic updates and testing.
29///
30/// When [isCollapsed] is true, displays a badge showing [collapsedCount]
31/// hidden replies on the threading indicator bar.
32class CommentCard extends StatelessWidget {
33 const CommentCard({
34 required this.comment,
35 this.depth = 0,
36 this.currentTime,
37 this.onTap,
38 this.onLongPress,
39 this.isCollapsed = false,
40 this.collapsedCount = 0,
41 super.key,
42 });
43
44 final CommentView comment;
45 final int depth;
46 final DateTime? currentTime;
47
48 /// Callback when the comment is tapped (for reply functionality)
49 final VoidCallback? onTap;
50
51 /// Callback when the comment is long-pressed (for collapse functionality)
52 final VoidCallback? onLongPress;
53
54 /// Whether this comment's thread is currently collapsed
55 final bool isCollapsed;
56
57 /// Number of replies hidden when collapsed
58 final int collapsedCount;
59
60 @override
61 Widget build(BuildContext context) {
62 // All comments get at least 1 threading line (depth + 1)
63 final threadingLineCount = depth + 1;
64 // Calculate left padding: (6px per line) + 14px base padding
65 final leftPadding = (threadingLineCount * 6.0) + 14.0;
66 // Border should start after the threading lines (add 2px to clear
67 // the stroke width)
68 final borderLeftOffset = (threadingLineCount * 6.0) + 2.0;
69
70 return Semantics(
71 button: true,
72 hint:
73 onLongPress != null
74 ? (isCollapsed
75 ? 'Double tap and hold to expand thread'
76 : 'Double tap and hold to collapse thread')
77 : null,
78 child: GestureDetector(
79 onLongPress:
80 onLongPress != null
81 ? () {
82 HapticFeedback.mediumImpact();
83 onLongPress!();
84 }
85 : null,
86 child: InkWell(
87 onTap: onTap,
88 child: Container(
89 decoration: const BoxDecoration(color: AppColors.background),
90 child: Stack(
91 children: [
92 // Threading indicators - vertical lines showing
93 // nesting ancestry
94 Positioned.fill(
95 child: CustomPaint(
96 painter: _CommentDepthPainter(depth: threadingLineCount),
97 ),
98 ),
99 // Bottom border
100 // (starts after threading lines, not overlapping them)
101 Positioned(
102 left: borderLeftOffset,
103 right: 0,
104 bottom: 0,
105 child: Container(height: 1, color: AppColors.border),
106 ),
107 // Comment content with depth-based left padding
108 // Animate height changes when collapsing/expanding
109 AnimatedSize(
110 duration: const Duration(milliseconds: 250),
111 curve: Curves.easeInOutCubic,
112 alignment: Alignment.topCenter,
113 child: Padding(
114 padding: EdgeInsets.fromLTRB(
115 leftPadding,
116 isCollapsed ? 10 : 12,
117 16,
118 isCollapsed ? 10 : 8,
119 ),
120 child: Column(
121 crossAxisAlignment: CrossAxisAlignment.start,
122 children: [
123 // Author info row
124 Row(
125 children: [
126 // Author avatar
127 _buildAuthorAvatar(comment.author),
128 const SizedBox(width: 8),
129 Expanded(
130 child: Text(
131 '@${comment.author.handle}',
132 style: TextStyle(
133 color: AppColors.textPrimary.withValues(
134 alpha: isCollapsed ? 0.7 : 0.5,
135 ),
136 fontSize: 13,
137 fontWeight: FontWeight.w500,
138 ),
139 ),
140 ),
141 // Show collapsed count OR time ago
142 if (isCollapsed && collapsedCount > 0)
143 _buildCollapsedBadge()
144 else
145 Text(
146 DateTimeUtils.formatTimeAgo(
147 comment.createdAt,
148 currentTime: currentTime,
149 ),
150 style: TextStyle(
151 color: AppColors.textPrimary.withValues(
152 alpha: 0.5,
153 ),
154 fontSize: 12,
155 ),
156 ),
157 ],
158 ),
159
160 // Only show content and actions when expanded
161 if (!isCollapsed) ...[
162 const SizedBox(height: 8),
163
164 // Comment content
165 if (comment.content.isNotEmpty) ...[
166 _buildCommentContent(comment),
167 const SizedBox(height: 8),
168 ],
169
170 // Action buttons (just vote for now)
171 _buildActionButtons(context),
172 ],
173 ],
174 ),
175 ),
176 ),
177 ],
178 ),
179 ),
180 ),
181 ),
182 );
183 }
184
185 /// Builds the author avatar widget
186 Widget _buildAuthorAvatar(AuthorView author) {
187 if (author.avatar != null && author.avatar!.isNotEmpty) {
188 // Show real author avatar
189 return ClipRRect(
190 borderRadius: BorderRadius.circular(12),
191 child: CachedNetworkImage(
192 imageUrl: author.avatar!,
193 width: 14,
194 height: 14,
195 fit: BoxFit.cover,
196 placeholder: (context, url) => _buildFallbackAvatar(author),
197 errorWidget: (context, url, error) => _buildFallbackAvatar(author),
198 ),
199 );
200 }
201
202 // Fallback to letter placeholder
203 return _buildFallbackAvatar(author);
204 }
205
206 /// Builds a fallback avatar with the first letter of handle
207 Widget _buildFallbackAvatar(AuthorView author) {
208 final firstLetter = author.handle.isNotEmpty ? author.handle[0] : '?';
209 return Container(
210 width: 24,
211 height: 24,
212 decoration: BoxDecoration(
213 color: AppColors.primary,
214 borderRadius: BorderRadius.circular(12),
215 ),
216 child: Center(
217 child: Text(
218 firstLetter.toUpperCase(),
219 style: const TextStyle(
220 color: AppColors.textPrimary,
221 fontSize: 12,
222 fontWeight: FontWeight.bold,
223 ),
224 ),
225 ),
226 );
227 }
228
229 /// Builds the compact collapsed badge showing "+X"
230 Widget _buildCollapsedBadge() {
231 return Container(
232 padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
233 decoration: BoxDecoration(
234 color: AppColors.primary.withValues(alpha: 0.15),
235 borderRadius: BorderRadius.circular(10),
236 ),
237 child: Text(
238 '+$collapsedCount',
239 style: TextStyle(
240 color: AppColors.primary.withValues(alpha: 0.9),
241 fontSize: 12,
242 fontWeight: FontWeight.w600,
243 ),
244 ),
245 );
246 }
247
248 /// Builds the comment content with support for facets
249 Widget _buildCommentContent(CommentView comment) {
250 // TODO: Add facet support for links and mentions like PostCard does
251 // For now, just render plain text
252 return Text(
253 comment.content,
254 style: const TextStyle(
255 color: AppColors.textPrimary,
256 fontSize: 14,
257 height: 1.4,
258 ),
259 );
260 }
261
262 /// Builds the action buttons row (vote button)
263 Widget _buildActionButtons(BuildContext context) {
264 return Consumer<VoteProvider>(
265 builder: (context, voteProvider, child) {
266 // Get optimistic vote state from provider
267 final isLiked = voteProvider.isLiked(comment.uri);
268 final adjustedScore = voteProvider.getAdjustedScore(
269 comment.uri,
270 comment.stats.score,
271 );
272
273 return Row(
274 mainAxisAlignment: MainAxisAlignment.end,
275 children: [
276 // Heart vote button
277 Semantics(
278 button: true,
279 label:
280 isLiked
281 ? 'Unlike comment, $adjustedScore '
282 '${adjustedScore == 1 ? "like" : "likes"}'
283 : 'Like comment, $adjustedScore '
284 '${adjustedScore == 1 ? "like" : "likes"}',
285 child: InkWell(
286 onTap: () async {
287 // Check authentication
288 final authProvider = context.read<AuthProvider>();
289 if (!authProvider.isAuthenticated) {
290 // Show sign-in dialog
291 final shouldSignIn = await SignInDialog.show(
292 context,
293 message: 'You need to sign in to vote on comments.',
294 );
295
296 if ((shouldSignIn ?? false) && context.mounted) {
297 // TODO: Navigate to sign-in screen
298 if (kDebugMode) {
299 debugPrint('Navigate to sign-in screen');
300 }
301 }
302 return;
303 }
304
305 // Light haptic feedback
306 await HapticFeedback.lightImpact();
307
308 // Toggle vote with optimistic update via VoteProvider
309 try {
310 await voteProvider.toggleVote(
311 postUri: comment.uri,
312 postCid: comment.cid,
313 );
314 } on Exception catch (e) {
315 if (kDebugMode) {
316 debugPrint('Failed to vote on comment: $e');
317 }
318 // TODO: Show error snackbar
319 }
320 },
321 child: Padding(
322 padding: const EdgeInsets.symmetric(
323 horizontal: 8,
324 vertical: 6,
325 ),
326 child: Row(
327 mainAxisSize: MainAxisSize.min,
328 children: [
329 AnimatedHeartIcon(
330 isLiked: isLiked,
331 size: 16,
332 color: AppColors.textPrimary.withValues(alpha: 0.6),
333 likedColor: const Color(0xFFFF0033),
334 ),
335 const SizedBox(width: 5),
336 Text(
337 DateTimeUtils.formatCount(adjustedScore),
338 style: TextStyle(
339 color: AppColors.textPrimary.withValues(alpha: 0.6),
340 fontSize: 12,
341 ),
342 ),
343 ],
344 ),
345 ),
346 ),
347 ),
348 ],
349 );
350 },
351 );
352 }
353}
354
355/// Custom painter for drawing comment depth indicator lines
356class _CommentDepthPainter extends CustomPainter {
357 _CommentDepthPainter({required this.depth});
358 final int depth;
359
360 @override
361 void paint(Canvas canvas, Size size) {
362 final paint =
363 Paint()
364 ..strokeWidth = 2.0
365 ..style = PaintingStyle.stroke;
366
367 // Draw vertical line for each depth level with different colors
368 for (var i = 0; i < depth; i++) {
369 // Cycle through colors based on depth level
370 paint.color = kThreadingColors[i % kThreadingColors.length].withValues(
371 alpha: 0.5,
372 );
373
374 final xPosition = (i + 1) * 6.0;
375 canvas.drawLine(
376 Offset(xPosition, 0),
377 Offset(xPosition, size.height),
378 paint,
379 );
380 }
381 }
382
383 @override
384 bool shouldRepaint(_CommentDepthPainter oldDelegate) {
385 return oldDelegate.depth != depth;
386 }
387}