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}