Main coves client
1import 'package:flutter/foundation.dart';
2import 'package:flutter/material.dart';
3
4import '../constants/app_colors.dart';
5import '../models/comment.dart';
6import 'comment_card.dart';
7
8/// Comment thread widget for displaying comments and their nested replies
9///
10/// Recursively displays a ThreadViewComment and its replies:
11/// - Renders the comment using CommentCard with optimistic voting
12/// via VoteProvider
13/// - Indents nested replies visually
14/// - Limits nesting depth to prevent excessive indentation
15/// - Shows "Load more replies" button when hasMore is true
16/// - Supports tap-to-reply via [onCommentTap] callback
17/// - Supports long-press to collapse threads via [onCollapseToggle] callback
18///
19/// The [maxDepth] parameter controls how deeply nested comments can be
20/// before they're rendered at the same level to prevent UI overflow.
21///
22/// When a comment is collapsed (via [collapsedComments]), its replies are
23/// hidden with a smooth animation and a badge shows the hidden count.
24class CommentThread extends StatelessWidget {
25 const CommentThread({
26 required this.thread,
27 this.depth = 0,
28 this.maxDepth = 5,
29 this.currentTime,
30 this.onLoadMoreReplies,
31 this.onCommentTap,
32 this.collapsedComments = const {},
33 this.onCollapseToggle,
34 super.key,
35 });
36
37 final ThreadViewComment thread;
38 final int depth;
39 final int maxDepth;
40 final DateTime? currentTime;
41 final VoidCallback? onLoadMoreReplies;
42
43 /// Callback when a comment is tapped (for reply functionality)
44 final void Function(ThreadViewComment)? onCommentTap;
45
46 /// Set of collapsed comment URIs
47 final Set<String> collapsedComments;
48
49 /// Callback when a comment collapse state is toggled
50 final void Function(String uri)? onCollapseToggle;
51
52 /// Count all descendants recursively
53 static int countDescendants(ThreadViewComment thread) {
54 if (thread.replies == null || thread.replies!.isEmpty) {
55 return 0;
56 }
57 var count = thread.replies!.length;
58 for (final reply in thread.replies!) {
59 count += countDescendants(reply);
60 }
61 return count;
62 }
63
64 @override
65 Widget build(BuildContext context) {
66 // Calculate effective depth (flatten after maxDepth)
67 final effectiveDepth = depth > maxDepth ? maxDepth : depth;
68
69 // Check if this comment is collapsed
70 final isCollapsed = collapsedComments.contains(thread.comment.uri);
71 final collapsedCount = isCollapsed ? countDescendants(thread) : 0;
72
73 // Check if there are replies to render
74 final hasReplies = thread.replies != null && thread.replies!.isNotEmpty;
75
76 // Only build replies widget when NOT collapsed (optimization)
77 // When collapsed, AnimatedSwitcher shows SizedBox.shrink() so children
78 // are never mounted - no need to build them at all
79 final repliesWidget = hasReplies && !isCollapsed
80 ? Column(
81 key: const ValueKey('replies'),
82 crossAxisAlignment: CrossAxisAlignment.start,
83 children: thread.replies!.map((reply) {
84 return CommentThread(
85 thread: reply,
86 depth: depth + 1,
87 maxDepth: maxDepth,
88 currentTime: currentTime,
89 onLoadMoreReplies: onLoadMoreReplies,
90 onCommentTap: onCommentTap,
91 collapsedComments: collapsedComments,
92 onCollapseToggle: onCollapseToggle,
93 );
94 }).toList(),
95 )
96 : null;
97
98 return Column(
99 crossAxisAlignment: CrossAxisAlignment.start,
100 children: [
101 // Render the comment with tap and long-press handlers
102 CommentCard(
103 comment: thread.comment,
104 depth: effectiveDepth,
105 currentTime: currentTime,
106 onTap: onCommentTap != null ? () => onCommentTap!(thread) : null,
107 onLongPress: onCollapseToggle != null
108 ? () => onCollapseToggle!(thread.comment.uri)
109 : null,
110 isCollapsed: isCollapsed,
111 collapsedCount: collapsedCount,
112 ),
113
114 // Render replies with animation
115 if (hasReplies)
116 AnimatedSwitcher(
117 duration: const Duration(milliseconds: 200),
118 switchInCurve: Curves.easeInOutCubicEmphasized,
119 switchOutCurve: Curves.easeInOutCubicEmphasized,
120 transitionBuilder: (Widget child, Animation<double> animation) {
121 return SizeTransition(
122 sizeFactor: animation,
123 axisAlignment: -1,
124 child: child,
125 );
126 },
127 child: isCollapsed
128 ? const SizedBox.shrink(key: ValueKey('collapsed'))
129 : repliesWidget,
130 ),
131
132 // Show "Load more replies" button if there are more (and not collapsed)
133 if (thread.hasMore && !isCollapsed) _buildLoadMoreButton(context),
134 ],
135 );
136 }
137
138 /// Builds the "Load more replies" button
139 Widget _buildLoadMoreButton(BuildContext context) {
140 // Calculate left padding based on depth (align with replies)
141 final effectiveDepth = depth > maxDepth ? maxDepth : depth;
142 final leftPadding = 16.0 + ((effectiveDepth + 1) * 12.0);
143
144 return Container(
145 padding: EdgeInsets.fromLTRB(leftPadding, 8, 16, 8),
146 decoration: const BoxDecoration(
147 border: Border(bottom: BorderSide(color: AppColors.border)),
148 ),
149 child: InkWell(
150 onTap: () {
151 if (onLoadMoreReplies != null) {
152 onLoadMoreReplies!();
153 } else {
154 if (kDebugMode) {
155 debugPrint('Load more replies tapped (no handler provided)');
156 }
157 }
158 },
159 child: Padding(
160 padding: const EdgeInsets.symmetric(vertical: 4),
161 child: Row(
162 children: [
163 Icon(
164 Icons.add_circle_outline,
165 size: 16,
166 color: AppColors.primary.withValues(alpha: 0.8),
167 ),
168 const SizedBox(width: 6),
169 Text(
170 'Load more replies',
171 style: TextStyle(
172 color: AppColors.primary.withValues(alpha: 0.8),
173 fontSize: 13,
174 fontWeight: FontWeight.w500,
175 ),
176 ),
177 ],
178 ),
179 ),
180 ),
181 );
182 }
183}