Main coves client
1import 'package:flutter/material.dart';
2import 'package:provider/provider.dart';
3
4import '../../constants/app_colors.dart';
5import '../../models/comment.dart';
6import '../../providers/auth_provider.dart';
7import '../../widgets/comment_card.dart';
8import '../../widgets/comment_thread.dart';
9import '../../widgets/status_bar_overlay.dart';
10import '../compose/reply_screen.dart';
11
12/// Focused thread screen for viewing deep comment threads
13///
14/// Displays a specific comment as the "anchor" with its full reply tree.
15/// Used when user taps "Read X more replies" on a deeply nested thread.
16///
17/// Shows:
18/// - Ancestor comments shown flat at the top (walking up the chain)
19/// - The anchor comment (highlighted as the focus)
20/// - All replies threaded below with fresh depth starting at 0
21///
22/// ## Collapsed State
23/// This screen maintains its own collapsed comment state, intentionally
24/// providing a "fresh slate" experience. When the user navigates back,
25/// any collapsed state is reset. This is by design - it allows users to
26/// explore deep threads without their collapse choices persisting across
27/// navigation, keeping the focused view clean and predictable.
28class FocusedThreadScreen extends StatelessWidget {
29 const FocusedThreadScreen({
30 required this.thread,
31 required this.ancestors,
32 required this.onReply,
33 super.key,
34 });
35
36 /// The comment thread to focus on (becomes the new root)
37 final ThreadViewComment thread;
38
39 /// Ancestor comments leading to this thread (for context display)
40 final List<ThreadViewComment> ancestors;
41
42 /// Callback when user replies to a comment
43 final Future<void> Function(String content, ThreadViewComment parent) onReply;
44
45 @override
46 Widget build(BuildContext context) {
47 return Scaffold(
48 backgroundColor: AppColors.background,
49 body: _FocusedThreadBody(
50 thread: thread,
51 ancestors: ancestors,
52 onReply: onReply,
53 ),
54 );
55 }
56}
57
58class _FocusedThreadBody extends StatefulWidget {
59 const _FocusedThreadBody({
60 required this.thread,
61 required this.ancestors,
62 required this.onReply,
63 });
64
65 final ThreadViewComment thread;
66 final List<ThreadViewComment> ancestors;
67 final Future<void> Function(String content, ThreadViewComment parent) onReply;
68
69 @override
70 State<_FocusedThreadBody> createState() => _FocusedThreadBodyState();
71}
72
73class _FocusedThreadBodyState extends State<_FocusedThreadBody> {
74 final Set<String> _collapsedComments = {};
75 final ScrollController _scrollController = ScrollController();
76 final GlobalKey _anchorKey = GlobalKey();
77
78 @override
79 void initState() {
80 super.initState();
81 // Scroll to anchor comment after build
82 WidgetsBinding.instance.addPostFrameCallback((_) {
83 _scrollToAnchor();
84 });
85 }
86
87 @override
88 void dispose() {
89 _scrollController.dispose();
90 super.dispose();
91 }
92
93 void _scrollToAnchor() {
94 final context = _anchorKey.currentContext;
95 if (context != null) {
96 Scrollable.ensureVisible(
97 context,
98 duration: const Duration(milliseconds: 300),
99 curve: Curves.easeOut,
100 );
101 }
102 }
103
104 void _toggleCollapsed(String uri) {
105 setState(() {
106 if (_collapsedComments.contains(uri)) {
107 _collapsedComments.remove(uri);
108 } else {
109 _collapsedComments.add(uri);
110 }
111 });
112 }
113
114 void _openReplyScreen(ThreadViewComment comment) {
115 // Check authentication
116 final authProvider = context.read<AuthProvider>();
117 if (!authProvider.isAuthenticated) {
118 ScaffoldMessenger.of(context).showSnackBar(
119 const SnackBar(
120 content: Text('Sign in to reply'),
121 behavior: SnackBarBehavior.floating,
122 ),
123 );
124 return;
125 }
126
127 Navigator.of(context).push(
128 MaterialPageRoute<void>(
129 builder: (context) => ReplyScreen(
130 comment: comment,
131 onSubmit: (content) => widget.onReply(content, comment),
132 ),
133 ),
134 );
135 }
136
137 /// Navigate deeper into a nested thread
138 void _onContinueThread(
139 ThreadViewComment thread,
140 List<ThreadViewComment> ancestors,
141 ) {
142 Navigator.of(context).push(
143 MaterialPageRoute<void>(
144 builder: (context) => FocusedThreadScreen(
145 thread: thread,
146 ancestors: ancestors,
147 onReply: widget.onReply,
148 ),
149 ),
150 );
151 }
152
153 @override
154 Widget build(BuildContext context) {
155 // Calculate minimum bottom padding to allow anchor to scroll to top
156 final screenHeight = MediaQuery.of(context).size.height;
157 final minBottomPadding = screenHeight * 0.6;
158
159 return Stack(
160 children: [
161 CustomScrollView(
162 controller: _scrollController,
163 slivers: [
164 // App bar
165 const SliverAppBar(
166 backgroundColor: AppColors.background,
167 surfaceTintColor: Colors.transparent,
168 foregroundColor: AppColors.textPrimary,
169 title: Text(
170 'Thread',
171 style: TextStyle(
172 fontSize: 18,
173 fontWeight: FontWeight.w600,
174 ),
175 ),
176 centerTitle: false,
177 elevation: 0,
178 floating: true,
179 snap: true,
180 ),
181
182 // Content
183 SliverSafeArea(
184 top: false,
185 sliver: SliverList(
186 delegate: SliverChildListDelegate([
187 // Ancestor comments (shown flat, not nested)
188 ...widget.ancestors.map(_buildAncestorComment),
189
190 // Anchor comment (the focused comment) - made prominent
191 KeyedSubtree(
192 key: _anchorKey,
193 child: _buildAnchorComment(),
194 ),
195
196 // Replies (if any)
197 if (widget.thread.replies != null &&
198 widget.thread.replies!.isNotEmpty)
199 ...widget.thread.replies!.map((reply) {
200 return CommentThread(
201 thread: reply,
202 depth: 1,
203 maxDepth: 6,
204 onCommentTap: _openReplyScreen,
205 collapsedComments: _collapsedComments,
206 onCollapseToggle: _toggleCollapsed,
207 onContinueThread: _onContinueThread,
208 ancestors: [widget.thread],
209 );
210 }),
211
212 // Empty state if no replies
213 if (widget.thread.replies == null ||
214 widget.thread.replies!.isEmpty)
215 _buildNoReplies(),
216
217 // Bottom padding to allow anchor to scroll to top
218 SizedBox(height: minBottomPadding),
219 ]),
220 ),
221 ),
222 ],
223 ),
224
225 // Prevents content showing through transparent status bar
226 const StatusBarOverlay(),
227 ],
228 );
229 }
230
231 /// Build an ancestor comment (shown flat as context above anchor)
232 /// Styled more subtly than the anchor to show it's contextual
233 Widget _buildAncestorComment(ThreadViewComment ancestor) {
234 return Opacity(
235 opacity: 0.6,
236 child: CommentCard(
237 comment: ancestor.comment,
238 onTap: () => _openReplyScreen(ancestor),
239 ),
240 );
241 }
242
243 /// Build the anchor comment (the focused comment) with prominent styling
244 Widget _buildAnchorComment() {
245 // Note: CommentCard has its own Consumer<VoteProvider> for vote state
246 return Container(
247 decoration: BoxDecoration(
248 // Subtle highlight to distinguish anchor from ancestors
249 color: AppColors.primary.withValues(alpha: 0.05),
250 border: Border(
251 left: BorderSide(
252 color: AppColors.primary.withValues(alpha: 0.6),
253 width: 3,
254 ),
255 ),
256 ),
257 child: CommentCard(
258 comment: widget.thread.comment,
259 onTap: () => _openReplyScreen(widget.thread),
260 onLongPress: () => _toggleCollapsed(widget.thread.comment.uri),
261 isCollapsed: _collapsedComments.contains(widget.thread.comment.uri),
262 collapsedCount: _collapsedComments.contains(widget.thread.comment.uri)
263 ? CommentThread.countDescendants(widget.thread)
264 : 0,
265 ),
266 );
267 }
268
269 /// Build empty state when there are no replies
270 Widget _buildNoReplies() {
271 return Container(
272 padding: const EdgeInsets.all(32),
273 alignment: Alignment.center,
274 child: Column(
275 children: [
276 Icon(
277 Icons.chat_bubble_outline_rounded,
278 size: 48,
279 color: AppColors.textSecondary.withValues(alpha: 0.5),
280 ),
281 const SizedBox(height: 16),
282 Text(
283 'No replies yet',
284 style: TextStyle(
285 color: AppColors.textSecondary.withValues(alpha: 0.7),
286 fontSize: 15,
287 ),
288 ),
289 const SizedBox(height: 8),
290 Text(
291 'Be the first to reply to this comment',
292 style: TextStyle(
293 color: AppColors.textSecondary.withValues(alpha: 0.5),
294 fontSize: 13,
295 ),
296 ),
297 ],
298 ),
299 );
300 }
301}