···
import 'package:provider/provider.dart';
import '../../constants/app_colors.dart';
5
-
import '../../models/post.dart';
import '../../providers/auth_provider.dart';
7
-
import '../../providers/feed_provider.dart';
6
+
import '../../providers/multi_feed_provider.dart';
7
+
import '../../widgets/feed_page.dart';
import '../../widgets/icons/bluesky_icons.dart';
9
-
import '../../widgets/post_card.dart';
/// Header layout constants
const double _kHeaderHeight = 44;
const double _kTabUnderlineWidth = 28;
const double _kTabUnderlineHeight = 3;
15
-
const double _kHeaderContentPadding = _kHeaderHeight;
class FeedScreen extends StatefulWidget {
const FeedScreen({super.key, this.onSearchTap});
···
class _FeedScreenState extends State<FeedScreen> {
28
-
final ScrollController _scrollController = ScrollController();
26
+
late PageController _pageController;
27
+
final Map<FeedType, ScrollController> _scrollControllers = {};
28
+
late AuthProvider _authProvider;
29
+
bool _wasAuthenticated = false;
33
-
_scrollController.addListener(_onScroll);
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);
41
+
// Save reference to AuthProvider for listener management
42
+
_authProvider = context.read<AuthProvider>();
43
+
_wasAuthenticated = _authProvider.isAuthenticated;
35
-
// Fetch feed after frame is built
45
+
// Listen to auth changes to sync PageController with provider state
46
+
_authProvider.addListener(_onAuthChanged);
48
+
// Load initial feed after frame is built
WidgetsBinding.instance.addPostFrameCallback((_) {
37
-
// Check if widget is still mounted before loading
46
-
_scrollController.dispose();
58
+
_authProvider.removeListener(_onAuthChanged);
59
+
_pageController.dispose();
60
+
for (final controller in _scrollControllers.values) {
61
+
controller.dispose();
50
-
/// Load feed - business logic is now in FeedProvider
52
-
Provider.of<FeedProvider>(context, listen: false).loadFeed(refresh: true);
66
+
/// Handle auth state changes to sync PageController with provider
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;
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);
81
+
_wasAuthenticated = isAuthenticated;
56
-
if (_scrollController.position.pixels >=
57
-
_scrollController.position.maxScrollExtent - 200) {
58
-
Provider.of<FeedProvider>(context, listen: false).loadMore();
84
+
/// Load initial feed based on authentication
85
+
void _loadInitialFeed() {
86
+
final provider = context.read<MultiFeedProvider>();
87
+
final isAuthenticated = context.read<AuthProvider>().isAuthenticated;
89
+
// Load the current feed
90
+
provider.loadFeed(provider.currentFeedType, refresh: true);
92
+
// Preload the other feed if authenticated
93
+
if (isAuthenticated) {
95
+
provider.currentFeedType == FeedType.discover
97
+
: FeedType.discover;
98
+
provider.loadFeed(otherFeed, refresh: true);
62
-
Future<void> _onRefresh() async {
63
-
final feedProvider = Provider.of<FeedProvider>(context, listen: false);
64
-
await feedProvider.loadFeed(refresh: true);
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,
110
+
_scrollControllers[type]!.addListener(() => _onScroll(type));
112
+
return _scrollControllers[type]!;
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(
122
+
controller.position.pixels,
125
+
// Trigger pagination when near bottom
126
+
if (controller.position.pixels >=
127
+
controller.position.maxScrollExtent - 200) {
128
+
context.read<MultiFeedProvider>().loadMore(type);
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
135
+
// Use select to only rebuild when specific fields change
final isAuthenticated = context.select<AuthProvider, bool>(
(p) => p.isAuthenticated,
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);
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>>(
85
-
final isLoadingMore = context.select<FeedProvider, bool>(
86
-
(p) => p.isLoadingMore,
88
-
final currentTime = context.select<FeedProvider, DateTime?>(
89
-
(p) => p.currentTime,
139
+
final currentFeed = context.select<MultiFeedProvider, FeedType>(
140
+
(p) => p.currentFeedType,
···
97
-
// Feed content (behind header)
99
-
isLoading: isLoading,
102
-
isLoadingMore: isLoadingMore,
148
+
// Feed content with PageView for swipe navigation
149
+
_buildBody(isAuthenticated: isAuthenticated),
150
+
// Transparent header overlay
152
+
feedType: currentFeed,
isAuthenticated: isAuthenticated,
104
-
currentTime: currentTime,
106
-
// Transparent header overlay
107
-
_buildHeader(feedType: feedType, isAuthenticated: isAuthenticated),
···
isActive: feedType == FeedType.discover,
185
-
onTap: () => _switchToFeedType(FeedType.discover),
232
+
onTap: () => _switchToFeedType(FeedType.discover, 0),
const SizedBox(width: 24),
isActive: feedType == FeedType.forYou,
191
-
onTap: () => _switchToFeedType(FeedType.forYou),
238
+
onTap: () => _switchToFeedType(FeedType.forYou, 1),
···
240
-
void _switchToFeedType(FeedType type) {
241
-
Provider.of<FeedProvider>(context, listen: false).setFeedType(type);
287
+
/// Switch to a feed type and animate PageView
288
+
void _switchToFeedType(FeedType type, int pageIndex) {
289
+
final provider = context.read<MultiFeedProvider>();
290
+
provider.setCurrentFeed(type);
292
+
// Animate to the corresponding page
293
+
_pageController.animateToPage(
295
+
duration: const Duration(milliseconds: 300),
296
+
curve: Curves.easeInOut,
299
+
// Load the feed if it hasn't been loaded yet
300
+
_ensureFeedLoaded(type);
302
+
// Restore scroll position after page animation completes
303
+
_restoreScrollPosition(type);
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,
252
-
// Loading state (only show full-screen loader for initial load,
254
-
if (isLoading && posts.isEmpty) {
255
-
return const Center(
256
-
child: CircularProgressIndicator(color: AppColors.primary),
306
+
/// Ensure a feed is loaded (trigger initial load if needed)
308
+
/// Called when switching to a feed that may not have been loaded yet,
309
+
/// e.g., when user signs in after app start and taps "For You" tab.
310
+
void _ensureFeedLoaded(FeedType type) {
311
+
final provider = context.read<MultiFeedProvider>();
312
+
final state = provider.getState(type);
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
263
-
if (error != null && posts.isEmpty) {
266
-
padding: const EdgeInsets.all(24),
268
-
mainAxisAlignment: MainAxisAlignment.center,
271
-
Icons.error_outline,
273
-
color: AppColors.primary,
275
-
const SizedBox(height: 16),
277
-
'Failed to load feed',
280
-
color: AppColors.textPrimary,
281
-
fontWeight: FontWeight.bold,
284
-
const SizedBox(height: 8),
286
-
_getUserFriendlyError(error),
287
-
style: const TextStyle(
289
-
color: AppColors.textSecondary,
291
-
textAlign: TextAlign.center,
293
-
const SizedBox(height: 24),
296
-
Provider.of<FeedProvider>(context, listen: false).retry();
298
-
style: ElevatedButton.styleFrom(
299
-
backgroundColor: AppColors.primary,
301
-
child: const Text('Retry'),
314
+
// If the feed has no posts and isn't currently loading, trigger a load
315
+
if (state.posts.isEmpty && !state.isLoading) {
316
+
provider.loadFeed(type, refresh: true);
310
-
if (posts.isEmpty) {
313
-
padding: const EdgeInsets.all(24),
315
-
mainAxisAlignment: MainAxisAlignment.center,
317
-
const Icon(Icons.forum, size: 64, color: AppColors.primary),
318
-
const SizedBox(height: 24),
320
-
isAuthenticated ? 'No posts yet' : 'No posts to discover',
321
-
style: const TextStyle(
323
-
color: AppColors.textPrimary,
324
-
fontWeight: FontWeight.bold,
327
-
const SizedBox(height: 8),
330
-
? 'Subscribe to communities to see posts in your feed'
331
-
: 'Check back later for new posts',
332
-
style: const TextStyle(
334
-
color: AppColors.textSecondary,
336
-
textAlign: TextAlign.center,
320
+
/// Restore scroll position for a feed type
321
+
void _restoreScrollPosition(FeedType type) {
322
+
// Wait for the next frame to ensure the controller has clients
323
+
WidgetsBinding.instance.addPostFrameCallback((_) {
324
+
if (!mounted) return;
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(
361
-
padding: EdgeInsets.all(16),
362
-
child: CircularProgressIndicator(color: AppColors.primary),
366
-
// Show error message for pagination failures
367
-
if (error != null) {
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),
379
-
Icons.error_outline,
380
-
color: AppColors.primary,
383
-
const SizedBox(height: 8),
385
-
_getUserFriendlyError(error),
386
-
style: const TextStyle(
387
-
color: AppColors.textSecondary,
390
-
textAlign: TextAlign.center,
392
-
const SizedBox(height: 12),
395
-
Provider.of<FeedProvider>(context, listen: false)
399
-
style: TextButton.styleFrom(
400
-
foregroundColor: AppColors.primary,
402
-
child: const Text('Retry'),
326
+
final controller = _scrollControllers[type];
327
+
if (controller != null && controller.hasClients) {
328
+
final provider = context.read<MultiFeedProvider>();
329
+
final savedPosition = provider.getState(type).scrollPosition;
410
-
final post = posts[index];
413
-
'Feed post in ${post.post.community.name} by '
414
-
'${post.post.author.displayName ?? post.post.author.handle}. '
415
-
'${post.post.title ?? ""}',
417
-
child: PostCard(post: post, currentTime: currentTime),
331
+
// Only jump if the saved position differs from current
332
+
if ((controller.offset - savedPosition).abs() > 1) {
333
+
controller.jumpTo(savedPosition);
339
+
Widget _buildBody({required bool isAuthenticated}) {
340
+
// For unauthenticated users, show only Discover feed (no PageView)
341
+
if (!isAuthenticated) {
342
+
return _buildFeedPage(FeedType.discover, isAuthenticated);
345
+
// For authenticated users, use PageView for swipe navigation
347
+
controller: _pageController,
348
+
onPageChanged: (index) {
349
+
final type = index == 0 ? FeedType.discover : FeedType.forYou;
350
+
context.read<MultiFeedProvider>().setCurrentFeed(type);
351
+
// Load the feed if it hasn't been loaded yet
352
+
_ensureFeedLoaded(type);
353
+
// Restore scroll position when swiping between feeds
354
+
_restoreScrollPosition(type);
357
+
_buildFeedPage(FeedType.discover, isAuthenticated),
358
+
_buildFeedPage(FeedType.forYou, isAuthenticated),
424
-
/// Transform technical error messages into user-friendly ones
425
-
String _getUserFriendlyError(String error) {
426
-
final lowerError = error.toLowerCase();
363
+
/// Build a FeedPage widget with all required state from provider
364
+
Widget _buildFeedPage(FeedType feedType, bool isAuthenticated) {
365
+
return Consumer<MultiFeedProvider>(
366
+
builder: (context, provider, _) {
367
+
final state = provider.getState(feedType);
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';
369
+
// Handle error: treat null and empty string as no error
370
+
final error = state.error;
371
+
final hasError = error != null && error.isNotEmpty;
445
-
// Fallback to generic message for unknown errors
446
-
return 'Something went wrong. Please try again';
374
+
feedType: feedType,
375
+
posts: state.posts,
376
+
isLoading: state.isLoading,
377
+
isLoadingMore: state.isLoadingMore,
378
+
error: hasError ? error : null,
379
+
scrollController: _getOrCreateScrollController(feedType),
380
+
onRefresh: () => provider.loadFeed(feedType, refresh: true),
381
+
onRetry: () => provider.retry(feedType),
382
+
onClearErrorAndLoadMore: () {
383
+
provider.clearError(feedType);
384
+
provider.loadMore(feedType);
386
+
isAuthenticated: isAuthenticated,
387
+
currentTime: provider.currentTime,