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>( 77 (p) => p.feedType, 78 ); 79 80 // IMPORTANT: This relies on FeedProvider creating new list instances 81 // (_posts = [..._posts, ...response.feed]) rather than mutating in-place. 82 // context.select uses == for comparison, and Lists use reference equality, 83 // so in-place mutations (_posts.addAll(...)) would not trigger rebuilds. 84 final posts = context.select<FeedProvider, List<FeedViewPost>>( 85 (p) => p.posts, 86 ); 87 final isLoadingMore = context.select<FeedProvider, bool>( 88 (p) => p.isLoadingMore, 89 ); 90 final currentTime = context.select<FeedProvider, DateTime?>( 91 (p) => p.currentTime, 92 ); 93 94 return Scaffold( 95 backgroundColor: AppColors.background, 96 body: SafeArea( 97 child: Stack( 98 children: [ 99 // Feed content (behind header) 100 _buildBody( 101 isLoading: isLoading, 102 error: error, 103 posts: posts, 104 isLoadingMore: isLoadingMore, 105 isAuthenticated: isAuthenticated, 106 currentTime: currentTime, 107 ), 108 // Transparent header overlay 109 _buildHeader( 110 feedType: feedType, 111 isAuthenticated: isAuthenticated, 112 ), 113 ], 114 ), 115 ), 116 ); 117 } 118 119 Widget _buildHeader({ 120 required FeedType feedType, 121 required bool isAuthenticated, 122 }) { 123 return Container( 124 height: _kHeaderHeight, 125 decoration: BoxDecoration( 126 // Gradient fade from solid to transparent 127 gradient: LinearGradient( 128 begin: Alignment.topCenter, 129 end: Alignment.bottomCenter, 130 colors: [ 131 AppColors.background, 132 AppColors.background.withValues(alpha: 0.8), 133 AppColors.background.withValues(alpha: 0), 134 ], 135 stops: const [0.0, 0.6, 1.0], 136 ), 137 ), 138 padding: const EdgeInsets.symmetric(horizontal: 16), 139 child: Row( 140 children: [ 141 // Feed type tabs in the center 142 Expanded( 143 child: _buildFeedTypeTabs( 144 feedType: feedType, 145 isAuthenticated: isAuthenticated, 146 ), 147 ), 148 // Search/Communities icon on the right 149 if (widget.onSearchTap != null) 150 Semantics( 151 label: 'Navigate to Communities', 152 button: true, 153 child: InkWell( 154 onTap: widget.onSearchTap, 155 borderRadius: BorderRadius.circular(20), 156 splashColor: AppColors.primary.withValues(alpha: 0.2), 157 child: Padding( 158 padding: const EdgeInsets.all(8), 159 child: BlueSkyIcon.search(color: AppColors.textPrimary), 160 ), 161 ), 162 ), 163 ], 164 ), 165 ); 166 } 167 168 Widget _buildFeedTypeTabs({ 169 required FeedType feedType, 170 required bool isAuthenticated, 171 }) { 172 // If not authenticated, only show Discover 173 if (!isAuthenticated) { 174 return Center( 175 child: _buildFeedTypeTab( 176 label: 'Discover', 177 isActive: true, 178 onTap: null, 179 ), 180 ); 181 } 182 183 // Authenticated: show both tabs side by side (TikTok style) 184 return Row( 185 mainAxisAlignment: MainAxisAlignment.center, 186 children: [ 187 _buildFeedTypeTab( 188 label: 'Discover', 189 isActive: feedType == FeedType.discover, 190 onTap: () => _switchToFeedType(FeedType.discover), 191 ), 192 const SizedBox(width: 24), 193 _buildFeedTypeTab( 194 label: 'For You', 195 isActive: feedType == FeedType.forYou, 196 onTap: () => _switchToFeedType(FeedType.forYou), 197 ), 198 ], 199 ); 200 } 201 202 Widget _buildFeedTypeTab({ 203 required String label, 204 required bool isActive, 205 required VoidCallback? onTap, 206 }) { 207 return Semantics( 208 label: '$label feed${isActive ? ', selected' : ''}', 209 button: true, 210 selected: isActive, 211 child: GestureDetector( 212 onTap: onTap, 213 behavior: HitTestBehavior.opaque, 214 child: Column( 215 mainAxisSize: MainAxisSize.min, 216 mainAxisAlignment: MainAxisAlignment.center, 217 children: [ 218 Text( 219 label, 220 style: TextStyle( 221 color: isActive 222 ? AppColors.textPrimary 223 : AppColors.textSecondary.withValues(alpha: 0.6), 224 fontSize: 16, 225 fontWeight: isActive ? FontWeight.w700 : FontWeight.w400, 226 ), 227 ), 228 const SizedBox(height: 2), 229 // Underline indicator (TikTok style) 230 Container( 231 width: _kTabUnderlineWidth, 232 height: _kTabUnderlineHeight, 233 decoration: BoxDecoration( 234 color: isActive ? AppColors.textPrimary : Colors.transparent, 235 borderRadius: BorderRadius.circular(2), 236 ), 237 ), 238 ], 239 ), 240 ), 241 ); 242 } 243 244 void _switchToFeedType(FeedType type) { 245 Provider.of<FeedProvider>(context, listen: false).setFeedType(type); 246 } 247 248 Widget _buildBody({ 249 required bool isLoading, 250 required String? error, 251 required List<FeedViewPost> posts, 252 required bool isLoadingMore, 253 required bool isAuthenticated, 254 required DateTime? currentTime, 255 }) { 256 // Loading state (only show full-screen loader for initial load, 257 // not refresh) 258 if (isLoading && posts.isEmpty) { 259 return const Center( 260 child: CircularProgressIndicator(color: AppColors.primary), 261 ); 262 } 263 264 // Error state (only show full-screen error when no posts loaded 265 // yet). If we have posts but pagination failed, we'll show the error 266 // at the bottom 267 if (error != null && posts.isEmpty) { 268 return Center( 269 child: Padding( 270 padding: const EdgeInsets.all(24), 271 child: Column( 272 mainAxisAlignment: MainAxisAlignment.center, 273 children: [ 274 const Icon( 275 Icons.error_outline, 276 size: 64, 277 color: AppColors.primary, 278 ), 279 const SizedBox(height: 16), 280 const Text( 281 'Failed to load feed', 282 style: TextStyle( 283 fontSize: 20, 284 color: AppColors.textPrimary, 285 fontWeight: FontWeight.bold, 286 ), 287 ), 288 const SizedBox(height: 8), 289 Text( 290 _getUserFriendlyError(error), 291 style: const TextStyle( 292 fontSize: 14, 293 color: AppColors.textSecondary, 294 ), 295 textAlign: TextAlign.center, 296 ), 297 const SizedBox(height: 24), 298 ElevatedButton( 299 onPressed: () { 300 Provider.of<FeedProvider>(context, listen: false).retry(); 301 }, 302 style: ElevatedButton.styleFrom( 303 backgroundColor: AppColors.primary, 304 ), 305 child: const Text('Retry'), 306 ), 307 ], 308 ), 309 ), 310 ); 311 } 312 313 // Empty state 314 if (posts.isEmpty) { 315 return Center( 316 child: Padding( 317 padding: const EdgeInsets.all(24), 318 child: Column( 319 mainAxisAlignment: MainAxisAlignment.center, 320 children: [ 321 const Icon(Icons.forum, size: 64, color: AppColors.primary), 322 const SizedBox(height: 24), 323 Text( 324 isAuthenticated ? 'No posts yet' : 'No posts to discover', 325 style: const TextStyle( 326 fontSize: 20, 327 color: AppColors.textPrimary, 328 fontWeight: FontWeight.bold, 329 ), 330 ), 331 const SizedBox(height: 8), 332 Text( 333 isAuthenticated 334 ? 'Subscribe to communities to see posts in your feed' 335 : 'Check back later for new posts', 336 style: const TextStyle( 337 fontSize: 14, 338 color: AppColors.textSecondary, 339 ), 340 textAlign: TextAlign.center, 341 ), 342 ], 343 ), 344 ), 345 ); 346 } 347 348 // Posts list 349 return RefreshIndicator( 350 onRefresh: _onRefresh, 351 color: AppColors.primary, 352 child: ListView.builder( 353 controller: _scrollController, 354 // Add top padding so content isn't hidden behind transparent header 355 padding: const EdgeInsets.only(top: _kHeaderContentPadding), 356 // Add extra item for loading indicator or pagination error 357 itemCount: posts.length + (isLoadingMore || error != null ? 1 : 0), 358 itemBuilder: (context, index) { 359 // Footer: loading indicator or error message 360 if (index == posts.length) { 361 // Show loading indicator for pagination 362 if (isLoadingMore) { 363 return const Center( 364 child: Padding( 365 padding: EdgeInsets.all(16), 366 child: CircularProgressIndicator(color: AppColors.primary), 367 ), 368 ); 369 } 370 // Show error message for pagination failures 371 if (error != null) { 372 return Container( 373 margin: const EdgeInsets.all(16), 374 padding: const EdgeInsets.all(16), 375 decoration: BoxDecoration( 376 color: AppColors.background, 377 borderRadius: BorderRadius.circular(8), 378 border: Border.all(color: AppColors.primary), 379 ), 380 child: Column( 381 children: [ 382 const Icon( 383 Icons.error_outline, 384 color: AppColors.primary, 385 size: 32, 386 ), 387 const SizedBox(height: 8), 388 Text( 389 _getUserFriendlyError(error), 390 style: const TextStyle( 391 color: AppColors.textSecondary, 392 fontSize: 14, 393 ), 394 textAlign: TextAlign.center, 395 ), 396 const SizedBox(height: 12), 397 TextButton( 398 onPressed: () { 399 Provider.of<FeedProvider>(context, listen: false) 400 ..clearError() 401 ..loadMore(); 402 }, 403 style: TextButton.styleFrom( 404 foregroundColor: AppColors.primary, 405 ), 406 child: const Text('Retry'), 407 ), 408 ], 409 ), 410 ); 411 } 412 } 413 414 final post = posts[index]; 415 return Semantics( 416 label: 417 'Feed post in ${post.post.community.name} by ' 418 '${post.post.author.displayName ?? post.post.author.handle}. ' 419 '${post.post.title ?? ""}', 420 button: true, 421 child: PostCard(post: post, currentTime: currentTime), 422 ); 423 }, 424 ), 425 ); 426 } 427 428 /// Transform technical error messages into user-friendly ones 429 String _getUserFriendlyError(String error) { 430 final lowerError = error.toLowerCase(); 431 432 if (lowerError.contains('socketexception') || 433 lowerError.contains('network') || 434 lowerError.contains('connection refused')) { 435 return 'Please check your internet connection'; 436 } else if (lowerError.contains('timeoutexception') || 437 lowerError.contains('timeout')) { 438 return 'Request timed out. Please try again'; 439 } else if (lowerError.contains('401') || 440 lowerError.contains('unauthorized')) { 441 return 'Authentication failed. Please sign in again'; 442 } else if (lowerError.contains('404') || lowerError.contains('not found')) { 443 return 'Content not found'; 444 } else if (lowerError.contains('500') || 445 lowerError.contains('internal server')) { 446 return 'Server error. Please try again later'; 447 } 448 449 // Fallback to generic message for unknown errors 450 return 'Something went wrong. Please try again'; 451 } 452}