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