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 _CommentDepthPainter({required this.depth}); 280 final int depth; 281 282 // Color palette for threading indicators (cycles through 6 colors) 283 static final List<Color> _threadingColors = [ 284 const Color(0xFFFF6B6B), // Red 285 const Color(0xFF4ECDC4), // Teal 286 const Color(0xFFFFE66D), // Yellow 287 const Color(0xFF95E1D3), // Mint 288 const Color(0xFFC7CEEA), // Purple 289 const Color(0xFFFFAA5C), // Orange 290 ]; 291 292 @override 293 void paint(Canvas canvas, Size size) { 294 final paint = 295 Paint() 296 ..strokeWidth = 2.0 297 ..style = PaintingStyle.stroke; 298 299 // Draw vertical line for each depth level with different colors 300 for (var i = 0; i < depth; i++) { 301 // Cycle through colors based on depth level 302 paint.color = _threadingColors[i % _threadingColors.length].withValues( 303 alpha: 0.5, 304 ); 305 306 final xPosition = (i + 1) * 6.0; 307 canvas.drawLine( 308 Offset(xPosition, 0), 309 Offset(xPosition, size.height), 310 paint, 311 ); 312 } 313 } 314 315 @override 316 bool shouldRepaint(_CommentDepthPainter oldDelegate) { 317 return oldDelegate.depth != depth; 318 } 319}