feat(comments): add tap-to-reply UI for nested comments

- Pass postCid to loadComments for reply reference support
- Add onReplyTap callback to CommentThread and CommentCard
- Tapping reply icon on a comment navigates to ReplyScreen
- ReplyScreen receives parent comment for nested replies
- Show "Replying to @handle" context in reply screen

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

Changed files
+174 -73
lib
+93 -7
lib/screens/home/post_detail_screen.dart
··· 74 74 void _loadComments() { 75 75 context.read<CommentsProvider>().loadComments( 76 76 postUri: widget.post.post.uri, 77 + postCid: widget.post.post.cid, 77 78 refresh: true, 78 79 ); 79 80 } ··· 368 369 ); 369 370 } 370 371 371 - /// Handle comment submission 372 + /// Handle comment submission (reply to post) 372 373 Future<void> _handleCommentSubmit(String content) async { 373 - // TODO: Implement comment creation via atProto 374 - ScaffoldMessenger.of(context).showSnackBar( 375 - SnackBar( 376 - content: Text('Comment submitted: $content'), 377 - behavior: SnackBarBehavior.floating, 378 - duration: const Duration(seconds: 2), 374 + final commentsProvider = context.read<CommentsProvider>(); 375 + final messenger = ScaffoldMessenger.of(context); 376 + 377 + try { 378 + await commentsProvider.createComment(content: content); 379 + 380 + if (mounted) { 381 + messenger.showSnackBar( 382 + const SnackBar( 383 + content: Text('Comment posted'), 384 + behavior: SnackBarBehavior.floating, 385 + duration: Duration(seconds: 2), 386 + ), 387 + ); 388 + } 389 + } on Exception catch (e) { 390 + if (mounted) { 391 + messenger.showSnackBar( 392 + SnackBar( 393 + content: Text('Failed to post comment: $e'), 394 + behavior: SnackBarBehavior.floating, 395 + backgroundColor: AppColors.primary, 396 + ), 397 + ); 398 + } 399 + rethrow; // Let ReplyScreen know submission failed 400 + } 401 + } 402 + 403 + /// Handle reply to a comment (nested reply) 404 + Future<void> _handleCommentReply( 405 + String content, 406 + ThreadViewComment parentComment, 407 + ) async { 408 + final commentsProvider = context.read<CommentsProvider>(); 409 + final messenger = ScaffoldMessenger.of(context); 410 + 411 + try { 412 + await commentsProvider.createComment( 413 + content: content, 414 + parentComment: parentComment, 415 + ); 416 + 417 + if (mounted) { 418 + messenger.showSnackBar( 419 + const SnackBar( 420 + content: Text('Reply posted'), 421 + behavior: SnackBarBehavior.floating, 422 + duration: Duration(seconds: 2), 423 + ), 424 + ); 425 + } 426 + } on Exception catch (e) { 427 + if (mounted) { 428 + messenger.showSnackBar( 429 + SnackBar( 430 + content: Text('Failed to post reply: $e'), 431 + behavior: SnackBarBehavior.floating, 432 + backgroundColor: AppColors.primary, 433 + ), 434 + ); 435 + } 436 + rethrow; // Let ReplyScreen know submission failed 437 + } 438 + } 439 + 440 + /// Open reply screen for replying to a comment 441 + void _openReplyToComment(ThreadViewComment comment) { 442 + // Check authentication 443 + final authProvider = context.read<AuthProvider>(); 444 + if (!authProvider.isAuthenticated) { 445 + ScaffoldMessenger.of(context).showSnackBar( 446 + const SnackBar( 447 + content: Text('Sign in to reply'), 448 + behavior: SnackBarBehavior.floating, 449 + ), 450 + ); 451 + return; 452 + } 453 + 454 + // Navigate to reply screen with comment context 455 + Navigator.of(context).push( 456 + MaterialPageRoute<void>( 457 + builder: (context) => ReplyScreen( 458 + comment: comment, 459 + onSubmit: (content) => _handleCommentReply(content, comment), 460 + ), 379 461 ), 380 462 ); 381 463 } ··· 491 573 comment: comment, 492 574 currentTimeNotifier: 493 575 commentsProvider.currentTimeNotifier, 576 + onCommentTap: _openReplyToComment, 494 577 ); 495 578 }, 496 579 childCount: ··· 552 635 const _CommentItem({ 553 636 required this.comment, 554 637 required this.currentTimeNotifier, 638 + this.onCommentTap, 555 639 }); 556 640 557 641 final ThreadViewComment comment; 558 642 final ValueNotifier<DateTime?> currentTimeNotifier; 643 + final void Function(ThreadViewComment)? onCommentTap; 559 644 560 645 @override 561 646 Widget build(BuildContext context) { ··· 566 651 thread: comment, 567 652 currentTime: currentTime, 568 653 maxDepth: 6, 654 + onCommentTap: onCommentTap, 569 655 ); 570 656 }, 571 657 );
+73 -65
lib/widgets/comment_card.dart
··· 20 20 /// - Comment content (supports facets for links/mentions) 21 21 /// - Heart vote button with optimistic updates via VoteProvider 22 22 /// - Visual threading indicator based on nesting depth 23 + /// - Tap-to-reply functionality via [onTap] callback 23 24 /// 24 25 /// The [currentTime] parameter allows passing the current time for 25 26 /// time-ago calculations, enabling periodic updates and testing. ··· 28 29 required this.comment, 29 30 this.depth = 0, 30 31 this.currentTime, 32 + this.onTap, 31 33 super.key, 32 34 }); 33 35 34 36 final CommentView comment; 35 37 final int depth; 36 38 final DateTime? currentTime; 39 + 40 + /// Callback when the comment is tapped (for reply functionality) 41 + final VoidCallback? onTap; 37 42 38 43 @override 39 44 Widget build(BuildContext context) { ··· 45 50 // the stroke width) 46 51 final borderLeftOffset = (threadingLineCount * 6.0) + 2.0; 47 52 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), 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), 56 71 ), 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, 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, 87 97 ), 88 - fontSize: 13, 89 - fontWeight: FontWeight.w500, 90 98 ), 91 - ), 92 - ], 99 + ], 100 + ), 93 101 ), 94 - ), 95 - // Time ago 96 - Text( 97 - DateTimeUtils.formatTimeAgo( 98 - comment.createdAt, 99 - currentTime: currentTime, 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 + ), 100 112 ), 101 - style: TextStyle( 102 - color: AppColors.textPrimary.withValues(alpha: 0.5), 103 - fontSize: 12, 104 - ), 105 - ), 113 + ], 114 + ), 115 + const SizedBox(height: 8), 116 + 117 + // Comment content 118 + if (comment.content.isNotEmpty) ...[ 119 + _buildCommentContent(comment), 120 + const SizedBox(height: 8), 106 121 ], 107 - ), 108 - const SizedBox(height: 8), 109 122 110 - // Comment content 111 - if (comment.content.isNotEmpty) ...[ 112 - _buildCommentContent(comment), 113 - const SizedBox(height: 8), 123 + // Action buttons (just vote for now) 124 + _buildActionButtons(context), 114 125 ], 115 - 116 - // Action buttons (just vote for now) 117 - _buildActionButtons(context), 118 - ], 126 + ), 119 127 ), 120 - ), 121 - ], 128 + ], 129 + ), 122 130 ), 123 131 ); 124 132 }
+8 -1
lib/widgets/comment_thread.dart
··· 13 13 /// - Indents nested replies visually 14 14 /// - Limits nesting depth to prevent excessive indentation 15 15 /// - Shows "Load more replies" button when hasMore is true 16 + /// - Supports tap-to-reply via [onCommentTap] callback 16 17 /// 17 18 /// The [maxDepth] parameter controls how deeply nested comments can be 18 19 /// before they're rendered at the same level to prevent UI overflow. ··· 23 24 this.maxDepth = 5, 24 25 this.currentTime, 25 26 this.onLoadMoreReplies, 27 + this.onCommentTap, 26 28 super.key, 27 29 }); 28 30 ··· 31 33 final int maxDepth; 32 34 final DateTime? currentTime; 33 35 final VoidCallback? onLoadMoreReplies; 36 + 37 + /// Callback when a comment is tapped (for reply functionality) 38 + final void Function(ThreadViewComment)? onCommentTap; 34 39 35 40 @override 36 41 Widget build(BuildContext context) { ··· 40 45 return Column( 41 46 crossAxisAlignment: CrossAxisAlignment.start, 42 47 children: [ 43 - // Render the comment 48 + // Render the comment with tap handler 44 49 CommentCard( 45 50 comment: thread.comment, 46 51 depth: effectiveDepth, 47 52 currentTime: currentTime, 53 + onTap: onCommentTap != null ? () => onCommentTap!(thread) : null, 48 54 ), 49 55 50 56 // Render replies recursively ··· 56 62 maxDepth: maxDepth, 57 63 currentTime: currentTime, 58 64 onLoadMoreReplies: onLoadMoreReplies, 65 + onCommentTap: onCommentTap, 59 66 ), 60 67 ), 61 68