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