fix(post): instant scroll restoration when returning to cached post

Move provider initialization from postFrameCallback to didChangeDependencies
for synchronous access before first build. Create ScrollController with
initialScrollOffset set to cached position, eliminating the visible flash
from loading → content at top → jump to cached position.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

Changed files
+51 -44
lib
+51 -44
lib/screens/home/post_detail_screen.dart
··· 37 37 /// - Loading, empty, and error states 38 38 /// - Automatic comment loading on screen init 39 39 class PostDetailScreen extends StatefulWidget { 40 - const PostDetailScreen({required this.post, this.isOptimistic = false, super.key}); 40 + const PostDetailScreen({ 41 + required this.post, 42 + this.isOptimistic = false, 43 + super.key, 44 + }); 41 45 42 46 /// Post to display (passed via route extras) 43 47 final FeedViewPost post; ··· 51 55 } 52 56 53 57 class _PostDetailScreenState extends State<PostDetailScreen> { 54 - final ScrollController _scrollController = ScrollController(); 58 + // ScrollController created lazily with cached scroll position for instant restoration 59 + late ScrollController _scrollController; 55 60 final GlobalKey _commentsHeaderKey = GlobalKey(); 56 61 57 62 // Cached provider from CommentsProviderCache ··· 67 72 @override 68 73 void initState() { 69 74 super.initState(); 70 - _scrollController.addListener(_onScroll); 75 + // ScrollController and provider initialization moved to didChangeDependencies 76 + // where we have access to context for synchronous provider acquisition 77 + } 71 78 72 - // Initialize provider after frame is built 73 - WidgetsBinding.instance.addPostFrameCallback((_) { 74 - if (mounted) { 75 - _initializeProvider(); 76 - _setupAuthListener(); 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 + } 79 88 } 80 89 81 90 /// Listen for auth state changes to handle sign-out ··· 92 101 93 102 // If user signed out while viewing this screen, navigate back 94 103 // The CommentsProviderCache has already disposed our provider 95 - if (!authProvider.isAuthenticated && _isInitialized && !_providerInvalidated) { 104 + if (!authProvider.isAuthenticated && 105 + _isInitialized && 106 + !_providerInvalidated) { 96 107 _providerInvalidated = true; 97 108 98 109 if (kDebugMode) { ··· 113 124 } 114 125 } 115 126 116 - /// Initialize provider from cache and restore state 117 - void _initializeProvider() { 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() { 118 133 // Get or create provider from cache 119 134 final cache = context.read<CommentsProviderCache>(); 120 135 _commentsCache = cache; ··· 123 138 postCid: widget.post.post.cid, 124 139 ); 125 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 + 126 155 // Listen for changes to trigger rebuilds 127 156 _commentsProvider.addListener(_onProviderChanged); 128 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 + 129 165 // Skip loading for optimistic posts (just created, not yet indexed) 130 166 if (widget.isOptimistic) { 131 167 if (kDebugMode) { ··· 133 169 } 134 170 // Don't load comments - there won't be any yet 135 171 } else if (_commentsProvider.comments.isNotEmpty) { 136 - // Already have data - restore scroll position immediately 172 + // Already have cached data - it will render immediately 137 173 if (kDebugMode) { 138 174 debugPrint( 139 175 '📦 Using cached comments (${_commentsProvider.comments.length})', 140 176 ); 141 177 } 142 - _restoreScrollPosition(); 143 178 144 - // Background refresh if data is stale 179 + // Background refresh if data is stale (won't cause flicker) 145 180 if (_commentsProvider.isStale) { 146 181 if (kDebugMode) { 147 182 debugPrint('🔄 Data stale, refreshing in background'); ··· 152 187 // No cached data - load fresh 153 188 _commentsProvider.loadComments(refresh: true); 154 189 } 155 - 156 - setState(() { 157 - _isInitialized = true; 158 - }); 159 190 } 160 191 161 192 @override ··· 194 225 if (mounted) { 195 226 setState(() {}); 196 227 } 197 - } 198 - 199 - /// Restore scroll position from provider 200 - void _restoreScrollPosition() { 201 - final savedPosition = _commentsProvider.scrollPosition; 202 - if (savedPosition <= 0) { 203 - return; 204 - } 205 - 206 - WidgetsBinding.instance.addPostFrameCallback((_) { 207 - if (!mounted || !_scrollController.hasClients) { 208 - return; 209 - } 210 - 211 - final maxExtent = _scrollController.position.maxScrollExtent; 212 - final targetPosition = savedPosition.clamp(0.0, maxExtent); 213 - 214 - if (targetPosition > 0) { 215 - _scrollController.jumpTo(targetPosition); 216 - if (kDebugMode) { 217 - debugPrint('📍 Restored scroll to $targetPosition (max: $maxExtent)'); 218 - } 219 - } 220 - }); 221 228 } 222 229 223 230 /// Handle sort changes from dropdown