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