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
···
void _loadComments() {
context.read<CommentsProvider>().loadComments(
postUri: widget.post.post.uri,
+
postCid: widget.post.post.cid,
refresh: true,
);
}
···
);
}
-
/// Handle comment submission
+
/// Handle comment submission (reply to post)
Future<void> _handleCommentSubmit(String content) async {
-
// TODO: Implement comment creation via atProto
-
ScaffoldMessenger.of(context).showSnackBar(
-
SnackBar(
-
content: Text('Comment submitted: $content'),
-
behavior: SnackBarBehavior.floating,
-
duration: const Duration(seconds: 2),
+
final commentsProvider = context.read<CommentsProvider>();
+
final messenger = ScaffoldMessenger.of(context);
+
+
try {
+
await commentsProvider.createComment(content: content);
+
+
if (mounted) {
+
messenger.showSnackBar(
+
const SnackBar(
+
content: Text('Comment posted'),
+
behavior: SnackBarBehavior.floating,
+
duration: Duration(seconds: 2),
+
),
+
);
+
}
+
} on Exception catch (e) {
+
if (mounted) {
+
messenger.showSnackBar(
+
SnackBar(
+
content: Text('Failed to post comment: $e'),
+
behavior: SnackBarBehavior.floating,
+
backgroundColor: AppColors.primary,
+
),
+
);
+
}
+
rethrow; // Let ReplyScreen know submission failed
+
}
+
}
+
+
/// Handle reply to a comment (nested reply)
+
Future<void> _handleCommentReply(
+
String content,
+
ThreadViewComment parentComment,
+
) async {
+
final commentsProvider = context.read<CommentsProvider>();
+
final messenger = ScaffoldMessenger.of(context);
+
+
try {
+
await commentsProvider.createComment(
+
content: content,
+
parentComment: parentComment,
+
);
+
+
if (mounted) {
+
messenger.showSnackBar(
+
const SnackBar(
+
content: Text('Reply posted'),
+
behavior: SnackBarBehavior.floating,
+
duration: Duration(seconds: 2),
+
),
+
);
+
}
+
} on Exception catch (e) {
+
if (mounted) {
+
messenger.showSnackBar(
+
SnackBar(
+
content: Text('Failed to post reply: $e'),
+
behavior: SnackBarBehavior.floating,
+
backgroundColor: AppColors.primary,
+
),
+
);
+
}
+
rethrow; // Let ReplyScreen know submission failed
+
}
+
}
+
+
/// Open reply screen for replying to a comment
+
void _openReplyToComment(ThreadViewComment comment) {
+
// Check authentication
+
final authProvider = context.read<AuthProvider>();
+
if (!authProvider.isAuthenticated) {
+
ScaffoldMessenger.of(context).showSnackBar(
+
const SnackBar(
+
content: Text('Sign in to reply'),
+
behavior: SnackBarBehavior.floating,
+
),
+
);
+
return;
+
}
+
+
// Navigate to reply screen with comment context
+
Navigator.of(context).push(
+
MaterialPageRoute<void>(
+
builder: (context) => ReplyScreen(
+
comment: comment,
+
onSubmit: (content) => _handleCommentReply(content, comment),
+
),
),
);
}
···
comment: comment,
currentTimeNotifier:
commentsProvider.currentTimeNotifier,
+
onCommentTap: _openReplyToComment,
);
},
childCount:
···
const _CommentItem({
required this.comment,
required this.currentTimeNotifier,
+
this.onCommentTap,
});
final ThreadViewComment comment;
final ValueNotifier<DateTime?> currentTimeNotifier;
+
final void Function(ThreadViewComment)? onCommentTap;
@override
Widget build(BuildContext context) {
···
thread: comment,
currentTime: currentTime,
maxDepth: 6,
+
onCommentTap: onCommentTap,
);
},
);
+73 -65
lib/widgets/comment_card.dart
···
/// - Comment content (supports facets for links/mentions)
/// - Heart vote button with optimistic updates via VoteProvider
/// - Visual threading indicator based on nesting depth
+
/// - Tap-to-reply functionality via [onTap] callback
///
/// The [currentTime] parameter allows passing the current time for
/// time-ago calculations, enabling periodic updates and testing.
···
required this.comment,
this.depth = 0,
this.currentTime,
+
this.onTap,
super.key,
});
final CommentView comment;
final int depth;
final DateTime? currentTime;
+
+
/// Callback when the comment is tapped (for reply functionality)
+
final VoidCallback? onTap;
@override
Widget build(BuildContext context) {
···
// the stroke width)
final borderLeftOffset = (threadingLineCount * 6.0) + 2.0;
-
return Container(
-
decoration: const BoxDecoration(color: AppColors.background),
-
child: Stack(
-
children: [
-
// Threading indicators - vertical lines showing nesting ancestry
-
Positioned.fill(
-
child: CustomPaint(
-
painter: _CommentDepthPainter(depth: threadingLineCount),
+
return InkWell(
+
onTap: onTap,
+
child: Container(
+
decoration: const BoxDecoration(color: AppColors.background),
+
child: Stack(
+
children: [
+
// Threading indicators - vertical lines showing nesting ancestry
+
Positioned.fill(
+
child: CustomPaint(
+
painter: _CommentDepthPainter(depth: threadingLineCount),
+
),
+
),
+
// Bottom border (starts after threading lines, not overlapping them)
+
Positioned(
+
left: borderLeftOffset,
+
right: 0,
+
bottom: 0,
+
child: Container(height: 1, color: AppColors.border),
),
-
),
-
// Bottom border (starts after threading lines, not overlapping them)
-
Positioned(
-
left: borderLeftOffset,
-
right: 0,
-
bottom: 0,
-
child: Container(height: 1, color: AppColors.border),
-
),
-
// Comment content with depth-based left padding
-
Padding(
-
padding: EdgeInsets.fromLTRB(leftPadding, 12, 16, 8),
-
child: Column(
-
crossAxisAlignment: CrossAxisAlignment.start,
-
children: [
-
// Author info row
-
Row(
-
children: [
-
// Author avatar
-
_buildAuthorAvatar(comment.author),
-
const SizedBox(width: 8),
-
Expanded(
-
child: Column(
-
crossAxisAlignment: CrossAxisAlignment.start,
-
children: [
-
// Author handle
-
Text(
-
'@${comment.author.handle}',
-
style: TextStyle(
-
color: AppColors.textPrimary.withValues(
-
alpha: 0.5,
+
// Comment content with depth-based left padding
+
Padding(
+
padding: EdgeInsets.fromLTRB(leftPadding, 12, 16, 8),
+
child: Column(
+
crossAxisAlignment: CrossAxisAlignment.start,
+
children: [
+
// Author info row
+
Row(
+
children: [
+
// Author avatar
+
_buildAuthorAvatar(comment.author),
+
const SizedBox(width: 8),
+
Expanded(
+
child: Column(
+
crossAxisAlignment: CrossAxisAlignment.start,
+
children: [
+
// Author handle
+
Text(
+
'@${comment.author.handle}',
+
style: TextStyle(
+
color: AppColors.textPrimary.withValues(
+
alpha: 0.5,
+
),
+
fontSize: 13,
+
fontWeight: FontWeight.w500,
),
-
fontSize: 13,
-
fontWeight: FontWeight.w500,
),
-
),
-
],
+
],
+
),
),
-
),
-
// Time ago
-
Text(
-
DateTimeUtils.formatTimeAgo(
-
comment.createdAt,
-
currentTime: currentTime,
+
// Time ago
+
Text(
+
DateTimeUtils.formatTimeAgo(
+
comment.createdAt,
+
currentTime: currentTime,
+
),
+
style: TextStyle(
+
color: AppColors.textPrimary.withValues(alpha: 0.5),
+
fontSize: 12,
+
),
),
-
style: TextStyle(
-
color: AppColors.textPrimary.withValues(alpha: 0.5),
-
fontSize: 12,
-
),
-
),
+
],
+
),
+
const SizedBox(height: 8),
+
+
// Comment content
+
if (comment.content.isNotEmpty) ...[
+
_buildCommentContent(comment),
+
const SizedBox(height: 8),
],
-
),
-
const SizedBox(height: 8),
-
// Comment content
-
if (comment.content.isNotEmpty) ...[
-
_buildCommentContent(comment),
-
const SizedBox(height: 8),
+
// Action buttons (just vote for now)
+
_buildActionButtons(context),
],
-
-
// Action buttons (just vote for now)
-
_buildActionButtons(context),
-
],
+
),
),
-
),
-
],
+
],
+
),
),
);
}
+8 -1
lib/widgets/comment_thread.dart
···
/// - Indents nested replies visually
/// - Limits nesting depth to prevent excessive indentation
/// - Shows "Load more replies" button when hasMore is true
+
/// - Supports tap-to-reply via [onCommentTap] callback
///
/// The [maxDepth] parameter controls how deeply nested comments can be
/// before they're rendered at the same level to prevent UI overflow.
···
this.maxDepth = 5,
this.currentTime,
this.onLoadMoreReplies,
+
this.onCommentTap,
super.key,
});
···
final int maxDepth;
final DateTime? currentTime;
final VoidCallback? onLoadMoreReplies;
+
+
/// Callback when a comment is tapped (for reply functionality)
+
final void Function(ThreadViewComment)? onCommentTap;
@override
Widget build(BuildContext context) {
···
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
-
// Render the comment
+
// Render the comment with tap handler
CommentCard(
comment: thread.comment,
depth: effectiveDepth,
currentTime: currentTime,
+
onTap: onCommentTap != null ? () => onCommentTap!(thread) : null,
),
// Render replies recursively
···
maxDepth: maxDepth,
currentTime: currentTime,
onLoadMoreReplies: onLoadMoreReplies,
+
onCommentTap: onCommentTap,
),
),