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