refactor(feed): update FeedScreen with auth sync and lazy feed loading

Refactors FeedScreen to work with MultiFeedProvider and adds several
UX and reliability improvements:

PageView for feed switching:
- Authenticated users can swipe between Discover and For You
- Unauthenticated users see only Discover (no PageView)
- Per-feed ScrollControllers with position restoration

Auth state synchronization:
- Listens to AuthProvider changes
- Jumps PageController to page 0 on sign-out to match provider state
- Prevents tab/page mismatch after re-authentication

Lazy feed loading (_ensureFeedLoaded):
- Triggers initial load when switching to an unloaded feed
- Handles case where user signs in after app start and taps For You
- Called from both tab tap and swipe navigation

This fixes issues where:
- For You tab showed empty state after signing in mid-session
- PageController stayed on page 1 after sign-out while provider
switched to Discover, causing misalignment on re-auth

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

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

Changed files
+188 -244
lib
screens
+188 -244
lib/screens/home/feed_screen.dart
··· 2 2 import 'package:provider/provider.dart'; 3 3 4 4 import '../../constants/app_colors.dart'; 5 - import '../../models/post.dart'; 6 5 import '../../providers/auth_provider.dart'; 7 - import '../../providers/feed_provider.dart'; 6 + import '../../providers/multi_feed_provider.dart'; 7 + import '../../widgets/feed_page.dart'; 8 8 import '../../widgets/icons/bluesky_icons.dart'; 9 - import '../../widgets/post_card.dart'; 10 9 11 10 /// Header layout constants 12 11 const double _kHeaderHeight = 44; 13 12 const double _kTabUnderlineWidth = 28; 14 13 const double _kTabUnderlineHeight = 3; 15 - const double _kHeaderContentPadding = _kHeaderHeight; 16 14 17 15 class FeedScreen extends StatefulWidget { 18 16 const FeedScreen({super.key, this.onSearchTap}); ··· 25 23 } 26 24 27 25 class _FeedScreenState extends State<FeedScreen> { 28 - final ScrollController _scrollController = ScrollController(); 26 + late PageController _pageController; 27 + final Map<FeedType, ScrollController> _scrollControllers = {}; 28 + late AuthProvider _authProvider; 29 + bool _wasAuthenticated = false; 29 30 30 31 @override 31 32 void initState() { 32 33 super.initState(); 33 - _scrollController.addListener(_onScroll); 34 + 35 + // Initialize PageController 36 + // Start on page 0 (Discover) or 1 (For You) based on current feed 37 + final provider = context.read<MultiFeedProvider>(); 38 + final initialPage = provider.currentFeedType == FeedType.forYou ? 1 : 0; 39 + _pageController = PageController(initialPage: initialPage); 40 + 41 + // Save reference to AuthProvider for listener management 42 + _authProvider = context.read<AuthProvider>(); 43 + _wasAuthenticated = _authProvider.isAuthenticated; 34 44 35 - // Fetch feed after frame is built 45 + // Listen to auth changes to sync PageController with provider state 46 + _authProvider.addListener(_onAuthChanged); 47 + 48 + // Load initial feed after frame is built 36 49 WidgetsBinding.instance.addPostFrameCallback((_) { 37 - // Check if widget is still mounted before loading 38 50 if (mounted) { 39 - _loadFeed(); 51 + _loadInitialFeed(); 40 52 } 41 53 }); 42 54 } 43 55 44 56 @override 45 57 void dispose() { 46 - _scrollController.dispose(); 58 + _authProvider.removeListener(_onAuthChanged); 59 + _pageController.dispose(); 60 + for (final controller in _scrollControllers.values) { 61 + controller.dispose(); 62 + } 47 63 super.dispose(); 48 64 } 49 65 50 - /// Load feed - business logic is now in FeedProvider 51 - void _loadFeed() { 52 - Provider.of<FeedProvider>(context, listen: false).loadFeed(refresh: true); 66 + /// Handle auth state changes to sync PageController with provider 67 + /// 68 + /// When user signs out while on For You tab, the provider switches to 69 + /// Discover but PageController stays on page 1. This listener ensures 70 + /// they stay in sync. 71 + void _onAuthChanged() { 72 + final isAuthenticated = _authProvider.isAuthenticated; 73 + 74 + // On sign-out: jump to Discover (page 0) to match provider state 75 + if (_wasAuthenticated && !isAuthenticated) { 76 + if (_pageController.hasClients && _pageController.page != 0) { 77 + _pageController.jumpToPage(0); 78 + } 79 + } 80 + 81 + _wasAuthenticated = isAuthenticated; 53 82 } 54 83 55 - void _onScroll() { 56 - if (_scrollController.position.pixels >= 57 - _scrollController.position.maxScrollExtent - 200) { 58 - Provider.of<FeedProvider>(context, listen: false).loadMore(); 84 + /// Load initial feed based on authentication 85 + void _loadInitialFeed() { 86 + final provider = context.read<MultiFeedProvider>(); 87 + final isAuthenticated = context.read<AuthProvider>().isAuthenticated; 88 + 89 + // Load the current feed 90 + provider.loadFeed(provider.currentFeedType, refresh: true); 91 + 92 + // Preload the other feed if authenticated 93 + if (isAuthenticated) { 94 + final otherFeed = 95 + provider.currentFeedType == FeedType.discover 96 + ? FeedType.forYou 97 + : FeedType.discover; 98 + provider.loadFeed(otherFeed, refresh: true); 59 99 } 60 100 } 61 101 62 - Future<void> _onRefresh() async { 63 - final feedProvider = Provider.of<FeedProvider>(context, listen: false); 64 - await feedProvider.loadFeed(refresh: true); 102 + /// Get or create scroll controller for a feed type 103 + ScrollController _getOrCreateScrollController(FeedType type) { 104 + if (!_scrollControllers.containsKey(type)) { 105 + final provider = context.read<MultiFeedProvider>(); 106 + final state = provider.getState(type); 107 + _scrollControllers[type] = ScrollController( 108 + initialScrollOffset: state.scrollPosition, 109 + ); 110 + _scrollControllers[type]!.addListener(() => _onScroll(type)); 111 + } 112 + return _scrollControllers[type]!; 113 + } 114 + 115 + /// Handle scroll events for pagination and scroll position saving 116 + void _onScroll(FeedType type) { 117 + final controller = _scrollControllers[type]; 118 + if (controller != null && controller.hasClients) { 119 + // Save scroll position passively (no rebuild needed) 120 + context.read<MultiFeedProvider>().saveScrollPosition( 121 + type, 122 + controller.position.pixels, 123 + ); 124 + 125 + // Trigger pagination when near bottom 126 + if (controller.position.pixels >= 127 + controller.position.maxScrollExtent - 200) { 128 + context.read<MultiFeedProvider>().loadMore(type); 129 + } 130 + } 65 131 } 66 132 67 133 @override 68 134 Widget build(BuildContext context) { 69 - // Optimized: Use select to only rebuild when specific fields change 70 - // This prevents unnecessary rebuilds when unrelated provider fields change 135 + // Use select to only rebuild when specific fields change 71 136 final isAuthenticated = context.select<AuthProvider, bool>( 72 137 (p) => p.isAuthenticated, 73 138 ); 74 - final isLoading = context.select<FeedProvider, bool>((p) => p.isLoading); 75 - final error = context.select<FeedProvider, String?>((p) => p.error); 76 - final feedType = context.select<FeedProvider, FeedType>((p) => p.feedType); 77 - 78 - // IMPORTANT: This relies on FeedProvider creating new list instances 79 - // (_posts = [..._posts, ...response.feed]) rather than mutating in-place. 80 - // context.select uses == for comparison, and Lists use reference equality, 81 - // so in-place mutations (_posts.addAll(...)) would not trigger rebuilds. 82 - final posts = context.select<FeedProvider, List<FeedViewPost>>( 83 - (p) => p.posts, 84 - ); 85 - final isLoadingMore = context.select<FeedProvider, bool>( 86 - (p) => p.isLoadingMore, 87 - ); 88 - final currentTime = context.select<FeedProvider, DateTime?>( 89 - (p) => p.currentTime, 139 + final currentFeed = context.select<MultiFeedProvider, FeedType>( 140 + (p) => p.currentFeedType, 90 141 ); 91 142 92 143 return Scaffold( ··· 94 145 body: SafeArea( 95 146 child: Stack( 96 147 children: [ 97 - // Feed content (behind header) 98 - _buildBody( 99 - isLoading: isLoading, 100 - error: error, 101 - posts: posts, 102 - isLoadingMore: isLoadingMore, 148 + // Feed content with PageView for swipe navigation 149 + _buildBody(isAuthenticated: isAuthenticated), 150 + // Transparent header overlay 151 + _buildHeader( 152 + feedType: currentFeed, 103 153 isAuthenticated: isAuthenticated, 104 - currentTime: currentTime, 105 154 ), 106 - // Transparent header overlay 107 - _buildHeader(feedType: feedType, isAuthenticated: isAuthenticated), 108 155 ], 109 156 ), 110 157 ), ··· 182 229 _buildFeedTypeTab( 183 230 label: 'Discover', 184 231 isActive: feedType == FeedType.discover, 185 - onTap: () => _switchToFeedType(FeedType.discover), 232 + onTap: () => _switchToFeedType(FeedType.discover, 0), 186 233 ), 187 234 const SizedBox(width: 24), 188 235 _buildFeedTypeTab( 189 236 label: 'For You', 190 237 isActive: feedType == FeedType.forYou, 191 - onTap: () => _switchToFeedType(FeedType.forYou), 238 + onTap: () => _switchToFeedType(FeedType.forYou, 1), 192 239 ), 193 240 ], 194 241 ); ··· 237 284 ); 238 285 } 239 286 240 - void _switchToFeedType(FeedType type) { 241 - Provider.of<FeedProvider>(context, listen: false).setFeedType(type); 287 + /// Switch to a feed type and animate PageView 288 + void _switchToFeedType(FeedType type, int pageIndex) { 289 + final provider = context.read<MultiFeedProvider>(); 290 + provider.setCurrentFeed(type); 291 + 292 + // Animate to the corresponding page 293 + _pageController.animateToPage( 294 + pageIndex, 295 + duration: const Duration(milliseconds: 300), 296 + curve: Curves.easeInOut, 297 + ); 298 + 299 + // Load the feed if it hasn't been loaded yet 300 + _ensureFeedLoaded(type); 301 + 302 + // Restore scroll position after page animation completes 303 + _restoreScrollPosition(type); 242 304 } 243 305 244 - Widget _buildBody({ 245 - required bool isLoading, 246 - required String? error, 247 - required List<FeedViewPost> posts, 248 - required bool isLoadingMore, 249 - required bool isAuthenticated, 250 - required DateTime? currentTime, 251 - }) { 252 - // Loading state (only show full-screen loader for initial load, 253 - // not refresh) 254 - if (isLoading && posts.isEmpty) { 255 - return const Center( 256 - child: CircularProgressIndicator(color: AppColors.primary), 257 - ); 258 - } 306 + /// Ensure a feed is loaded (trigger initial load if needed) 307 + /// 308 + /// Called when switching to a feed that may not have been loaded yet, 309 + /// e.g., when user signs in after app start and taps "For You" tab. 310 + void _ensureFeedLoaded(FeedType type) { 311 + final provider = context.read<MultiFeedProvider>(); 312 + final state = provider.getState(type); 259 313 260 - // Error state (only show full-screen error when no posts loaded 261 - // yet). If we have posts but pagination failed, we'll show the error 262 - // at the bottom 263 - if (error != null && posts.isEmpty) { 264 - return Center( 265 - child: Padding( 266 - padding: const EdgeInsets.all(24), 267 - child: Column( 268 - mainAxisAlignment: MainAxisAlignment.center, 269 - children: [ 270 - const Icon( 271 - Icons.error_outline, 272 - size: 64, 273 - color: AppColors.primary, 274 - ), 275 - const SizedBox(height: 16), 276 - const Text( 277 - 'Failed to load feed', 278 - style: TextStyle( 279 - fontSize: 20, 280 - color: AppColors.textPrimary, 281 - fontWeight: FontWeight.bold, 282 - ), 283 - ), 284 - const SizedBox(height: 8), 285 - Text( 286 - _getUserFriendlyError(error), 287 - style: const TextStyle( 288 - fontSize: 14, 289 - color: AppColors.textSecondary, 290 - ), 291 - textAlign: TextAlign.center, 292 - ), 293 - const SizedBox(height: 24), 294 - ElevatedButton( 295 - onPressed: () { 296 - Provider.of<FeedProvider>(context, listen: false).retry(); 297 - }, 298 - style: ElevatedButton.styleFrom( 299 - backgroundColor: AppColors.primary, 300 - ), 301 - child: const Text('Retry'), 302 - ), 303 - ], 304 - ), 305 - ), 306 - ); 314 + // If the feed has no posts and isn't currently loading, trigger a load 315 + if (state.posts.isEmpty && !state.isLoading) { 316 + provider.loadFeed(type, refresh: true); 307 317 } 318 + } 308 319 309 - // Empty state 310 - if (posts.isEmpty) { 311 - return Center( 312 - child: Padding( 313 - padding: const EdgeInsets.all(24), 314 - child: Column( 315 - mainAxisAlignment: MainAxisAlignment.center, 316 - children: [ 317 - const Icon(Icons.forum, size: 64, color: AppColors.primary), 318 - const SizedBox(height: 24), 319 - Text( 320 - isAuthenticated ? 'No posts yet' : 'No posts to discover', 321 - style: const TextStyle( 322 - fontSize: 20, 323 - color: AppColors.textPrimary, 324 - fontWeight: FontWeight.bold, 325 - ), 326 - ), 327 - const SizedBox(height: 8), 328 - Text( 329 - isAuthenticated 330 - ? 'Subscribe to communities to see posts in your feed' 331 - : 'Check back later for new posts', 332 - style: const TextStyle( 333 - fontSize: 14, 334 - color: AppColors.textSecondary, 335 - ), 336 - textAlign: TextAlign.center, 337 - ), 338 - ], 339 - ), 340 - ), 341 - ); 342 - } 320 + /// Restore scroll position for a feed type 321 + void _restoreScrollPosition(FeedType type) { 322 + // Wait for the next frame to ensure the controller has clients 323 + WidgetsBinding.instance.addPostFrameCallback((_) { 324 + if (!mounted) return; 343 325 344 - // Posts list 345 - return RefreshIndicator( 346 - onRefresh: _onRefresh, 347 - color: AppColors.primary, 348 - child: ListView.builder( 349 - controller: _scrollController, 350 - // Add top padding so content isn't hidden behind transparent header 351 - padding: const EdgeInsets.only(top: _kHeaderContentPadding), 352 - // Add extra item for loading indicator or pagination error 353 - itemCount: posts.length + (isLoadingMore || error != null ? 1 : 0), 354 - itemBuilder: (context, index) { 355 - // Footer: loading indicator or error message 356 - if (index == posts.length) { 357 - // Show loading indicator for pagination 358 - if (isLoadingMore) { 359 - return const Center( 360 - child: Padding( 361 - padding: EdgeInsets.all(16), 362 - child: CircularProgressIndicator(color: AppColors.primary), 363 - ), 364 - ); 365 - } 366 - // Show error message for pagination failures 367 - if (error != null) { 368 - return Container( 369 - margin: const EdgeInsets.all(16), 370 - padding: const EdgeInsets.all(16), 371 - decoration: BoxDecoration( 372 - color: AppColors.background, 373 - borderRadius: BorderRadius.circular(8), 374 - border: Border.all(color: AppColors.primary), 375 - ), 376 - child: Column( 377 - children: [ 378 - const Icon( 379 - Icons.error_outline, 380 - color: AppColors.primary, 381 - size: 32, 382 - ), 383 - const SizedBox(height: 8), 384 - Text( 385 - _getUserFriendlyError(error), 386 - style: const TextStyle( 387 - color: AppColors.textSecondary, 388 - fontSize: 14, 389 - ), 390 - textAlign: TextAlign.center, 391 - ), 392 - const SizedBox(height: 12), 393 - TextButton( 394 - onPressed: () { 395 - Provider.of<FeedProvider>(context, listen: false) 396 - ..clearError() 397 - ..loadMore(); 398 - }, 399 - style: TextButton.styleFrom( 400 - foregroundColor: AppColors.primary, 401 - ), 402 - child: const Text('Retry'), 403 - ), 404 - ], 405 - ), 406 - ); 407 - } 408 - } 326 + final controller = _scrollControllers[type]; 327 + if (controller != null && controller.hasClients) { 328 + final provider = context.read<MultiFeedProvider>(); 329 + final savedPosition = provider.getState(type).scrollPosition; 409 330 410 - final post = posts[index]; 411 - return Semantics( 412 - label: 413 - 'Feed post in ${post.post.community.name} by ' 414 - '${post.post.author.displayName ?? post.post.author.handle}. ' 415 - '${post.post.title ?? ""}', 416 - button: true, 417 - child: PostCard(post: post, currentTime: currentTime), 418 - ); 419 - }, 420 - ), 331 + // Only jump if the saved position differs from current 332 + if ((controller.offset - savedPosition).abs() > 1) { 333 + controller.jumpTo(savedPosition); 334 + } 335 + } 336 + }); 337 + } 338 + 339 + Widget _buildBody({required bool isAuthenticated}) { 340 + // For unauthenticated users, show only Discover feed (no PageView) 341 + if (!isAuthenticated) { 342 + return _buildFeedPage(FeedType.discover, isAuthenticated); 343 + } 344 + 345 + // For authenticated users, use PageView for swipe navigation 346 + return PageView( 347 + controller: _pageController, 348 + onPageChanged: (index) { 349 + final type = index == 0 ? FeedType.discover : FeedType.forYou; 350 + context.read<MultiFeedProvider>().setCurrentFeed(type); 351 + // Load the feed if it hasn't been loaded yet 352 + _ensureFeedLoaded(type); 353 + // Restore scroll position when swiping between feeds 354 + _restoreScrollPosition(type); 355 + }, 356 + children: [ 357 + _buildFeedPage(FeedType.discover, isAuthenticated), 358 + _buildFeedPage(FeedType.forYou, isAuthenticated), 359 + ], 421 360 ); 422 361 } 423 362 424 - /// Transform technical error messages into user-friendly ones 425 - String _getUserFriendlyError(String error) { 426 - final lowerError = error.toLowerCase(); 363 + /// Build a FeedPage widget with all required state from provider 364 + Widget _buildFeedPage(FeedType feedType, bool isAuthenticated) { 365 + return Consumer<MultiFeedProvider>( 366 + builder: (context, provider, _) { 367 + final state = provider.getState(feedType); 427 368 428 - if (lowerError.contains('socketexception') || 429 - lowerError.contains('network') || 430 - lowerError.contains('connection refused')) { 431 - return 'Please check your internet connection'; 432 - } else if (lowerError.contains('timeoutexception') || 433 - lowerError.contains('timeout')) { 434 - return 'Request timed out. Please try again'; 435 - } else if (lowerError.contains('401') || 436 - lowerError.contains('unauthorized')) { 437 - return 'Authentication failed. Please sign in again'; 438 - } else if (lowerError.contains('404') || lowerError.contains('not found')) { 439 - return 'Content not found'; 440 - } else if (lowerError.contains('500') || 441 - lowerError.contains('internal server')) { 442 - return 'Server error. Please try again later'; 443 - } 369 + // Handle error: treat null and empty string as no error 370 + final error = state.error; 371 + final hasError = error != null && error.isNotEmpty; 444 372 445 - // Fallback to generic message for unknown errors 446 - return 'Something went wrong. Please try again'; 373 + return FeedPage( 374 + feedType: feedType, 375 + posts: state.posts, 376 + isLoading: state.isLoading, 377 + isLoadingMore: state.isLoadingMore, 378 + error: hasError ? error : null, 379 + scrollController: _getOrCreateScrollController(feedType), 380 + onRefresh: () => provider.loadFeed(feedType, refresh: true), 381 + onRetry: () => provider.retry(feedType), 382 + onClearErrorAndLoadMore: () { 383 + provider.clearError(feedType); 384 + provider.loadMore(feedType); 385 + }, 386 + isAuthenticated: isAuthenticated, 387 + currentTime: provider.currentTime, 388 + ); 389 + }, 390 + ); 447 391 } 448 392 }