at main 13 kB view raw
1import 'package:flutter/material.dart'; 2import 'package:provider/provider.dart'; 3 4import '../../constants/app_colors.dart'; 5import '../../providers/auth_provider.dart'; 6import '../../providers/multi_feed_provider.dart'; 7import '../../widgets/feed_page.dart'; 8import '../../widgets/icons/bluesky_icons.dart'; 9 10/// Header layout constants 11const double _kHeaderHeight = 44; 12const double _kTabUnderlineWidth = 28; 13const double _kTabUnderlineHeight = 3; 14 15class FeedScreen extends StatefulWidget { 16 const FeedScreen({super.key, this.onSearchTap}); 17 18 /// Callback when search icon is tapped (to switch to communities tab) 19 final VoidCallback? onSearchTap; 20 21 @override 22 State<FeedScreen> createState() => _FeedScreenState(); 23} 24 25class _FeedScreenState extends State<FeedScreen> { 26 late PageController _pageController; 27 final Map<FeedType, ScrollController> _scrollControllers = {}; 28 late AuthProvider _authProvider; 29 bool _wasAuthenticated = false; 30 31 @override 32 void initState() { 33 super.initState(); 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; 44 45 // Listen to auth changes to sync PageController with provider state 46 _authProvider.addListener(_onAuthChanged); 47 48 // Load initial feed after frame is built 49 WidgetsBinding.instance.addPostFrameCallback((_) { 50 if (mounted) { 51 _loadInitialFeed(); 52 } 53 }); 54 } 55 56 @override 57 void dispose() { 58 _authProvider.removeListener(_onAuthChanged); 59 _pageController.dispose(); 60 for (final controller in _scrollControllers.values) { 61 controller.dispose(); 62 } 63 super.dispose(); 64 } 65 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; 82 } 83 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); 99 } 100 } 101 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 } 131 } 132 133 @override 134 Widget build(BuildContext context) { 135 // Use select to only rebuild when specific fields change 136 final isAuthenticated = context.select<AuthProvider, bool>( 137 (p) => p.isAuthenticated, 138 ); 139 final currentFeed = context.select<MultiFeedProvider, FeedType>( 140 (p) => p.currentFeedType, 141 ); 142 143 return Scaffold( 144 backgroundColor: AppColors.background, 145 body: SafeArea( 146 child: Stack( 147 children: [ 148 // Feed content with PageView for swipe navigation 149 _buildBody(isAuthenticated: isAuthenticated), 150 // Transparent header overlay 151 _buildHeader( 152 feedType: currentFeed, 153 isAuthenticated: isAuthenticated, 154 ), 155 ], 156 ), 157 ), 158 ); 159 } 160 161 Widget _buildHeader({ 162 required FeedType feedType, 163 required bool isAuthenticated, 164 }) { 165 return Container( 166 height: _kHeaderHeight, 167 decoration: BoxDecoration( 168 // Gradient fade from solid to transparent 169 gradient: LinearGradient( 170 begin: Alignment.topCenter, 171 end: Alignment.bottomCenter, 172 colors: [ 173 AppColors.background, 174 AppColors.background.withValues(alpha: 0.8), 175 AppColors.background.withValues(alpha: 0), 176 ], 177 stops: const [0.0, 0.6, 1.0], 178 ), 179 ), 180 padding: const EdgeInsets.symmetric(horizontal: 16), 181 child: Row( 182 children: [ 183 // Feed type tabs in the center 184 Expanded( 185 child: _buildFeedTypeTabs( 186 feedType: feedType, 187 isAuthenticated: isAuthenticated, 188 ), 189 ), 190 // Search/Communities icon on the right 191 if (widget.onSearchTap != null) 192 Semantics( 193 label: 'Navigate to Communities', 194 button: true, 195 child: InkWell( 196 onTap: widget.onSearchTap, 197 borderRadius: BorderRadius.circular(20), 198 splashColor: AppColors.primary.withValues(alpha: 0.2), 199 child: Padding( 200 padding: const EdgeInsets.all(8), 201 child: BlueSkyIcon.search(color: AppColors.textPrimary), 202 ), 203 ), 204 ), 205 ], 206 ), 207 ); 208 } 209 210 Widget _buildFeedTypeTabs({ 211 required FeedType feedType, 212 required bool isAuthenticated, 213 }) { 214 // If not authenticated, only show Discover 215 if (!isAuthenticated) { 216 return Center( 217 child: _buildFeedTypeTab( 218 label: 'Discover', 219 isActive: true, 220 onTap: null, 221 ), 222 ); 223 } 224 225 // Authenticated: show both tabs side by side (TikTok style) 226 return Row( 227 mainAxisAlignment: MainAxisAlignment.center, 228 children: [ 229 _buildFeedTypeTab( 230 label: 'Discover', 231 isActive: feedType == FeedType.discover, 232 onTap: () => _switchToFeedType(FeedType.discover, 0), 233 ), 234 const SizedBox(width: 24), 235 _buildFeedTypeTab( 236 label: 'For You', 237 isActive: feedType == FeedType.forYou, 238 onTap: () => _switchToFeedType(FeedType.forYou, 1), 239 ), 240 ], 241 ); 242 } 243 244 Widget _buildFeedTypeTab({ 245 required String label, 246 required bool isActive, 247 required VoidCallback? onTap, 248 }) { 249 return Semantics( 250 label: '$label feed${isActive ? ', selected' : ''}', 251 button: true, 252 selected: isActive, 253 child: GestureDetector( 254 onTap: onTap, 255 behavior: HitTestBehavior.opaque, 256 child: Column( 257 mainAxisSize: MainAxisSize.min, 258 mainAxisAlignment: MainAxisAlignment.center, 259 children: [ 260 Text( 261 label, 262 style: TextStyle( 263 color: 264 isActive 265 ? AppColors.textPrimary 266 : AppColors.textSecondary.withValues(alpha: 0.6), 267 fontSize: 16, 268 fontWeight: isActive ? FontWeight.w700 : FontWeight.w400, 269 ), 270 ), 271 const SizedBox(height: 2), 272 // Underline indicator (TikTok style) 273 Container( 274 width: _kTabUnderlineWidth, 275 height: _kTabUnderlineHeight, 276 decoration: BoxDecoration( 277 color: isActive ? AppColors.textPrimary : Colors.transparent, 278 borderRadius: BorderRadius.circular(2), 279 ), 280 ), 281 ], 282 ), 283 ), 284 ); 285 } 286 287 /// Switch to a feed type and animate PageView 288 void _switchToFeedType(FeedType type, int pageIndex) { 289 context.read<MultiFeedProvider>().setCurrentFeed(type); 290 291 // Animate to the corresponding page 292 _pageController.animateToPage( 293 pageIndex, 294 duration: const Duration(milliseconds: 300), 295 curve: Curves.easeInOut, 296 ); 297 298 // Load the feed if it hasn't been loaded yet 299 _ensureFeedLoaded(type); 300 301 // Restore scroll position after page animation completes 302 _restoreScrollPosition(type); 303 } 304 305 /// Ensure a feed is loaded (trigger initial load if needed) 306 /// 307 /// Called when switching to a feed that may not have been loaded yet, 308 /// e.g., when user signs in after app start and taps "For You" tab. 309 void _ensureFeedLoaded(FeedType type) { 310 final provider = context.read<MultiFeedProvider>(); 311 final state = provider.getState(type); 312 313 // If the feed has no posts and isn't currently loading, trigger a load 314 if (state.posts.isEmpty && !state.isLoading) { 315 provider.loadFeed(type, refresh: true); 316 } 317 } 318 319 /// Restore scroll position for a feed type 320 void _restoreScrollPosition(FeedType type) { 321 // Wait for the next frame to ensure the controller has clients 322 WidgetsBinding.instance.addPostFrameCallback((_) { 323 if (!mounted) { 324 return; 325 } 326 327 final controller = _scrollControllers[type]; 328 if (controller != null && controller.hasClients) { 329 final provider = context.read<MultiFeedProvider>(); 330 final savedPosition = provider.getState(type).scrollPosition; 331 332 // Only jump if the saved position differs from current 333 if ((controller.offset - savedPosition).abs() > 1) { 334 controller.jumpTo(savedPosition); 335 } 336 } 337 }); 338 } 339 340 Widget _buildBody({required bool isAuthenticated}) { 341 // For unauthenticated users, show only Discover feed (no PageView) 342 if (!isAuthenticated) { 343 return _buildFeedPage(FeedType.discover, isAuthenticated); 344 } 345 346 // For authenticated users, use PageView for swipe navigation 347 return PageView( 348 controller: _pageController, 349 onPageChanged: (index) { 350 final type = index == 0 ? FeedType.discover : FeedType.forYou; 351 context.read<MultiFeedProvider>().setCurrentFeed(type); 352 // Load the feed if it hasn't been loaded yet 353 _ensureFeedLoaded(type); 354 // Restore scroll position when swiping between feeds 355 _restoreScrollPosition(type); 356 }, 357 children: [ 358 _buildFeedPage(FeedType.discover, isAuthenticated), 359 _buildFeedPage(FeedType.forYou, isAuthenticated), 360 ], 361 ); 362 } 363 364 /// Build a FeedPage widget with all required state from provider 365 Widget _buildFeedPage(FeedType feedType, bool isAuthenticated) { 366 return Consumer<MultiFeedProvider>( 367 builder: (context, provider, _) { 368 final state = provider.getState(feedType); 369 370 // Handle error: treat null and empty string as no error 371 final error = state.error; 372 final hasError = error != null && error.isNotEmpty; 373 374 return FeedPage( 375 feedType: feedType, 376 posts: state.posts, 377 isLoading: state.isLoading, 378 isLoadingMore: state.isLoadingMore, 379 error: hasError ? error : null, 380 scrollController: _getOrCreateScrollController(feedType), 381 onRefresh: () => provider.loadFeed(feedType, refresh: true), 382 onRetry: () => provider.retry(feedType), 383 onClearErrorAndLoadMore: 384 () => 385 provider 386 ..clearError(feedType) 387 ..loadMore(feedType), 388 isAuthenticated: isAuthenticated, 389 currentTime: provider.currentTime, 390 ); 391 }, 392 ); 393 } 394}