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