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/post_card.dart'; 9 10class FeedScreen extends StatefulWidget { 11 const FeedScreen({super.key}); 12 13 @override 14 State<FeedScreen> createState() => _FeedScreenState(); 15} 16 17class _FeedScreenState extends State<FeedScreen> { 18 final ScrollController _scrollController = ScrollController(); 19 20 @override 21 void initState() { 22 super.initState(); 23 _scrollController.addListener(_onScroll); 24 25 // Fetch feed after frame is built 26 WidgetsBinding.instance.addPostFrameCallback((_) { 27 // Check if widget is still mounted before loading 28 if (mounted) { 29 _loadFeed(); 30 } 31 }); 32 } 33 34 @override 35 void dispose() { 36 _scrollController.dispose(); 37 super.dispose(); 38 } 39 40 /// Load feed - business logic is now in FeedProvider 41 void _loadFeed() { 42 Provider.of<FeedProvider>(context, listen: false).loadFeed(refresh: true); 43 } 44 45 void _onScroll() { 46 if (_scrollController.position.pixels >= 47 _scrollController.position.maxScrollExtent - 200) { 48 Provider.of<FeedProvider>(context, listen: false).loadMore(); 49 } 50 } 51 52 Future<void> _onRefresh() async { 53 final feedProvider = Provider.of<FeedProvider>(context, listen: false); 54 await feedProvider.loadFeed(refresh: true); 55 } 56 57 @override 58 Widget build(BuildContext context) { 59 // Optimized: Use select to only rebuild when specific fields change 60 // This prevents unnecessary rebuilds when unrelated provider fields change 61 final isAuthenticated = context.select<AuthProvider, bool>( 62 (p) => p.isAuthenticated, 63 ); 64 final isLoading = context.select<FeedProvider, bool>((p) => p.isLoading); 65 final error = context.select<FeedProvider, String?>((p) => p.error); 66 67 // IMPORTANT: This relies on FeedProvider creating new list instances 68 // (_posts = [..._posts, ...response.feed]) rather than mutating in-place. 69 // context.select uses == for comparison, and Lists use reference equality, 70 // so in-place mutations (_posts.addAll(...)) would not trigger rebuilds. 71 final posts = context.select<FeedProvider, List<FeedViewPost>>( 72 (p) => p.posts, 73 ); 74 final isLoadingMore = context.select<FeedProvider, bool>( 75 (p) => p.isLoadingMore, 76 ); 77 final currentTime = context.select<FeedProvider, DateTime?>( 78 (p) => p.currentTime, 79 ); 80 81 return Scaffold( 82 backgroundColor: AppColors.background, 83 appBar: AppBar( 84 backgroundColor: AppColors.background, 85 foregroundColor: AppColors.textPrimary, 86 title: Text(isAuthenticated ? 'Feed' : 'Explore'), 87 automaticallyImplyLeading: false, 88 ), 89 body: SafeArea( 90 child: _buildBody( 91 isLoading: isLoading, 92 error: error, 93 posts: posts, 94 isLoadingMore: isLoadingMore, 95 isAuthenticated: isAuthenticated, 96 currentTime: currentTime, 97 ), 98 ), 99 ); 100 } 101 102 Widget _buildBody({ 103 required bool isLoading, 104 required String? error, 105 required List<FeedViewPost> posts, 106 required bool isLoadingMore, 107 required bool isAuthenticated, 108 required DateTime? currentTime, 109 }) { 110 // Loading state (only show full-screen loader for initial load, 111 // not refresh) 112 if (isLoading && posts.isEmpty) { 113 return const Center( 114 child: CircularProgressIndicator(color: AppColors.primary), 115 ); 116 } 117 118 // Error state (only show full-screen error when no posts loaded 119 // yet). If we have posts but pagination failed, we'll show the error 120 // at the bottom 121 if (error != null && posts.isEmpty) { 122 return Center( 123 child: Padding( 124 padding: const EdgeInsets.all(24), 125 child: Column( 126 mainAxisAlignment: MainAxisAlignment.center, 127 children: [ 128 const Icon( 129 Icons.error_outline, 130 size: 64, 131 color: AppColors.primary, 132 ), 133 const SizedBox(height: 16), 134 const Text( 135 'Failed to load feed', 136 style: TextStyle( 137 fontSize: 20, 138 color: AppColors.textPrimary, 139 fontWeight: FontWeight.bold, 140 ), 141 ), 142 const SizedBox(height: 8), 143 Text( 144 _getUserFriendlyError(error), 145 style: const TextStyle( 146 fontSize: 14, 147 color: AppColors.textSecondary, 148 ), 149 textAlign: TextAlign.center, 150 ), 151 const SizedBox(height: 24), 152 ElevatedButton( 153 onPressed: () { 154 Provider.of<FeedProvider>(context, listen: false).retry(); 155 }, 156 style: ElevatedButton.styleFrom( 157 backgroundColor: AppColors.primary, 158 ), 159 child: const Text('Retry'), 160 ), 161 ], 162 ), 163 ), 164 ); 165 } 166 167 // Empty state 168 if (posts.isEmpty) { 169 return Center( 170 child: Padding( 171 padding: const EdgeInsets.all(24), 172 child: Column( 173 mainAxisAlignment: MainAxisAlignment.center, 174 children: [ 175 const Icon(Icons.forum, size: 64, color: AppColors.primary), 176 const SizedBox(height: 24), 177 Text( 178 isAuthenticated ? 'No posts yet' : 'No posts to discover', 179 style: const TextStyle( 180 fontSize: 20, 181 color: AppColors.textPrimary, 182 fontWeight: FontWeight.bold, 183 ), 184 ), 185 const SizedBox(height: 8), 186 Text( 187 isAuthenticated 188 ? 'Subscribe to communities to see posts in your feed' 189 : 'Check back later for new posts', 190 style: const TextStyle( 191 fontSize: 14, 192 color: AppColors.textSecondary, 193 ), 194 textAlign: TextAlign.center, 195 ), 196 ], 197 ), 198 ), 199 ); 200 } 201 202 // Posts list 203 return RefreshIndicator( 204 onRefresh: _onRefresh, 205 color: AppColors.primary, 206 child: ListView.builder( 207 controller: _scrollController, 208 // Add extra item for loading indicator or pagination error 209 itemCount: posts.length + (isLoadingMore || error != null ? 1 : 0), 210 itemBuilder: (context, index) { 211 // Footer: loading indicator or error message 212 if (index == posts.length) { 213 // Show loading indicator for pagination 214 if (isLoadingMore) { 215 return const Center( 216 child: Padding( 217 padding: EdgeInsets.all(16), 218 child: CircularProgressIndicator(color: AppColors.primary), 219 ), 220 ); 221 } 222 // Show error message for pagination failures 223 if (error != null) { 224 return Container( 225 margin: const EdgeInsets.all(16), 226 padding: const EdgeInsets.all(16), 227 decoration: BoxDecoration( 228 color: AppColors.background, 229 borderRadius: BorderRadius.circular(8), 230 border: Border.all(color: AppColors.primary), 231 ), 232 child: Column( 233 children: [ 234 const Icon( 235 Icons.error_outline, 236 color: AppColors.primary, 237 size: 32, 238 ), 239 const SizedBox(height: 8), 240 Text( 241 _getUserFriendlyError(error), 242 style: const TextStyle( 243 color: AppColors.textSecondary, 244 fontSize: 14, 245 ), 246 textAlign: TextAlign.center, 247 ), 248 const SizedBox(height: 12), 249 TextButton( 250 onPressed: () { 251 Provider.of<FeedProvider>(context, listen: false) 252 ..clearError() 253 ..loadMore(); 254 }, 255 style: TextButton.styleFrom( 256 foregroundColor: AppColors.primary, 257 ), 258 child: const Text('Retry'), 259 ), 260 ], 261 ), 262 ); 263 } 264 } 265 266 final post = posts[index]; 267 return Semantics( 268 label: 269 'Feed post in ${post.post.community.name} by ' 270 '${post.post.author.displayName ?? post.post.author.handle}. ' 271 '${post.post.title ?? ""}', 272 button: true, 273 child: PostCard(post: post, currentTime: currentTime), 274 ); 275 }, 276 ), 277 ); 278 } 279 280 /// Transform technical error messages into user-friendly ones 281 String _getUserFriendlyError(String error) { 282 final lowerError = error.toLowerCase(); 283 284 if (lowerError.contains('socketexception') || 285 lowerError.contains('network') || 286 lowerError.contains('connection refused')) { 287 return 'Please check your internet connection'; 288 } else if (lowerError.contains('timeoutexception') || 289 lowerError.contains('timeout')) { 290 return 'Request timed out. Please try again'; 291 } else if (lowerError.contains('401') || 292 lowerError.contains('unauthorized')) { 293 return 'Authentication failed. Please sign in again'; 294 } else if (lowerError.contains('404') || lowerError.contains('not found')) { 295 return 'Content not found'; 296 } else if (lowerError.contains('500') || 297 lowerError.contains('internal server')) { 298 return 'Server error. Please try again later'; 299 } 300 301 // Fallback to generic message for unknown errors 302 return 'Something went wrong. Please try again'; 303 } 304}