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/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}