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