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