Main coves client
1import 'package:flutter/material.dart';
2import 'package:provider/provider.dart';
3
4import '../../constants/app_colors.dart';
5import '../../providers/auth_provider.dart';
6import '../../providers/multi_feed_provider.dart';
7import '../../widgets/feed_page.dart';
8import '../../widgets/icons/bluesky_icons.dart';
9
10/// Header layout constants
11const double _kHeaderHeight = 44;
12const double _kTabUnderlineWidth = 28;
13const double _kTabUnderlineHeight = 3;
14
15class FeedScreen extends StatefulWidget {
16 const FeedScreen({super.key, this.onSearchTap});
17
18 /// Callback when search icon is tapped (to switch to communities tab)
19 final VoidCallback? onSearchTap;
20
21 @override
22 State<FeedScreen> createState() => _FeedScreenState();
23}
24
25class _FeedScreenState extends State<FeedScreen> {
26 late PageController _pageController;
27 final Map<FeedType, ScrollController> _scrollControllers = {};
28 late AuthProvider _authProvider;
29 bool _wasAuthenticated = false;
30
31 @override
32 void initState() {
33 super.initState();
34
35 // Initialize PageController
36 // Start on page 0 (Discover) or 1 (For You) based on current feed
37 final provider = context.read<MultiFeedProvider>();
38 final initialPage = provider.currentFeedType == FeedType.forYou ? 1 : 0;
39 _pageController = PageController(initialPage: initialPage);
40
41 // Save reference to AuthProvider for listener management
42 _authProvider = context.read<AuthProvider>();
43 _wasAuthenticated = _authProvider.isAuthenticated;
44
45 // Listen to auth changes to sync PageController with provider state
46 _authProvider.addListener(_onAuthChanged);
47
48 // Load initial feed after frame is built
49 WidgetsBinding.instance.addPostFrameCallback((_) {
50 if (mounted) {
51 _loadInitialFeed();
52 }
53 });
54 }
55
56 @override
57 void dispose() {
58 _authProvider.removeListener(_onAuthChanged);
59 _pageController.dispose();
60 for (final controller in _scrollControllers.values) {
61 controller.dispose();
62 }
63 super.dispose();
64 }
65
66 /// Handle auth state changes to sync PageController with provider
67 ///
68 /// When user signs out while on For You tab, the provider switches to
69 /// Discover but PageController stays on page 1. This listener ensures
70 /// they stay in sync.
71 void _onAuthChanged() {
72 final isAuthenticated = _authProvider.isAuthenticated;
73
74 // On sign-out: jump to Discover (page 0) to match provider state
75 if (_wasAuthenticated && !isAuthenticated) {
76 if (_pageController.hasClients && _pageController.page != 0) {
77 _pageController.jumpToPage(0);
78 }
79 }
80
81 _wasAuthenticated = isAuthenticated;
82 }
83
84 /// Load initial feed based on authentication
85 void _loadInitialFeed() {
86 final provider = context.read<MultiFeedProvider>();
87 final isAuthenticated = context.read<AuthProvider>().isAuthenticated;
88
89 // Load the current feed
90 provider.loadFeed(provider.currentFeedType, refresh: true);
91
92 // Preload the other feed if authenticated
93 if (isAuthenticated) {
94 final otherFeed =
95 provider.currentFeedType == FeedType.discover
96 ? FeedType.forYou
97 : FeedType.discover;
98 provider.loadFeed(otherFeed, refresh: true);
99 }
100 }
101
102 /// Get or create scroll controller for a feed type
103 ScrollController _getOrCreateScrollController(FeedType type) {
104 if (!_scrollControllers.containsKey(type)) {
105 final provider = context.read<MultiFeedProvider>();
106 final state = provider.getState(type);
107 _scrollControllers[type] = ScrollController(
108 initialScrollOffset: state.scrollPosition,
109 );
110 _scrollControllers[type]!.addListener(() => _onScroll(type));
111 }
112 return _scrollControllers[type]!;
113 }
114
115 /// Handle scroll events for pagination and scroll position saving
116 void _onScroll(FeedType type) {
117 final controller = _scrollControllers[type];
118 if (controller != null && controller.hasClients) {
119 // Save scroll position passively (no rebuild needed)
120 context.read<MultiFeedProvider>().saveScrollPosition(
121 type,
122 controller.position.pixels,
123 );
124
125 // Trigger pagination when near bottom
126 if (controller.position.pixels >=
127 controller.position.maxScrollExtent - 200) {
128 context.read<MultiFeedProvider>().loadMore(type);
129 }
130 }
131 }
132
133 @override
134 Widget build(BuildContext context) {
135 // Use select to only rebuild when specific fields change
136 final isAuthenticated = context.select<AuthProvider, bool>(
137 (p) => p.isAuthenticated,
138 );
139 final currentFeed = context.select<MultiFeedProvider, FeedType>(
140 (p) => p.currentFeedType,
141 );
142
143 return Scaffold(
144 backgroundColor: AppColors.background,
145 body: SafeArea(
146 child: Stack(
147 children: [
148 // Feed content with PageView for swipe navigation
149 _buildBody(isAuthenticated: isAuthenticated),
150 // Transparent header overlay
151 _buildHeader(
152 feedType: currentFeed,
153 isAuthenticated: isAuthenticated,
154 ),
155 ],
156 ),
157 ),
158 );
159 }
160
161 Widget _buildHeader({
162 required FeedType feedType,
163 required bool isAuthenticated,
164 }) {
165 return Container(
166 height: _kHeaderHeight,
167 decoration: BoxDecoration(
168 // Gradient fade from solid to transparent
169 gradient: LinearGradient(
170 begin: Alignment.topCenter,
171 end: Alignment.bottomCenter,
172 colors: [
173 AppColors.background,
174 AppColors.background.withValues(alpha: 0.8),
175 AppColors.background.withValues(alpha: 0),
176 ],
177 stops: const [0.0, 0.6, 1.0],
178 ),
179 ),
180 padding: const EdgeInsets.symmetric(horizontal: 16),
181 child: Row(
182 children: [
183 // Feed type tabs in the center
184 Expanded(
185 child: _buildFeedTypeTabs(
186 feedType: feedType,
187 isAuthenticated: isAuthenticated,
188 ),
189 ),
190 // Search/Communities icon on the right
191 if (widget.onSearchTap != null)
192 Semantics(
193 label: 'Navigate to Communities',
194 button: true,
195 child: InkWell(
196 onTap: widget.onSearchTap,
197 borderRadius: BorderRadius.circular(20),
198 splashColor: AppColors.primary.withValues(alpha: 0.2),
199 child: Padding(
200 padding: const EdgeInsets.all(8),
201 child: BlueSkyIcon.search(color: AppColors.textPrimary),
202 ),
203 ),
204 ),
205 ],
206 ),
207 );
208 }
209
210 Widget _buildFeedTypeTabs({
211 required FeedType feedType,
212 required bool isAuthenticated,
213 }) {
214 // If not authenticated, only show Discover
215 if (!isAuthenticated) {
216 return Center(
217 child: _buildFeedTypeTab(
218 label: 'Discover',
219 isActive: true,
220 onTap: null,
221 ),
222 );
223 }
224
225 // Authenticated: show both tabs side by side (TikTok style)
226 return Row(
227 mainAxisAlignment: MainAxisAlignment.center,
228 children: [
229 _buildFeedTypeTab(
230 label: 'Discover',
231 isActive: feedType == FeedType.discover,
232 onTap: () => _switchToFeedType(FeedType.discover, 0),
233 ),
234 const SizedBox(width: 24),
235 _buildFeedTypeTab(
236 label: 'For You',
237 isActive: feedType == FeedType.forYou,
238 onTap: () => _switchToFeedType(FeedType.forYou, 1),
239 ),
240 ],
241 );
242 }
243
244 Widget _buildFeedTypeTab({
245 required String label,
246 required bool isActive,
247 required VoidCallback? onTap,
248 }) {
249 return Semantics(
250 label: '$label feed${isActive ? ', selected' : ''}',
251 button: true,
252 selected: isActive,
253 child: GestureDetector(
254 onTap: onTap,
255 behavior: HitTestBehavior.opaque,
256 child: Column(
257 mainAxisSize: MainAxisSize.min,
258 mainAxisAlignment: MainAxisAlignment.center,
259 children: [
260 Text(
261 label,
262 style: TextStyle(
263 color:
264 isActive
265 ? AppColors.textPrimary
266 : AppColors.textSecondary.withValues(alpha: 0.6),
267 fontSize: 16,
268 fontWeight: isActive ? FontWeight.w700 : FontWeight.w400,
269 ),
270 ),
271 const SizedBox(height: 2),
272 // Underline indicator (TikTok style)
273 Container(
274 width: _kTabUnderlineWidth,
275 height: _kTabUnderlineHeight,
276 decoration: BoxDecoration(
277 color: isActive ? AppColors.textPrimary : Colors.transparent,
278 borderRadius: BorderRadius.circular(2),
279 ),
280 ),
281 ],
282 ),
283 ),
284 );
285 }
286
287 /// Switch to a feed type and animate PageView
288 void _switchToFeedType(FeedType type, int pageIndex) {
289 context.read<MultiFeedProvider>().setCurrentFeed(type);
290
291 // Animate to the corresponding page
292 _pageController.animateToPage(
293 pageIndex,
294 duration: const Duration(milliseconds: 300),
295 curve: Curves.easeInOut,
296 );
297
298 // Load the feed if it hasn't been loaded yet
299 _ensureFeedLoaded(type);
300
301 // Restore scroll position after page animation completes
302 _restoreScrollPosition(type);
303 }
304
305 /// Ensure a feed is loaded (trigger initial load if needed)
306 ///
307 /// Called when switching to a feed that may not have been loaded yet,
308 /// e.g., when user signs in after app start and taps "For You" tab.
309 void _ensureFeedLoaded(FeedType type) {
310 final provider = context.read<MultiFeedProvider>();
311 final state = provider.getState(type);
312
313 // If the feed has no posts and isn't currently loading, trigger a load
314 if (state.posts.isEmpty && !state.isLoading) {
315 provider.loadFeed(type, refresh: true);
316 }
317 }
318
319 /// Restore scroll position for a feed type
320 void _restoreScrollPosition(FeedType type) {
321 // Wait for the next frame to ensure the controller has clients
322 WidgetsBinding.instance.addPostFrameCallback((_) {
323 if (!mounted) {
324 return;
325 }
326
327 final controller = _scrollControllers[type];
328 if (controller != null && controller.hasClients) {
329 final provider = context.read<MultiFeedProvider>();
330 final savedPosition = provider.getState(type).scrollPosition;
331
332 // Only jump if the saved position differs from current
333 if ((controller.offset - savedPosition).abs() > 1) {
334 controller.jumpTo(savedPosition);
335 }
336 }
337 });
338 }
339
340 Widget _buildBody({required bool isAuthenticated}) {
341 // For unauthenticated users, show only Discover feed (no PageView)
342 if (!isAuthenticated) {
343 return _buildFeedPage(FeedType.discover, isAuthenticated);
344 }
345
346 // For authenticated users, use PageView for swipe navigation
347 return PageView(
348 controller: _pageController,
349 onPageChanged: (index) {
350 final type = index == 0 ? FeedType.discover : FeedType.forYou;
351 context.read<MultiFeedProvider>().setCurrentFeed(type);
352 // Load the feed if it hasn't been loaded yet
353 _ensureFeedLoaded(type);
354 // Restore scroll position when swiping between feeds
355 _restoreScrollPosition(type);
356 },
357 children: [
358 _buildFeedPage(FeedType.discover, isAuthenticated),
359 _buildFeedPage(FeedType.forYou, isAuthenticated),
360 ],
361 );
362 }
363
364 /// Build a FeedPage widget with all required state from provider
365 Widget _buildFeedPage(FeedType feedType, bool isAuthenticated) {
366 return Consumer<MultiFeedProvider>(
367 builder: (context, provider, _) {
368 final state = provider.getState(feedType);
369
370 // Handle error: treat null and empty string as no error
371 final error = state.error;
372 final hasError = error != null && error.isNotEmpty;
373
374 return FeedPage(
375 feedType: feedType,
376 posts: state.posts,
377 isLoading: state.isLoading,
378 isLoadingMore: state.isLoadingMore,
379 error: hasError ? error : null,
380 scrollController: _getOrCreateScrollController(feedType),
381 onRefresh: () => provider.loadFeed(feedType, refresh: true),
382 onRetry: () => provider.retry(feedType),
383 onClearErrorAndLoadMore:
384 () =>
385 provider
386 ..clearError(feedType)
387 ..loadMore(feedType),
388 isAuthenticated: isAuthenticated,
389 currentTime: provider.currentTime,
390 );
391 },
392 );
393 }
394}