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 postCid: widget.post.post.cid,
78 refresh: true,
79 );
80 }
81
82 /// Handle sort changes from dropdown
83 Future<void> _onSortChanged(String newSort) async {
84 final previousSort = _currentSort;
85
86 setState(() {
87 _currentSort = newSort;
88 });
89
90 final commentsProvider = context.read<CommentsProvider>();
91 final success = await commentsProvider.setSortOption(newSort);
92
93 // Show error snackbar and revert UI if sort change failed
94 if (!success && mounted) {
95 setState(() {
96 _currentSort = previousSort;
97 });
98
99 ScaffoldMessenger.of(context).showSnackBar(
100 SnackBar(
101 content: const Text('Failed to change sort order. Please try again.'),
102 backgroundColor: AppColors.primary,
103 behavior: SnackBarBehavior.floating,
104 duration: const Duration(seconds: 3),
105 action: SnackBarAction(
106 label: 'Retry',
107 textColor: AppColors.textPrimary,
108 onPressed: () {
109 _onSortChanged(newSort);
110 },
111 ),
112 ),
113 );
114 }
115 }
116
117 /// Handle scroll for pagination
118 void _onScroll() {
119 if (_scrollController.position.pixels >=
120 _scrollController.position.maxScrollExtent - 200) {
121 context.read<CommentsProvider>().loadMoreComments();
122 }
123 }
124
125 /// Handle pull-to-refresh
126 Future<void> _onRefresh() async {
127 final commentsProvider = context.read<CommentsProvider>();
128 await commentsProvider.refreshComments();
129 }
130
131 @override
132 Widget build(BuildContext context) {
133 return Scaffold(
134 backgroundColor: AppColors.background,
135 body: _buildContent(),
136 bottomNavigationBar: _buildActionBar(),
137 );
138 }
139
140 /// Build community title with avatar and handle
141 Widget _buildCommunityTitle() {
142 final community = widget.post.post.community;
143 final displayHandle = CommunityHandleUtils.formatHandleForDisplay(
144 community.handle,
145 );
146
147 return Row(
148 mainAxisSize: MainAxisSize.min,
149 children: [
150 // Community avatar
151 if (community.avatar != null && community.avatar!.isNotEmpty)
152 ClipRRect(
153 borderRadius: BorderRadius.circular(16),
154 child: CachedNetworkImage(
155 imageUrl: community.avatar!,
156 width: 32,
157 height: 32,
158 fit: BoxFit.cover,
159 placeholder: (context, url) => _buildFallbackAvatar(community),
160 errorWidget:
161 (context, url, error) => _buildFallbackAvatar(community),
162 ),
163 )
164 else
165 _buildFallbackAvatar(community),
166 const SizedBox(width: 8),
167 // Community handle with styled parts
168 if (displayHandle != null)
169 Flexible(child: _buildStyledHandle(displayHandle))
170 else
171 Flexible(
172 child: Text(
173 community.name,
174 style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600),
175 overflow: TextOverflow.ellipsis,
176 ),
177 ),
178 ],
179 );
180 }
181
182 /// Build styled community handle with color-coded parts
183 Widget _buildStyledHandle(String displayHandle) {
184 // Format: !gaming@coves.social
185 final atIndex = displayHandle.indexOf('@');
186 final communityPart = displayHandle.substring(0, atIndex);
187 final instancePart = displayHandle.substring(atIndex);
188
189 return Text.rich(
190 TextSpan(
191 children: [
192 TextSpan(
193 text: communityPart,
194 style: const TextStyle(
195 color: AppColors.communityName,
196 fontSize: 16,
197 fontWeight: FontWeight.w600,
198 ),
199 ),
200 TextSpan(
201 text: instancePart,
202 style: TextStyle(
203 color: AppColors.textSecondary.withValues(alpha: 0.8),
204 fontSize: 16,
205 fontWeight: FontWeight.w600,
206 ),
207 ),
208 ],
209 ),
210 overflow: TextOverflow.ellipsis,
211 );
212 }
213
214 /// Build fallback avatar with first letter
215 Widget _buildFallbackAvatar(CommunityRef community) {
216 final firstLetter = community.name.isNotEmpty ? community.name[0] : '?';
217 return Container(
218 width: 32,
219 height: 32,
220 decoration: BoxDecoration(
221 color: AppColors.primary,
222 borderRadius: BorderRadius.circular(16),
223 ),
224 child: Center(
225 child: Text(
226 firstLetter.toUpperCase(),
227 style: const TextStyle(
228 color: AppColors.textPrimary,
229 fontSize: 14,
230 fontWeight: FontWeight.bold,
231 ),
232 ),
233 ),
234 );
235 }
236
237 /// Handle share button tap
238 Future<void> _handleShare() async {
239 // Add haptic feedback
240 await HapticFeedback.lightImpact();
241
242 // TODO: Generate proper deep link URL when deep linking is implemented
243 final postUri = widget.post.post.uri;
244 final title = widget.post.post.title ?? 'Check out this post';
245
246 await Share.share('$title\n\n$postUri', subject: title);
247 }
248
249 /// Build bottom action bar with vote, save, and comment actions
250 Widget _buildActionBar() {
251 return Consumer<VoteProvider>(
252 builder: (context, voteProvider, child) {
253 final isVoted = voteProvider.isLiked(widget.post.post.uri);
254 final adjustedScore = voteProvider.getAdjustedScore(
255 widget.post.post.uri,
256 widget.post.post.stats.score,
257 );
258
259 // Create a modified post with adjusted score for display
260 final displayPost = FeedViewPost(
261 post: PostView(
262 uri: widget.post.post.uri,
263 cid: widget.post.post.cid,
264 rkey: widget.post.post.rkey,
265 author: widget.post.post.author,
266 community: widget.post.post.community,
267 createdAt: widget.post.post.createdAt,
268 indexedAt: widget.post.post.indexedAt,
269 text: widget.post.post.text,
270 title: widget.post.post.title,
271 stats: PostStats(
272 upvotes: widget.post.post.stats.upvotes,
273 downvotes: widget.post.post.stats.downvotes,
274 score: adjustedScore,
275 commentCount: widget.post.post.stats.commentCount,
276 ),
277 embed: widget.post.post.embed,
278 facets: widget.post.post.facets,
279 ),
280 reason: widget.post.reason,
281 );
282
283 return PostActionBar(
284 post: displayPost,
285 isVoted: isVoted,
286 onCommentInputTap: _openCommentComposer,
287 onCommentCountTap: _scrollToComments,
288 onVoteTap: () async {
289 // Check authentication
290 final authProvider = context.read<AuthProvider>();
291 if (!authProvider.isAuthenticated) {
292 ScaffoldMessenger.of(context).showSnackBar(
293 const SnackBar(
294 content: Text('Sign in to vote on posts'),
295 behavior: SnackBarBehavior.floating,
296 ),
297 );
298 return;
299 }
300
301 // Capture messenger before async operations
302 final messenger = ScaffoldMessenger.of(context);
303
304 // Light haptic feedback on both like and unlike
305 await HapticFeedback.lightImpact();
306 try {
307 await voteProvider.toggleVote(
308 postUri: widget.post.post.uri,
309 postCid: widget.post.post.cid,
310 );
311 } on Exception catch (e) {
312 if (mounted) {
313 messenger.showSnackBar(
314 SnackBar(
315 content: Text('Failed to vote: $e'),
316 behavior: SnackBarBehavior.floating,
317 ),
318 );
319 }
320 }
321 },
322 onSaveTap: () {
323 // TODO: Add save functionality
324 ScaffoldMessenger.of(context).showSnackBar(
325 const SnackBar(
326 content: Text('Save feature coming soon!'),
327 behavior: SnackBarBehavior.floating,
328 ),
329 );
330 },
331 );
332 },
333 );
334 }
335
336 /// Scroll to the comments section
337 void _scrollToComments() {
338 final context = _commentsHeaderKey.currentContext;
339 if (context != null) {
340 Scrollable.ensureVisible(
341 context,
342 duration: const Duration(milliseconds: 300),
343 curve: Curves.easeInOut,
344 );
345 }
346 }
347
348 /// Open the reply screen for composing a comment
349 void _openCommentComposer() {
350 // Check authentication
351 final authProvider = context.read<AuthProvider>();
352 if (!authProvider.isAuthenticated) {
353 ScaffoldMessenger.of(context).showSnackBar(
354 const SnackBar(
355 content: Text('Sign in to comment'),
356 behavior: SnackBarBehavior.floating,
357 ),
358 );
359 return;
360 }
361
362 // Navigate to reply screen with full post context
363 Navigator.of(context).push(
364 MaterialPageRoute<void>(
365 builder:
366 (context) =>
367 ReplyScreen(post: widget.post, onSubmit: _handleCommentSubmit),
368 ),
369 );
370 }
371
372 /// Handle comment submission (reply to post)
373 Future<void> _handleCommentSubmit(String content) async {
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 ),
461 ),
462 );
463 }
464
465 /// Build main content area
466 Widget _buildContent() {
467 // Use Consumer to rebuild when comments provider changes
468 return Consumer<CommentsProvider>(
469 builder: (context, commentsProvider, child) {
470 final isLoading = commentsProvider.isLoading;
471 final error = commentsProvider.error;
472 final comments = commentsProvider.comments;
473 final isLoadingMore = commentsProvider.isLoadingMore;
474
475 // Loading state (only show full-screen loader for initial load)
476 if (isLoading && comments.isEmpty) {
477 return const FullScreenLoading();
478 }
479
480 // Error state (only show full-screen error when no comments loaded yet)
481 if (error != null && comments.isEmpty) {
482 return FullScreenError(
483 title: 'Failed to load comments',
484 message: ErrorMessages.getUserFriendly(error),
485 onRetry: commentsProvider.retry,
486 );
487 }
488
489 // Content with RefreshIndicator and floating SliverAppBar
490 return RefreshIndicator(
491 onRefresh: _onRefresh,
492 color: AppColors.primary,
493 child: CustomScrollView(
494 controller: _scrollController,
495 slivers: [
496 // Floating app bar that hides on scroll down, shows on scroll up
497 SliverAppBar(
498 backgroundColor: AppColors.background,
499 surfaceTintColor: Colors.transparent,
500 foregroundColor: AppColors.textPrimary,
501 title: _buildCommunityTitle(),
502 centerTitle: false,
503 elevation: 0,
504 floating: true,
505 snap: true,
506 actions: [
507 IconButton(
508 icon: const ShareIcon(color: AppColors.textPrimary),
509 onPressed: _handleShare,
510 tooltip: 'Share',
511 ),
512 ],
513 ),
514
515 // Post + comments + loading indicator
516 SliverSafeArea(
517 top: false,
518 sliver: SliverList(
519 delegate: SliverChildBuilderDelegate(
520 (context, index) {
521 // Post card (index 0)
522 if (index == 0) {
523 return Column(
524 children: [
525 // Reuse PostCard (hide comment button in
526 // detail view)
527 // Use ValueListenableBuilder to only rebuild
528 // when time changes
529 _PostHeader(
530 post: widget.post,
531 currentTimeNotifier:
532 commentsProvider.currentTimeNotifier,
533 ),
534
535 // Visual divider before comments section
536 Container(
537 margin: const EdgeInsets.symmetric(vertical: 16),
538 height: 1,
539 color: AppColors.border,
540 ),
541
542 // Comments header with sort dropdown
543 CommentsHeader(
544 key: _commentsHeaderKey,
545 commentCount: comments.length,
546 currentSort: _currentSort,
547 onSortChanged: _onSortChanged,
548 ),
549 ],
550 );
551 }
552
553 // Loading indicator or error at the end
554 if (index == comments.length + 1) {
555 if (isLoadingMore) {
556 return const InlineLoading();
557 }
558 if (error != null) {
559 return InlineError(
560 message: ErrorMessages.getUserFriendly(error),
561 onRetry: () {
562 commentsProvider
563 ..clearError()
564 ..loadMoreComments();
565 },
566 );
567 }
568 }
569
570 // Comment item - use existing CommentThread widget
571 final comment = comments[index - 1];
572 return _CommentItem(
573 comment: comment,
574 currentTimeNotifier:
575 commentsProvider.currentTimeNotifier,
576 onCommentTap: _openReplyToComment,
577 collapsedComments: commentsProvider.collapsedComments,
578 onCollapseToggle: commentsProvider.toggleCollapsed,
579 );
580 },
581 childCount:
582 1 +
583 comments.length +
584 (isLoadingMore || error != null ? 1 : 0),
585 ),
586 ),
587 ),
588 ],
589 ),
590 );
591 },
592 );
593 }
594}
595
596/// Post header widget that only rebuilds when time changes
597///
598/// Extracted to prevent unnecessary rebuilds when comment list changes.
599/// Uses ValueListenableBuilder to listen only to time updates.
600class _PostHeader extends StatelessWidget {
601 const _PostHeader({required this.post, required this.currentTimeNotifier});
602
603 final FeedViewPost post;
604 final ValueNotifier<DateTime?> currentTimeNotifier;
605
606 @override
607 Widget build(BuildContext context) {
608 return ValueListenableBuilder<DateTime?>(
609 valueListenable: currentTimeNotifier,
610 builder: (context, currentTime, child) {
611 return PostCard(
612 post: post,
613 currentTime: currentTime,
614 showCommentButton: false,
615 disableNavigation: true,
616 showActions: false,
617 showHeader: false,
618 showBorder: false,
619 showFullText: true,
620 showAuthorFooter: true,
621 textFontSize: 16,
622 textLineHeight: 1.6,
623 embedHeight: 280,
624 titleFontSize: 20,
625 titleFontWeight: FontWeight.w600,
626 );
627 },
628 );
629 }
630}
631
632/// Comment item wrapper that only rebuilds when time changes
633///
634/// Uses ValueListenableBuilder to prevent rebuilds when unrelated
635/// provider state changes (like loading state or error state).
636class _CommentItem extends StatelessWidget {
637 const _CommentItem({
638 required this.comment,
639 required this.currentTimeNotifier,
640 this.onCommentTap,
641 this.collapsedComments = const {},
642 this.onCollapseToggle,
643 });
644
645 final ThreadViewComment comment;
646 final ValueNotifier<DateTime?> currentTimeNotifier;
647 final void Function(ThreadViewComment)? onCommentTap;
648 final Set<String> collapsedComments;
649 final void Function(String uri)? onCollapseToggle;
650
651 @override
652 Widget build(BuildContext context) {
653 return ValueListenableBuilder<DateTime?>(
654 valueListenable: currentTimeNotifier,
655 builder: (context, currentTime, child) {
656 return CommentThread(
657 thread: comment,
658 currentTime: currentTime,
659 maxDepth: 6,
660 onCommentTap: onCommentTap,
661 collapsedComments: collapsedComments,
662 onCollapseToggle: onCollapseToggle,
663 );
664 },
665 );
666 }
667}