1import 'package:flutter/material.dart'; 2import 'package:provider/provider.dart'; 3 4import '../../constants/app_colors.dart'; 5import '../../models/post.dart'; 6import '../../providers/auth_provider.dart'; 7import '../../providers/feed_provider.dart'; 8import '../../widgets/icons/bluesky_icons.dart'; 9import '../../widgets/post_card.dart'; 10 11/// Header layout constants 12const double _kHeaderHeight = 44; 13const double _kTabUnderlineWidth = 28; 14const double _kTabUnderlineHeight = 3; 15const double _kHeaderContentPadding = _kHeaderHeight; 16 17class FeedScreen extends StatefulWidget { 18 const FeedScreen({super.key, this.onSearchTap}); 19 20 /// Callback when search icon is tapped (to switch to communities tab) 21 final VoidCallback? onSearchTap; 22 23 @override 24 State<FeedScreen> createState() => _FeedScreenState(); 25} 26 27class _FeedScreenState extends State<FeedScreen> { 28 final ScrollController _scrollController = ScrollController(); 29 30 @override 31 void initState() { 32 super.initState(); 33 _scrollController.addListener(_onScroll); 34 35 // Fetch feed after frame is built 36 WidgetsBinding.instance.addPostFrameCallback((_) { 37 // Check if widget is still mounted before loading 38 if (mounted) { 39 _loadFeed(); 40 } 41 }); 42 } 43 44 @override 45 void dispose() { 46 _scrollController.dispose(); 47 super.dispose(); 48 } 49 50 /// Load feed - business logic is now in FeedProvider 51 void _loadFeed() { 52 Provider.of<FeedProvider>(context, listen: false).loadFeed(refresh: true); 53 } 54 55 void _onScroll() { 56 if (_scrollController.position.pixels >= 57 _scrollController.position.maxScrollExtent - 200) { 58 Provider.of<FeedProvider>(context, listen: false).loadMore(); 59 } 60 } 61 62 Future<void> _onRefresh() async { 63 final feedProvider = Provider.of<FeedProvider>(context, listen: false); 64 await feedProvider.loadFeed(refresh: true); 65 } 66 67 @override 68 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 71 final isAuthenticated = context.select<AuthProvider, bool>( 72 (p) => p.isAuthenticated, 73 ); 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, 90 ); 91 92 return Scaffold( 93 backgroundColor: AppColors.background, 94 body: SafeArea( 95 child: Stack( 96 children: [ 97 // Feed content (behind header) 98 _buildBody( 99 isLoading: isLoading, 100 error: error, 101 posts: posts, 102 isLoadingMore: isLoadingMore, 103 isAuthenticated: isAuthenticated, 104 currentTime: currentTime, 105 ), 106 // Transparent header overlay 107 _buildHeader(feedType: feedType, isAuthenticated: isAuthenticated), 108 ], 109 ), 110 ), 111 ); 112 } 113 114 Widget _buildHeader({ 115 required FeedType feedType, 116 required bool isAuthenticated, 117 }) { 118 return Container( 119 height: _kHeaderHeight, 120 decoration: BoxDecoration( 121 // Gradient fade from solid to transparent 122 gradient: LinearGradient( 123 begin: Alignment.topCenter, 124 end: Alignment.bottomCenter, 125 colors: [ 126 AppColors.background, 127 AppColors.background.withValues(alpha: 0.8), 128 AppColors.background.withValues(alpha: 0), 129 ], 130 stops: const [0.0, 0.6, 1.0], 131 ), 132 ), 133 padding: const EdgeInsets.symmetric(horizontal: 16), 134 child: Row( 135 children: [ 136 // Feed type tabs in the center 137 Expanded( 138 child: _buildFeedTypeTabs( 139 feedType: feedType, 140 isAuthenticated: isAuthenticated, 141 ), 142 ), 143 // Search/Communities icon on the right 144 if (widget.onSearchTap != null) 145 Semantics( 146 label: 'Navigate to Communities', 147 button: true, 148 child: InkWell( 149 onTap: widget.onSearchTap, 150 borderRadius: BorderRadius.circular(20), 151 splashColor: AppColors.primary.withValues(alpha: 0.2), 152 child: Padding( 153 padding: const EdgeInsets.all(8), 154 child: BlueSkyIcon.search(color: AppColors.textPrimary), 155 ), 156 ), 157 ), 158 ], 159 ), 160 ); 161 } 162 163 Widget _buildFeedTypeTabs({ 164 required FeedType feedType, 165 required bool isAuthenticated, 166 }) { 167 // If not authenticated, only show Discover 168 if (!isAuthenticated) { 169 return Center( 170 child: _buildFeedTypeTab( 171 label: 'Discover', 172 isActive: true, 173 onTap: null, 174 ), 175 ); 176 } 177 178 // Authenticated: show both tabs side by side (TikTok style) 179 return Row( 180 mainAxisAlignment: MainAxisAlignment.center, 181 children: [ 182 _buildFeedTypeTab( 183 label: 'Discover', 184 isActive: feedType == FeedType.discover, 185 onTap: () => _switchToFeedType(FeedType.discover), 186 ), 187 const SizedBox(width: 24), 188 _buildFeedTypeTab( 189 label: 'For You', 190 isActive: feedType == FeedType.forYou, 191 onTap: () => _switchToFeedType(FeedType.forYou), 192 ), 193 ], 194 ); 195 } 196 197 Widget _buildFeedTypeTab({ 198 required String label, 199 required bool isActive, 200 required VoidCallback? onTap, 201 }) { 202 return Semantics( 203 label: '$label feed${isActive ? ', selected' : ''}', 204 button: true, 205 selected: isActive, 206 child: GestureDetector( 207 onTap: onTap, 208 behavior: HitTestBehavior.opaque, 209 child: Column( 210 mainAxisSize: MainAxisSize.min, 211 mainAxisAlignment: MainAxisAlignment.center, 212 children: [ 213 Text( 214 label, 215 style: TextStyle( 216 color: 217 isActive 218 ? AppColors.textPrimary 219 : AppColors.textSecondary.withValues(alpha: 0.6), 220 fontSize: 16, 221 fontWeight: isActive ? FontWeight.w700 : FontWeight.w400, 222 ), 223 ), 224 const SizedBox(height: 2), 225 // Underline indicator (TikTok style) 226 Container( 227 width: _kTabUnderlineWidth, 228 height: _kTabUnderlineHeight, 229 decoration: BoxDecoration( 230 color: isActive ? AppColors.textPrimary : Colors.transparent, 231 borderRadius: BorderRadius.circular(2), 232 ), 233 ), 234 ], 235 ), 236 ), 237 ); 238 } 239 240 void _switchToFeedType(FeedType type) { 241 Provider.of<FeedProvider>(context, listen: false).setFeedType(type); 242 } 243 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 } 259 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 ); 307 } 308 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 } 343 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 } 409 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 ), 421 ); 422 } 423 424 /// Transform technical error messages into user-friendly ones 425 String _getUserFriendlyError(String error) { 426 final lowerError = error.toLowerCase(); 427 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 } 444 445 // Fallback to generic message for unknown errors 446 return 'Something went wrong. Please try again'; 447 } 448}