Main coves client
1import 'package:cached_network_image/cached_network_image.dart';
2import 'package:flutter/foundation.dart';
3import 'package:flutter/material.dart';
4import 'package:flutter/services.dart';
5import 'package:provider/provider.dart';
6import 'package:share_plus/share_plus.dart';
7
8import '../../constants/app_colors.dart';
9import '../../models/comment.dart';
10import '../../models/post.dart';
11import '../../providers/auth_provider.dart';
12import '../../providers/comments_provider.dart';
13import '../../providers/vote_provider.dart';
14import '../../services/comments_provider_cache.dart';
15import '../../utils/community_handle_utils.dart';
16import '../../utils/error_messages.dart';
17import '../../widgets/comment_thread.dart';
18import '../../widgets/comments_header.dart';
19import '../../widgets/icons/share_icon.dart';
20import '../../widgets/loading_error_states.dart';
21import '../../widgets/post_action_bar.dart';
22import '../../widgets/post_card.dart';
23import '../../widgets/status_bar_overlay.dart';
24import '../compose/reply_screen.dart';
25import 'focused_thread_screen.dart';
26
27/// Post Detail Screen
28///
29/// Displays a full post with its comments.
30/// Architecture: Standalone screen for route destination and PageView child.
31///
32/// Features:
33/// - Full post display (reuses PostCard widget)
34/// - Sort selector (Hot/Top/New) using dropdown
35/// - Comment list with ListView.builder for performance
36/// - Pull-to-refresh with RefreshIndicator
37/// - Loading, empty, and error states
38/// - Automatic comment loading on screen init
39class PostDetailScreen extends StatefulWidget {
40 const PostDetailScreen({
41 required this.post,
42 this.isOptimistic = false,
43 super.key,
44 });
45
46 /// Post to display (passed via route extras)
47 final FeedViewPost post;
48
49 /// Whether this is an optimistic post (just created, not yet indexed)
50 /// When true, skips initial comment load since we know there are no comments
51 final bool isOptimistic;
52
53 @override
54 State<PostDetailScreen> createState() => _PostDetailScreenState();
55}
56
57class _PostDetailScreenState extends State<PostDetailScreen> {
58 // ScrollController created lazily with cached scroll position for instant restoration
59 late ScrollController _scrollController;
60 final GlobalKey _commentsHeaderKey = GlobalKey();
61
62 // Cached provider from CommentsProviderCache
63 late CommentsProvider _commentsProvider;
64 CommentsProviderCache? _commentsCache;
65
66 // Track initialization state
67 bool _isInitialized = false;
68
69 // Track if provider has been invalidated (e.g., by sign-out)
70 bool _providerInvalidated = false;
71
72 @override
73 void initState() {
74 super.initState();
75 // ScrollController and provider initialization moved to didChangeDependencies
76 // where we have access to context for synchronous provider acquisition
77 }
78
79 @override
80 void didChangeDependencies() {
81 super.didChangeDependencies();
82 // Initialize provider synchronously on first call (has context access)
83 // This ensures cached data is available for the first build, avoiding
84 // the flash from loading state → content → scroll position jump
85 if (!_isInitialized) {
86 _initializeProviderSync();
87 }
88 }
89
90 /// Listen for auth state changes to handle sign-out
91 void _setupAuthListener() {
92 final authProvider = context.read<AuthProvider>();
93 authProvider.addListener(_onAuthChanged);
94 }
95
96 /// Handle auth state changes (specifically sign-out)
97 void _onAuthChanged() {
98 if (!mounted) return;
99
100 final authProvider = context.read<AuthProvider>();
101
102 // If user signed out while viewing this screen, navigate back
103 // The CommentsProviderCache has already disposed our provider
104 if (!authProvider.isAuthenticated &&
105 _isInitialized &&
106 !_providerInvalidated) {
107 _providerInvalidated = true;
108
109 if (kDebugMode) {
110 debugPrint('🚪 User signed out - cleaning up PostDetailScreen');
111 }
112
113 // Remove listener from provider (it's disposed but this is safe)
114 try {
115 _commentsProvider.removeListener(_onProviderChanged);
116 } on Exception {
117 // Provider already disposed - expected
118 }
119
120 // Navigate back to feed
121 if (mounted) {
122 Navigator.of(context).popUntil((route) => route.isFirst);
123 }
124 }
125 }
126
127 /// Initialize provider synchronously from cache
128 ///
129 /// Called from didChangeDependencies to ensure cached data is available
130 /// for the first build. Creates ScrollController with initialScrollOffset
131 /// set to cached position for instant scroll restoration without flicker.
132 void _initializeProviderSync() {
133 // Get or create provider from cache
134 final cache = context.read<CommentsProviderCache>();
135 _commentsCache = cache;
136 _commentsProvider = cache.acquireProvider(
137 postUri: widget.post.post.uri,
138 postCid: widget.post.post.cid,
139 );
140
141 // Create scroll controller with cached position for instant restoration
142 // This avoids the flash: loading → content at top → jump to cached position
143 final cachedScrollPosition = _commentsProvider.scrollPosition;
144 _scrollController = ScrollController(
145 initialScrollOffset: cachedScrollPosition,
146 );
147 _scrollController.addListener(_onScroll);
148
149 if (kDebugMode && cachedScrollPosition > 0) {
150 debugPrint(
151 '📍 Created ScrollController with initial offset: $cachedScrollPosition',
152 );
153 }
154
155 // Listen for changes to trigger rebuilds
156 _commentsProvider.addListener(_onProviderChanged);
157
158 // Setup auth listener
159 _setupAuthListener();
160
161 // Mark as initialized before triggering any loads
162 // This ensures the first build shows content (not loading) when cached
163 _isInitialized = true;
164
165 // Skip loading for optimistic posts (just created, not yet indexed)
166 if (widget.isOptimistic) {
167 if (kDebugMode) {
168 debugPrint('✨ Optimistic post - skipping initial comment load');
169 }
170 // Don't load comments - there won't be any yet
171 } else if (_commentsProvider.comments.isNotEmpty) {
172 // Already have cached data - it will render immediately
173 if (kDebugMode) {
174 debugPrint(
175 '📦 Using cached comments (${_commentsProvider.comments.length})',
176 );
177 }
178
179 // Background refresh if data is stale (won't cause flicker)
180 if (_commentsProvider.isStale) {
181 if (kDebugMode) {
182 debugPrint('🔄 Data stale, refreshing in background');
183 }
184 _commentsProvider.loadComments(refresh: true);
185 }
186 } else {
187 // No cached data - load fresh
188 _commentsProvider.loadComments(refresh: true);
189 }
190 }
191
192 @override
193 void dispose() {
194 // Remove auth listener
195 try {
196 context.read<AuthProvider>().removeListener(_onAuthChanged);
197 } on Exception {
198 // Context may not be valid during dispose
199 }
200
201 // Release provider pin in cache (prevents LRU eviction disposing an active
202 // provider while this screen is in the navigation stack).
203 if (_isInitialized) {
204 try {
205 _commentsCache?.releaseProvider(widget.post.post.uri);
206 } on Exception {
207 // Cache may already be disposed
208 }
209 }
210
211 // Remove provider listener if not already invalidated
212 if (_isInitialized && !_providerInvalidated) {
213 try {
214 _commentsProvider.removeListener(_onProviderChanged);
215 } on Exception {
216 // Provider may already be disposed
217 }
218 }
219 _scrollController.dispose();
220 super.dispose();
221 }
222
223 /// Handle provider changes
224 void _onProviderChanged() {
225 if (mounted) {
226 setState(() {});
227 }
228 }
229
230 /// Handle sort changes from dropdown
231 Future<void> _onSortChanged(String newSort) async {
232 final success = await _commentsProvider.setSortOption(newSort);
233
234 // Show error snackbar if sort change failed
235 if (!success && mounted) {
236 ScaffoldMessenger.of(context).showSnackBar(
237 SnackBar(
238 content: const Text('Failed to change sort order. Please try again.'),
239 backgroundColor: AppColors.primary,
240 behavior: SnackBarBehavior.floating,
241 duration: const Duration(seconds: 3),
242 action: SnackBarAction(
243 label: 'Retry',
244 textColor: AppColors.textPrimary,
245 onPressed: () {
246 _onSortChanged(newSort);
247 },
248 ),
249 ),
250 );
251 }
252 }
253
254 /// Handle scroll for pagination
255 void _onScroll() {
256 // Don't interact with disposed provider
257 if (_providerInvalidated) return;
258
259 // Save scroll position to provider on every scroll event
260 if (_scrollController.hasClients) {
261 _commentsProvider.saveScrollPosition(_scrollController.position.pixels);
262 }
263
264 // Load more comments when near bottom
265 if (_scrollController.position.pixels >=
266 _scrollController.position.maxScrollExtent - 200) {
267 _commentsProvider.loadMoreComments();
268 }
269 }
270
271 /// Handle pull-to-refresh
272 Future<void> _onRefresh() async {
273 // Don't interact with disposed provider
274 if (_providerInvalidated) return;
275
276 await _commentsProvider.refreshComments();
277 }
278
279 @override
280 Widget build(BuildContext context) {
281 // Show loading until provider is initialized
282 if (!_isInitialized) {
283 return const Scaffold(
284 backgroundColor: AppColors.background,
285 body: FullScreenLoading(),
286 );
287 }
288
289 // If provider was invalidated (sign-out), show loading while navigating away
290 if (_providerInvalidated) {
291 return const Scaffold(
292 backgroundColor: AppColors.background,
293 body: FullScreenLoading(),
294 );
295 }
296
297 // Provide the cached CommentsProvider to descendant widgets
298 return ChangeNotifierProvider.value(
299 value: _commentsProvider,
300 child: Scaffold(
301 backgroundColor: AppColors.background,
302 body: _buildContent(),
303 bottomNavigationBar: _buildActionBar(),
304 ),
305 );
306 }
307
308 /// Build community title with avatar and handle
309 Widget _buildCommunityTitle() {
310 final community = widget.post.post.community;
311 final displayHandle = CommunityHandleUtils.formatHandleForDisplay(
312 community.handle,
313 );
314
315 return Row(
316 mainAxisSize: MainAxisSize.min,
317 children: [
318 // Community avatar
319 if (community.avatar != null && community.avatar!.isNotEmpty)
320 ClipRRect(
321 borderRadius: BorderRadius.circular(16),
322 child: CachedNetworkImage(
323 imageUrl: community.avatar!,
324 width: 32,
325 height: 32,
326 fit: BoxFit.cover,
327 placeholder: (context, url) => _buildFallbackAvatar(community),
328 errorWidget:
329 (context, url, error) => _buildFallbackAvatar(community),
330 ),
331 )
332 else
333 _buildFallbackAvatar(community),
334 const SizedBox(width: 8),
335 // Community handle with styled parts
336 if (displayHandle != null)
337 Flexible(child: _buildStyledHandle(displayHandle))
338 else
339 Flexible(
340 child: Text(
341 community.name,
342 style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600),
343 overflow: TextOverflow.ellipsis,
344 ),
345 ),
346 ],
347 );
348 }
349
350 /// Build styled community handle with color-coded parts
351 Widget _buildStyledHandle(String displayHandle) {
352 // Format: !gaming@coves.social
353 final atIndex = displayHandle.indexOf('@');
354 final communityPart = displayHandle.substring(0, atIndex);
355 final instancePart = displayHandle.substring(atIndex);
356
357 return Text.rich(
358 TextSpan(
359 children: [
360 TextSpan(
361 text: communityPart,
362 style: const TextStyle(
363 color: AppColors.communityName,
364 fontSize: 16,
365 fontWeight: FontWeight.w600,
366 ),
367 ),
368 TextSpan(
369 text: instancePart,
370 style: TextStyle(
371 color: AppColors.textSecondary.withValues(alpha: 0.8),
372 fontSize: 16,
373 fontWeight: FontWeight.w600,
374 ),
375 ),
376 ],
377 ),
378 overflow: TextOverflow.ellipsis,
379 );
380 }
381
382 /// Build fallback avatar with first letter
383 Widget _buildFallbackAvatar(CommunityRef community) {
384 final firstLetter = community.name.isNotEmpty ? community.name[0] : '?';
385 return Container(
386 width: 32,
387 height: 32,
388 decoration: BoxDecoration(
389 color: AppColors.primary,
390 borderRadius: BorderRadius.circular(16),
391 ),
392 child: Center(
393 child: Text(
394 firstLetter.toUpperCase(),
395 style: const TextStyle(
396 color: AppColors.textPrimary,
397 fontSize: 14,
398 fontWeight: FontWeight.bold,
399 ),
400 ),
401 ),
402 );
403 }
404
405 /// Handle share button tap
406 Future<void> _handleShare() async {
407 // Add haptic feedback
408 await HapticFeedback.lightImpact();
409
410 // TODO: Generate proper deep link URL when deep linking is implemented
411 final postUri = widget.post.post.uri;
412 final title = widget.post.post.title ?? 'Check out this post';
413
414 await Share.share('$title\n\n$postUri', subject: title);
415 }
416
417 /// Build bottom action bar with vote, save, and comment actions
418 Widget _buildActionBar() {
419 return Consumer<VoteProvider>(
420 builder: (context, voteProvider, child) {
421 final isVoted = voteProvider.isLiked(widget.post.post.uri);
422 final adjustedScore = voteProvider.getAdjustedScore(
423 widget.post.post.uri,
424 widget.post.post.stats.score,
425 );
426
427 // Create a modified post with adjusted score for display
428 final displayPost = FeedViewPost(
429 post: PostView(
430 uri: widget.post.post.uri,
431 cid: widget.post.post.cid,
432 rkey: widget.post.post.rkey,
433 author: widget.post.post.author,
434 community: widget.post.post.community,
435 createdAt: widget.post.post.createdAt,
436 indexedAt: widget.post.post.indexedAt,
437 text: widget.post.post.text,
438 title: widget.post.post.title,
439 stats: PostStats(
440 upvotes: widget.post.post.stats.upvotes,
441 downvotes: widget.post.post.stats.downvotes,
442 score: adjustedScore,
443 commentCount: widget.post.post.stats.commentCount,
444 ),
445 embed: widget.post.post.embed,
446 facets: widget.post.post.facets,
447 ),
448 reason: widget.post.reason,
449 );
450
451 return PostActionBar(
452 post: displayPost,
453 isVoted: isVoted,
454 onCommentInputTap: _openCommentComposer,
455 onCommentCountTap: _scrollToComments,
456 onVoteTap: () async {
457 // Check authentication
458 final authProvider = context.read<AuthProvider>();
459 if (!authProvider.isAuthenticated) {
460 ScaffoldMessenger.of(context).showSnackBar(
461 const SnackBar(
462 content: Text('Sign in to vote on posts'),
463 behavior: SnackBarBehavior.floating,
464 ),
465 );
466 return;
467 }
468
469 // Capture messenger before async operations
470 final messenger = ScaffoldMessenger.of(context);
471
472 // Light haptic feedback on both like and unlike
473 await HapticFeedback.lightImpact();
474 try {
475 await voteProvider.toggleVote(
476 postUri: widget.post.post.uri,
477 postCid: widget.post.post.cid,
478 );
479 } on Exception catch (e) {
480 if (mounted) {
481 messenger.showSnackBar(
482 SnackBar(
483 content: Text('Failed to vote: $e'),
484 behavior: SnackBarBehavior.floating,
485 ),
486 );
487 }
488 }
489 },
490 onSaveTap: () {
491 // TODO: Add save functionality
492 ScaffoldMessenger.of(context).showSnackBar(
493 const SnackBar(
494 content: Text('Save feature coming soon!'),
495 behavior: SnackBarBehavior.floating,
496 ),
497 );
498 },
499 );
500 },
501 );
502 }
503
504 /// Scroll to the comments section
505 void _scrollToComments() {
506 final context = _commentsHeaderKey.currentContext;
507 if (context != null) {
508 Scrollable.ensureVisible(
509 context,
510 duration: const Duration(milliseconds: 300),
511 curve: Curves.easeInOut,
512 );
513 }
514 }
515
516 /// Open the reply screen for composing a comment
517 void _openCommentComposer() {
518 // Check authentication
519 final authProvider = context.read<AuthProvider>();
520 if (!authProvider.isAuthenticated) {
521 ScaffoldMessenger.of(context).showSnackBar(
522 const SnackBar(
523 content: Text('Sign in to comment'),
524 behavior: SnackBarBehavior.floating,
525 ),
526 );
527 return;
528 }
529
530 // Navigate to reply screen with full post context
531 Navigator.of(context).push(
532 MaterialPageRoute<void>(
533 builder:
534 (context) => ReplyScreen(
535 post: widget.post,
536 onSubmit: _handleCommentSubmit,
537 commentsProvider: _commentsProvider,
538 ),
539 ),
540 );
541 }
542
543 /// Handle comment submission (reply to post)
544 Future<void> _handleCommentSubmit(String content) async {
545 final messenger = ScaffoldMessenger.of(context);
546
547 try {
548 await _commentsProvider.createComment(content: content);
549
550 if (mounted) {
551 messenger.showSnackBar(
552 const SnackBar(
553 content: Text('Comment posted'),
554 behavior: SnackBarBehavior.floating,
555 duration: Duration(seconds: 2),
556 ),
557 );
558 }
559 } on Exception catch (e) {
560 if (mounted) {
561 messenger.showSnackBar(
562 SnackBar(
563 content: Text('Failed to post comment: $e'),
564 behavior: SnackBarBehavior.floating,
565 backgroundColor: AppColors.primary,
566 ),
567 );
568 }
569 rethrow; // Let ReplyScreen know submission failed
570 }
571 }
572
573 /// Handle reply to a comment (nested reply)
574 Future<void> _handleCommentReply(
575 String content,
576 ThreadViewComment parentComment,
577 ) async {
578 final messenger = ScaffoldMessenger.of(context);
579
580 try {
581 await _commentsProvider.createComment(
582 content: content,
583 parentComment: parentComment,
584 );
585
586 if (mounted) {
587 messenger.showSnackBar(
588 const SnackBar(
589 content: Text('Reply posted'),
590 behavior: SnackBarBehavior.floating,
591 duration: Duration(seconds: 2),
592 ),
593 );
594 }
595 } on Exception catch (e) {
596 if (mounted) {
597 messenger.showSnackBar(
598 SnackBar(
599 content: Text('Failed to post reply: $e'),
600 behavior: SnackBarBehavior.floating,
601 backgroundColor: AppColors.primary,
602 ),
603 );
604 }
605 rethrow; // Let ReplyScreen know submission failed
606 }
607 }
608
609 /// Open reply screen for replying to a comment
610 void _openReplyToComment(ThreadViewComment comment) {
611 // Check authentication
612 final authProvider = context.read<AuthProvider>();
613 if (!authProvider.isAuthenticated) {
614 ScaffoldMessenger.of(context).showSnackBar(
615 const SnackBar(
616 content: Text('Sign in to reply'),
617 behavior: SnackBarBehavior.floating,
618 ),
619 );
620 return;
621 }
622
623 // Navigate to reply screen with comment context
624 Navigator.of(context).push(
625 MaterialPageRoute<void>(
626 builder:
627 (context) => ReplyScreen(
628 comment: comment,
629 onSubmit: (content) => _handleCommentReply(content, comment),
630 commentsProvider: _commentsProvider,
631 ),
632 ),
633 );
634 }
635
636 /// Navigate to focused thread screen for deep threads
637 void _onContinueThread(
638 ThreadViewComment thread,
639 List<ThreadViewComment> ancestors,
640 ) {
641 Navigator.of(context).push(
642 MaterialPageRoute<void>(
643 builder:
644 (context) => FocusedThreadScreen(
645 thread: thread,
646 ancestors: ancestors,
647 onReply: _handleCommentReply,
648 commentsProvider: _commentsProvider,
649 ),
650 ),
651 );
652 }
653
654 /// Build main content area
655 Widget _buildContent() {
656 // Use Consumer to rebuild when comments provider changes
657 return Consumer<CommentsProvider>(
658 builder: (context, commentsProvider, child) {
659 final isLoading = commentsProvider.isLoading;
660 final error = commentsProvider.error;
661 final comments = commentsProvider.comments;
662 final isLoadingMore = commentsProvider.isLoadingMore;
663
664 // Loading state (only show full-screen loader for initial load)
665 if (isLoading && comments.isEmpty) {
666 return const FullScreenLoading();
667 }
668
669 // Error state (only show full-screen error when no comments loaded yet)
670 if (error != null && comments.isEmpty) {
671 return FullScreenError(
672 title: 'Failed to load comments',
673 message: ErrorMessages.getUserFriendly(error),
674 onRetry: commentsProvider.retry,
675 );
676 }
677
678 // Content with RefreshIndicator and floating SliverAppBar
679 // Wrapped in Stack to add solid status bar background overlay
680 return Stack(
681 children: [
682 RefreshIndicator(
683 onRefresh: _onRefresh,
684 color: AppColors.primary,
685 child: CustomScrollView(
686 controller: _scrollController,
687 physics: const AlwaysScrollableScrollPhysics(),
688 slivers: [
689 // Floating app bar that hides on scroll down,
690 // shows on scroll up
691 SliverAppBar(
692 backgroundColor: AppColors.background,
693 surfaceTintColor: Colors.transparent,
694 foregroundColor: AppColors.textPrimary,
695 title: _buildCommunityTitle(),
696 centerTitle: false,
697 elevation: 0,
698 floating: true,
699 snap: true,
700 actions: [
701 IconButton(
702 icon: const ShareIcon(color: AppColors.textPrimary),
703 onPressed: _handleShare,
704 tooltip: 'Share',
705 ),
706 ],
707 ),
708
709 // Post + comments + loading indicator
710 SliverSafeArea(
711 top: false,
712 sliver: SliverList(
713 delegate: SliverChildBuilderDelegate(
714 (context, index) {
715 // Post card (index 0)
716 if (index == 0) {
717 return Column(
718 children: [
719 // Reuse PostCard (hide comment button in
720 // detail view)
721 // Use ValueListenableBuilder to only rebuild
722 // when time changes
723 _PostHeader(
724 post: widget.post,
725 currentTimeNotifier:
726 commentsProvider.currentTimeNotifier,
727 ),
728
729 // Visual divider before comments section
730 Container(
731 margin: const EdgeInsets.symmetric(
732 vertical: 16,
733 ),
734 height: 1,
735 color: AppColors.border,
736 ),
737
738 // Comments header with sort dropdown
739 CommentsHeader(
740 key: _commentsHeaderKey,
741 commentCount: comments.length,
742 currentSort: commentsProvider.sort,
743 onSortChanged: _onSortChanged,
744 ),
745 ],
746 );
747 }
748
749 // Loading indicator or error at the end
750 if (index == comments.length + 1) {
751 if (isLoadingMore) {
752 return const InlineLoading();
753 }
754 if (error != null) {
755 return InlineError(
756 message: ErrorMessages.getUserFriendly(error),
757 onRetry: () {
758 commentsProvider
759 ..clearError()
760 ..loadMoreComments();
761 },
762 );
763 }
764 }
765
766 // Comment item - use existing CommentThread widget
767 final comment = comments[index - 1];
768 return _CommentItem(
769 comment: comment,
770 currentTimeNotifier:
771 commentsProvider.currentTimeNotifier,
772 onCommentTap: _openReplyToComment,
773 collapsedComments:
774 commentsProvider.collapsedComments,
775 onCollapseToggle: commentsProvider.toggleCollapsed,
776 onContinueThread: _onContinueThread,
777 );
778 },
779 childCount:
780 1 +
781 comments.length +
782 (isLoadingMore || error != null ? 1 : 0),
783 ),
784 ),
785 ),
786 ],
787 ),
788 ),
789 // Prevents content showing through transparent status bar
790 const StatusBarOverlay(),
791 ],
792 );
793 },
794 );
795 }
796}
797
798/// Post header widget that only rebuilds when time changes
799///
800/// Extracted to prevent unnecessary rebuilds when comment list changes.
801/// Uses ValueListenableBuilder to listen only to time updates.
802class _PostHeader extends StatelessWidget {
803 const _PostHeader({required this.post, required this.currentTimeNotifier});
804
805 final FeedViewPost post;
806 final ValueNotifier<DateTime?> currentTimeNotifier;
807
808 @override
809 Widget build(BuildContext context) {
810 return ValueListenableBuilder<DateTime?>(
811 valueListenable: currentTimeNotifier,
812 builder: (context, currentTime, child) {
813 return PostCard(
814 post: post,
815 currentTime: currentTime,
816 showCommentButton: false,
817 disableNavigation: true,
818 showActions: false,
819 showHeader: false,
820 showBorder: false,
821 showFullText: true,
822 showAuthorFooter: true,
823 textFontSize: 16,
824 textLineHeight: 1.6,
825 embedHeight: 280,
826 titleFontSize: 20,
827 titleFontWeight: FontWeight.w600,
828 );
829 },
830 );
831 }
832}
833
834/// Comment item wrapper that only rebuilds when time changes
835///
836/// Uses ValueListenableBuilder to prevent rebuilds when unrelated
837/// provider state changes (like loading state or error state).
838class _CommentItem extends StatelessWidget {
839 const _CommentItem({
840 required this.comment,
841 required this.currentTimeNotifier,
842 this.onCommentTap,
843 this.collapsedComments = const {},
844 this.onCollapseToggle,
845 this.onContinueThread,
846 });
847
848 final ThreadViewComment comment;
849 final ValueNotifier<DateTime?> currentTimeNotifier;
850 final void Function(ThreadViewComment)? onCommentTap;
851 final Set<String> collapsedComments;
852 final void Function(String uri)? onCollapseToggle;
853 final void Function(ThreadViewComment, List<ThreadViewComment>)?
854 onContinueThread;
855
856 @override
857 Widget build(BuildContext context) {
858 return ValueListenableBuilder<DateTime?>(
859 valueListenable: currentTimeNotifier,
860 builder: (context, currentTime, child) {
861 return CommentThread(
862 thread: comment,
863 currentTime: currentTime,
864 maxDepth: 6,
865 onCommentTap: onCommentTap,
866 collapsedComments: collapsedComments,
867 onCollapseToggle: onCollapseToggle,
868 onContinueThread: onContinueThread,
869 );
870 },
871 );
872 }
873}