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