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 GestureDetector(
70 onLongPress: onLongPress != null
71 ? () {
72 HapticFeedback.mediumImpact();
73 onLongPress!();
74 }
75 : null,
76 child: InkWell(
77 onTap: onTap,
78 child: Container(
79 decoration: const BoxDecoration(color: AppColors.background),
80 child: Stack(
81 children: [
82 // Threading indicators - vertical lines showing nesting ancestry
83 Positioned.fill(
84 child: CustomPaint(
85 painter: _CommentDepthPainter(depth: threadingLineCount),
86 ),
87 ),
88 // Collapsed count badge - positioned after threading lines
89 // to avoid overlap at any depth level
90 if (isCollapsed && collapsedCount > 0)
91 Positioned(
92 left: borderLeftOffset + 4,
93 bottom: 8,
94 child: Container(
95 padding: const EdgeInsets.symmetric(
96 horizontal: 6,
97 vertical: 2,
98 ),
99 decoration: BoxDecoration(
100 color: AppColors.primary,
101 borderRadius: BorderRadius.circular(8),
102 ),
103 child: Text(
104 '+$collapsedCount hidden',
105 style: const TextStyle(
106 color: AppColors.textPrimary,
107 fontSize: 10,
108 fontWeight: FontWeight.w500,
109 ),
110 ),
111 ),
112 ),
113 // Bottom border
114 // (starts after threading lines, not overlapping them)
115 Positioned(
116 left: borderLeftOffset,
117 right: 0,
118 bottom: 0,
119 child: Container(height: 1, color: AppColors.border),
120 ),
121 // Comment content with depth-based left padding
122 Padding(
123 padding: EdgeInsets.fromLTRB(leftPadding, 12, 16, 8),
124 child: Column(
125 crossAxisAlignment: CrossAxisAlignment.start,
126 children: [
127 // Author info row
128 Row(
129 children: [
130 // Author avatar
131 _buildAuthorAvatar(comment.author),
132 const SizedBox(width: 8),
133 Expanded(
134 child: Column(
135 crossAxisAlignment: CrossAxisAlignment.start,
136 children: [
137 // Author handle
138 Text(
139 '@${comment.author.handle}',
140 style: TextStyle(
141 color: AppColors.textPrimary.withValues(
142 alpha: 0.5,
143 ),
144 fontSize: 13,
145 fontWeight: FontWeight.w500,
146 ),
147 ),
148 ],
149 ),
150 ),
151 // Time ago
152 Text(
153 DateTimeUtils.formatTimeAgo(
154 comment.createdAt,
155 currentTime: currentTime,
156 ),
157 style: TextStyle(
158 color: AppColors.textPrimary.withValues(alpha: 0.5),
159 fontSize: 12,
160 ),
161 ),
162 ],
163 ),
164 const SizedBox(height: 8),
165
166 // Comment content
167 if (comment.content.isNotEmpty) ...[
168 _buildCommentContent(comment),
169 const SizedBox(height: 8),
170 ],
171
172 // Action buttons (just vote for now)
173 _buildActionButtons(context),
174 ],
175 ),
176 ),
177 ],
178 ),
179 ),
180 ),
181 );
182 }
183
184 /// Builds the author avatar widget
185 Widget _buildAuthorAvatar(AuthorView author) {
186 if (author.avatar != null && author.avatar!.isNotEmpty) {
187 // Show real author avatar
188 return ClipRRect(
189 borderRadius: BorderRadius.circular(12),
190 child: CachedNetworkImage(
191 imageUrl: author.avatar!,
192 width: 14,
193 height: 14,
194 fit: BoxFit.cover,
195 placeholder: (context, url) => _buildFallbackAvatar(author),
196 errorWidget: (context, url, error) => _buildFallbackAvatar(author),
197 ),
198 );
199 }
200
201 // Fallback to letter placeholder
202 return _buildFallbackAvatar(author);
203 }
204
205 /// Builds a fallback avatar with the first letter of handle
206 Widget _buildFallbackAvatar(AuthorView author) {
207 final firstLetter = author.handle.isNotEmpty ? author.handle[0] : '?';
208 return Container(
209 width: 24,
210 height: 24,
211 decoration: BoxDecoration(
212 color: AppColors.primary,
213 borderRadius: BorderRadius.circular(12),
214 ),
215 child: Center(
216 child: Text(
217 firstLetter.toUpperCase(),
218 style: const TextStyle(
219 color: AppColors.textPrimary,
220 fontSize: 12,
221 fontWeight: FontWeight.bold,
222 ),
223 ),
224 ),
225 );
226 }
227
228 /// Builds the comment content with support for facets
229 Widget _buildCommentContent(CommentView comment) {
230 // TODO: Add facet support for links and mentions like PostCard does
231 // For now, just render plain text
232 return Text(
233 comment.content,
234 style: const TextStyle(
235 color: AppColors.textPrimary,
236 fontSize: 14,
237 height: 1.4,
238 ),
239 );
240 }
241
242 /// Builds the action buttons row (vote button)
243 Widget _buildActionButtons(BuildContext context) {
244 return Consumer<VoteProvider>(
245 builder: (context, voteProvider, child) {
246 // Get optimistic vote state from provider
247 final isLiked = voteProvider.isLiked(comment.uri);
248 final adjustedScore = voteProvider.getAdjustedScore(
249 comment.uri,
250 comment.stats.score,
251 );
252
253 return Row(
254 mainAxisAlignment: MainAxisAlignment.end,
255 children: [
256 // Heart vote button
257 Semantics(
258 button: true,
259 label:
260 isLiked
261 ? 'Unlike comment, $adjustedScore '
262 '${adjustedScore == 1 ? "like" : "likes"}'
263 : 'Like comment, $adjustedScore '
264 '${adjustedScore == 1 ? "like" : "likes"}',
265 child: InkWell(
266 onTap: () async {
267 // Check authentication
268 final authProvider = context.read<AuthProvider>();
269 if (!authProvider.isAuthenticated) {
270 // Show sign-in dialog
271 final shouldSignIn = await SignInDialog.show(
272 context,
273 message: 'You need to sign in to vote on comments.',
274 );
275
276 if ((shouldSignIn ?? false) && context.mounted) {
277 // TODO: Navigate to sign-in screen
278 if (kDebugMode) {
279 debugPrint('Navigate to sign-in screen');
280 }
281 }
282 return;
283 }
284
285 // Light haptic feedback
286 await HapticFeedback.lightImpact();
287
288 // Toggle vote with optimistic update via VoteProvider
289 try {
290 await voteProvider.toggleVote(
291 postUri: comment.uri,
292 postCid: comment.cid,
293 );
294 } on Exception catch (e) {
295 if (kDebugMode) {
296 debugPrint('Failed to vote on comment: $e');
297 }
298 // TODO: Show error snackbar
299 }
300 },
301 child: Padding(
302 padding: const EdgeInsets.symmetric(
303 horizontal: 8,
304 vertical: 6,
305 ),
306 child: Row(
307 mainAxisSize: MainAxisSize.min,
308 children: [
309 AnimatedHeartIcon(
310 isLiked: isLiked,
311 size: 16,
312 color: AppColors.textPrimary.withValues(alpha: 0.6),
313 likedColor: const Color(0xFFFF0033),
314 ),
315 const SizedBox(width: 5),
316 Text(
317 DateTimeUtils.formatCount(adjustedScore),
318 style: TextStyle(
319 color: AppColors.textPrimary.withValues(alpha: 0.6),
320 fontSize: 12,
321 ),
322 ),
323 ],
324 ),
325 ),
326 ),
327 ),
328 ],
329 );
330 },
331 );
332 }
333}
334
335/// Custom painter for drawing comment depth indicator lines
336class _CommentDepthPainter extends CustomPainter {
337 _CommentDepthPainter({required this.depth});
338 final int depth;
339
340 // Color palette for threading indicators (cycles through 6 colors)
341 static final List<Color> _threadingColors = [
342 const Color(0xFFFF6B6B), // Red
343 const Color(0xFF4ECDC4), // Teal
344 const Color(0xFFFFE66D), // Yellow
345 const Color(0xFF95E1D3), // Mint
346 const Color(0xFFC7CEEA), // Purple
347 const Color(0xFFFFAA5C), // Orange
348 ];
349
350 @override
351 void paint(Canvas canvas, Size size) {
352 final paint =
353 Paint()
354 ..strokeWidth = 2.0
355 ..style = PaintingStyle.stroke;
356
357 // Draw vertical line for each depth level with different colors
358 for (var i = 0; i < depth; i++) {
359 // Cycle through colors based on depth level
360 paint.color = _threadingColors[i % _threadingColors.length].withValues(
361 alpha: 0.5,
362 );
363
364 final xPosition = (i + 1) * 6.0;
365 canvas.drawLine(
366 Offset(xPosition, 0),
367 Offset(xPosition, size.height),
368 paint,
369 );
370 }
371 }
372
373 @override
374 bool shouldRepaint(_CommentDepthPainter oldDelegate) {
375 return oldDelegate.depth != depth;
376 }
377}