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}