Main coves client
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}