Main coves client
1import 'package:cached_network_image/cached_network_image.dart';
2import 'package:flutter/material.dart';
3import 'package:flutter/services.dart';
4import 'package:provider/provider.dart';
5import 'package:share_plus/share_plus.dart';
6
7import '../../constants/app_colors.dart';
8import '../../models/comment.dart';
9import '../../models/post.dart';
10import '../../providers/auth_provider.dart';
11import '../../providers/comments_provider.dart';
12import '../../providers/vote_provider.dart';
13import '../../utils/community_handle_utils.dart';
14import '../../utils/error_messages.dart';
15import '../../widgets/comment_thread.dart';
16import '../../widgets/comments_header.dart';
17import '../../widgets/icons/share_icon.dart';
18import '../../widgets/loading_error_states.dart';
19import '../../widgets/post_action_bar.dart';
20import '../../widgets/post_card.dart';
21import '../compose/reply_screen.dart';
22
23/// Post Detail Screen
24///
25/// Displays a full post with its comments.
26/// Architecture: Standalone screen for route destination and PageView child.
27///
28/// Features:
29/// - Full post display (reuses PostCard widget)
30/// - Sort selector (Hot/Top/New) using dropdown
31/// - Comment list with ListView.builder for performance
32/// - Pull-to-refresh with RefreshIndicator
33/// - Loading, empty, and error states
34/// - Automatic comment loading on screen init
35class PostDetailScreen extends StatefulWidget {
36 const PostDetailScreen({required this.post, super.key});
37
38 /// Post to display (passed via route extras)
39 final FeedViewPost post;
40
41 @override
42 State<PostDetailScreen> createState() => _PostDetailScreenState();
43}
44
45class _PostDetailScreenState extends State<PostDetailScreen> {
46 final ScrollController _scrollController = ScrollController();
47 final GlobalKey _commentsHeaderKey = GlobalKey();
48
49 // Current sort option
50 String _currentSort = 'hot';
51
52 @override
53 void initState() {
54 super.initState();
55
56 // Initialize scroll controller for pagination
57 _scrollController.addListener(_onScroll);
58
59 // Load comments after frame is built using provider from tree
60 WidgetsBinding.instance.addPostFrameCallback((_) {
61 if (mounted) {
62 _loadComments();
63 }
64 });
65 }
66
67 @override
68 void dispose() {
69 _scrollController.dispose();
70 super.dispose();
71 }
72
73 /// Load comments for the current post
74 void _loadComments() {
75 context.read<CommentsProvider>().loadComments(
76 postUri: widget.post.post.uri,
77 refresh: true,
78 );
79 }
80
81 /// Handle sort changes from dropdown
82 Future<void> _onSortChanged(String newSort) async {
83 final previousSort = _currentSort;
84
85 setState(() {
86 _currentSort = newSort;
87 });
88
89 final commentsProvider = context.read<CommentsProvider>();
90 final success = await commentsProvider.setSortOption(newSort);
91
92 // Show error snackbar and revert UI if sort change failed
93 if (!success && mounted) {
94 setState(() {
95 _currentSort = previousSort;
96 });
97
98 ScaffoldMessenger.of(context).showSnackBar(
99 SnackBar(
100 content: const Text('Failed to change sort order. Please try again.'),
101 backgroundColor: AppColors.primary,
102 behavior: SnackBarBehavior.floating,
103 duration: const Duration(seconds: 3),
104 action: SnackBarAction(
105 label: 'Retry',
106 textColor: AppColors.textPrimary,
107 onPressed: () {
108 _onSortChanged(newSort);
109 },
110 ),
111 ),
112 );
113 }
114 }
115
116 /// Handle scroll for pagination
117 void _onScroll() {
118 if (_scrollController.position.pixels >=
119 _scrollController.position.maxScrollExtent - 200) {
120 context.read<CommentsProvider>().loadMoreComments();
121 }
122 }
123
124 /// Handle pull-to-refresh
125 Future<void> _onRefresh() async {
126 final commentsProvider = context.read<CommentsProvider>();
127 await commentsProvider.refreshComments();
128 }
129
130 @override
131 Widget build(BuildContext context) {
132 return Scaffold(
133 backgroundColor: AppColors.background,
134 body: _buildContent(),
135 bottomNavigationBar: _buildActionBar(),
136 );
137 }
138
139 /// Build community title with avatar and handle
140 Widget _buildCommunityTitle() {
141 final community = widget.post.post.community;
142 final displayHandle = CommunityHandleUtils.formatHandleForDisplay(
143 community.handle,
144 );
145
146 return Row(
147 mainAxisSize: MainAxisSize.min,
148 children: [
149 // Community avatar
150 if (community.avatar != null && community.avatar!.isNotEmpty)
151 ClipRRect(
152 borderRadius: BorderRadius.circular(16),
153 child: CachedNetworkImage(
154 imageUrl: community.avatar!,
155 width: 32,
156 height: 32,
157 fit: BoxFit.cover,
158 placeholder: (context, url) => _buildFallbackAvatar(community),
159 errorWidget:
160 (context, url, error) => _buildFallbackAvatar(community),
161 ),
162 )
163 else
164 _buildFallbackAvatar(community),
165 const SizedBox(width: 8),
166 // Community handle with styled parts
167 if (displayHandle != null)
168 Flexible(child: _buildStyledHandle(displayHandle))
169 else
170 Flexible(
171 child: Text(
172 community.name,
173 style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600),
174 overflow: TextOverflow.ellipsis,
175 ),
176 ),
177 ],
178 );
179 }
180
181 /// Build styled community handle with color-coded parts
182 Widget _buildStyledHandle(String displayHandle) {
183 // Format: !gaming@coves.social
184 final atIndex = displayHandle.indexOf('@');
185 final communityPart = displayHandle.substring(0, atIndex);
186 final instancePart = displayHandle.substring(atIndex);
187
188 return Text.rich(
189 TextSpan(
190 children: [
191 TextSpan(
192 text: communityPart,
193 style: const TextStyle(
194 color: AppColors.communityName,
195 fontSize: 16,
196 fontWeight: FontWeight.w600,
197 ),
198 ),
199 TextSpan(
200 text: instancePart,
201 style: TextStyle(
202 color: AppColors.textSecondary.withValues(alpha: 0.8),
203 fontSize: 16,
204 fontWeight: FontWeight.w600,
205 ),
206 ),
207 ],
208 ),
209 overflow: TextOverflow.ellipsis,
210 );
211 }
212
213 /// Build fallback avatar with first letter
214 Widget _buildFallbackAvatar(CommunityRef community) {
215 final firstLetter = community.name.isNotEmpty ? community.name[0] : '?';
216 return Container(
217 width: 32,
218 height: 32,
219 decoration: BoxDecoration(
220 color: AppColors.primary,
221 borderRadius: BorderRadius.circular(16),
222 ),
223 child: Center(
224 child: Text(
225 firstLetter.toUpperCase(),
226 style: const TextStyle(
227 color: AppColors.textPrimary,
228 fontSize: 14,
229 fontWeight: FontWeight.bold,
230 ),
231 ),
232 ),
233 );
234 }
235
236 /// Handle share button tap
237 Future<void> _handleShare() async {
238 // Add haptic feedback
239 await HapticFeedback.lightImpact();
240
241 // TODO: Generate proper deep link URL when deep linking is implemented
242 final postUri = widget.post.post.uri;
243 final title = widget.post.post.title ?? 'Check out this post';
244
245 await Share.share('$title\n\n$postUri', subject: title);
246 }
247
248 /// Build bottom action bar with vote, save, and comment actions
249 Widget _buildActionBar() {
250 return Consumer<VoteProvider>(
251 builder: (context, voteProvider, child) {
252 final isVoted = voteProvider.isLiked(widget.post.post.uri);
253 final adjustedScore = voteProvider.getAdjustedScore(
254 widget.post.post.uri,
255 widget.post.post.stats.score,
256 );
257
258 // Create a modified post with adjusted score for display
259 final displayPost = FeedViewPost(
260 post: PostView(
261 uri: widget.post.post.uri,
262 cid: widget.post.post.cid,
263 rkey: widget.post.post.rkey,
264 author: widget.post.post.author,
265 community: widget.post.post.community,
266 createdAt: widget.post.post.createdAt,
267 indexedAt: widget.post.post.indexedAt,
268 text: widget.post.post.text,
269 title: widget.post.post.title,
270 stats: PostStats(
271 upvotes: widget.post.post.stats.upvotes,
272 downvotes: widget.post.post.stats.downvotes,
273 score: adjustedScore,
274 commentCount: widget.post.post.stats.commentCount,
275 ),
276 embed: widget.post.post.embed,
277 facets: widget.post.post.facets,
278 ),
279 reason: widget.post.reason,
280 );
281
282 return PostActionBar(
283 post: displayPost,
284 isVoted: isVoted,
285 onCommentInputTap: _openCommentComposer,
286 onCommentCountTap: _scrollToComments,
287 onVoteTap: () async {
288 // Check authentication
289 final authProvider = context.read<AuthProvider>();
290 if (!authProvider.isAuthenticated) {
291 ScaffoldMessenger.of(context).showSnackBar(
292 const SnackBar(
293 content: Text('Sign in to vote on posts'),
294 behavior: SnackBarBehavior.floating,
295 ),
296 );
297 return;
298 }
299
300 // Capture messenger before async operations
301 final messenger = ScaffoldMessenger.of(context);
302
303 // Light haptic feedback on both like and unlike
304 await HapticFeedback.lightImpact();
305 try {
306 await voteProvider.toggleVote(
307 postUri: widget.post.post.uri,
308 postCid: widget.post.post.cid,
309 );
310 } on Exception catch (e) {
311 if (mounted) {
312 messenger.showSnackBar(
313 SnackBar(
314 content: Text('Failed to vote: $e'),
315 behavior: SnackBarBehavior.floating,
316 ),
317 );
318 }
319 }
320 },
321 onSaveTap: () {
322 // TODO: Add save functionality
323 ScaffoldMessenger.of(context).showSnackBar(
324 const SnackBar(
325 content: Text('Save feature coming soon!'),
326 behavior: SnackBarBehavior.floating,
327 ),
328 );
329 },
330 );
331 },
332 );
333 }
334
335 /// Scroll to the comments section
336 void _scrollToComments() {
337 final context = _commentsHeaderKey.currentContext;
338 if (context != null) {
339 Scrollable.ensureVisible(
340 context,
341 duration: const Duration(milliseconds: 300),
342 curve: Curves.easeInOut,
343 );
344 }
345 }
346
347 /// Open the reply screen for composing a comment
348 void _openCommentComposer() {
349 // Check authentication
350 final authProvider = context.read<AuthProvider>();
351 if (!authProvider.isAuthenticated) {
352 ScaffoldMessenger.of(context).showSnackBar(
353 const SnackBar(
354 content: Text('Sign in to comment'),
355 behavior: SnackBarBehavior.floating,
356 ),
357 );
358 return;
359 }
360
361 // Navigate to reply screen with full post context
362 Navigator.of(context).push(
363 MaterialPageRoute<void>(
364 builder:
365 (context) =>
366 ReplyScreen(post: widget.post, onSubmit: _handleCommentSubmit),
367 ),
368 );
369 }
370
371 /// Handle comment submission
372 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),
379 ),
380 );
381 }
382
383 /// Build main content area
384 Widget _buildContent() {
385 // Use Consumer to rebuild when comments provider changes
386 return Consumer<CommentsProvider>(
387 builder: (context, commentsProvider, child) {
388 final isLoading = commentsProvider.isLoading;
389 final error = commentsProvider.error;
390 final comments = commentsProvider.comments;
391 final isLoadingMore = commentsProvider.isLoadingMore;
392
393 // Loading state (only show full-screen loader for initial load)
394 if (isLoading && comments.isEmpty) {
395 return const FullScreenLoading();
396 }
397
398 // Error state (only show full-screen error when no comments loaded yet)
399 if (error != null && comments.isEmpty) {
400 return FullScreenError(
401 title: 'Failed to load comments',
402 message: ErrorMessages.getUserFriendly(error),
403 onRetry: commentsProvider.retry,
404 );
405 }
406
407 // Content with RefreshIndicator and floating SliverAppBar
408 return RefreshIndicator(
409 onRefresh: _onRefresh,
410 color: AppColors.primary,
411 child: CustomScrollView(
412 controller: _scrollController,
413 slivers: [
414 // Floating app bar that hides on scroll down, shows on scroll up
415 SliverAppBar(
416 backgroundColor: AppColors.background,
417 surfaceTintColor: Colors.transparent,
418 foregroundColor: AppColors.textPrimary,
419 title: _buildCommunityTitle(),
420 centerTitle: false,
421 elevation: 0,
422 floating: true,
423 snap: true,
424 actions: [
425 IconButton(
426 icon: const ShareIcon(color: AppColors.textPrimary),
427 onPressed: _handleShare,
428 tooltip: 'Share',
429 ),
430 ],
431 ),
432
433 // Post + comments + loading indicator
434 SliverSafeArea(
435 top: false,
436 sliver: SliverList(
437 delegate: SliverChildBuilderDelegate(
438 (context, index) {
439 // Post card (index 0)
440 if (index == 0) {
441 return Column(
442 children: [
443 // Reuse PostCard (hide comment button in
444 // detail view)
445 // Use ValueListenableBuilder to only rebuild
446 // when time changes
447 _PostHeader(
448 post: widget.post,
449 currentTimeNotifier:
450 commentsProvider.currentTimeNotifier,
451 ),
452
453 // Visual divider before comments section
454 Container(
455 margin: const EdgeInsets.symmetric(vertical: 16),
456 height: 1,
457 color: AppColors.border,
458 ),
459
460 // Comments header with sort dropdown
461 CommentsHeader(
462 key: _commentsHeaderKey,
463 commentCount: comments.length,
464 currentSort: _currentSort,
465 onSortChanged: _onSortChanged,
466 ),
467 ],
468 );
469 }
470
471 // Loading indicator or error at the end
472 if (index == comments.length + 1) {
473 if (isLoadingMore) {
474 return const InlineLoading();
475 }
476 if (error != null) {
477 return InlineError(
478 message: ErrorMessages.getUserFriendly(error),
479 onRetry: () {
480 commentsProvider
481 ..clearError()
482 ..loadMoreComments();
483 },
484 );
485 }
486 }
487
488 // Comment item - use existing CommentThread widget
489 final comment = comments[index - 1];
490 return _CommentItem(
491 comment: comment,
492 currentTimeNotifier:
493 commentsProvider.currentTimeNotifier,
494 );
495 },
496 childCount:
497 1 +
498 comments.length +
499 (isLoadingMore || error != null ? 1 : 0),
500 ),
501 ),
502 ),
503 ],
504 ),
505 );
506 },
507 );
508 }
509}
510
511/// Post header widget that only rebuilds when time changes
512///
513/// Extracted to prevent unnecessary rebuilds when comment list changes.
514/// Uses ValueListenableBuilder to listen only to time updates.
515class _PostHeader extends StatelessWidget {
516 const _PostHeader({required this.post, required this.currentTimeNotifier});
517
518 final FeedViewPost post;
519 final ValueNotifier<DateTime?> currentTimeNotifier;
520
521 @override
522 Widget build(BuildContext context) {
523 return ValueListenableBuilder<DateTime?>(
524 valueListenable: currentTimeNotifier,
525 builder: (context, currentTime, child) {
526 return PostCard(
527 post: post,
528 currentTime: currentTime,
529 showCommentButton: false,
530 disableNavigation: true,
531 showActions: false,
532 showHeader: false,
533 showBorder: false,
534 showFullText: true,
535 showAuthorFooter: true,
536 textFontSize: 16,
537 textLineHeight: 1.6,
538 embedHeight: 280,
539 titleFontSize: 20,
540 titleFontWeight: FontWeight.w600,
541 );
542 },
543 );
544 }
545}
546
547/// Comment item wrapper that only rebuilds when time changes
548///
549/// Uses ValueListenableBuilder to prevent rebuilds when unrelated
550/// provider state changes (like loading state or error state).
551class _CommentItem extends StatelessWidget {
552 const _CommentItem({
553 required this.comment,
554 required this.currentTimeNotifier,
555 });
556
557 final ThreadViewComment comment;
558 final ValueNotifier<DateTime?> currentTimeNotifier;
559
560 @override
561 Widget build(BuildContext context) {
562 return ValueListenableBuilder<DateTime?>(
563 valueListenable: currentTimeNotifier,
564 builder: (context, currentTime, child) {
565 return CommentThread(
566 thread: comment,
567 currentTime: currentTime,
568 maxDepth: 6,
569 );
570 },
571 );
572 }
573}