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