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