+93
-7
lib/screens/home/post_detail_screen.dart
+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
+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
+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