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