Merge branch 'feat/multi-feed-state'

Multi-feed state management with security hardening:

- FeedState model with sentinel copyWith pattern for nullable field clearing
- MultiFeedProvider with per-feed state and cross-session security guards
- FeedPage widget with pull-to-refresh on empty state
- FeedScreen auth sync and lazy feed loading
- Comprehensive tests for new architecture

Fixes:
- copyWith can now clear nullable fields (cursor, error, lastRefreshTime)
- Cross-session data leaks prevented via DID comparison
- Empty feed states are now refreshable
- PageController syncs with auth state on sign-out
- For You tab loads on first access after sign-in

+20 -14
lib/main.dart
··· 8 8 import 'constants/app_colors.dart'; 9 9 import 'models/post.dart'; 10 10 import 'providers/auth_provider.dart'; 11 - import 'providers/feed_provider.dart'; 11 + import 'providers/multi_feed_provider.dart'; 12 12 import 'providers/vote_provider.dart'; 13 13 import 'screens/auth/login_screen.dart'; 14 14 import 'screens/home/main_shell_screen.dart'; ··· 64 64 authProvider: authProvider, 65 65 ), 66 66 ), 67 - ChangeNotifierProxyProvider2<AuthProvider, VoteProvider, FeedProvider>( 67 + ChangeNotifierProxyProvider2< 68 + AuthProvider, 69 + VoteProvider, 70 + MultiFeedProvider 71 + >( 68 72 create: 69 - (context) => FeedProvider( 73 + (context) => MultiFeedProvider( 70 74 authProvider, 71 75 voteProvider: context.read<VoteProvider>(), 72 76 ), 73 77 update: (context, auth, vote, previous) { 74 78 // Reuse existing provider to maintain state across rebuilds 75 - return previous ?? FeedProvider(auth, voteProvider: vote); 79 + return previous ?? MultiFeedProvider(auth, voteProvider: vote); 76 80 }, 77 81 ), 78 82 // CommentsProviderCache manages per-post CommentsProvider instances 79 83 // with LRU eviction and sign-out cleanup 80 84 ProxyProvider2<AuthProvider, VoteProvider, CommentsProviderCache>( 81 - create: (context) => CommentsProviderCache( 82 - authProvider: authProvider, 83 - voteProvider: context.read<VoteProvider>(), 84 - commentService: commentService, 85 - ), 85 + create: 86 + (context) => CommentsProviderCache( 87 + authProvider: authProvider, 88 + voteProvider: context.read<VoteProvider>(), 89 + commentService: commentService, 90 + ), 86 91 update: (context, auth, vote, previous) { 87 92 // Reuse existing cache 88 - return previous ?? CommentsProviderCache( 89 - authProvider: auth, 90 - voteProvider: vote, 91 - commentService: commentService, 92 - ); 93 + return previous ?? 94 + CommentsProviderCache( 95 + authProvider: auth, 96 + voteProvider: vote, 97 + commentService: commentService, 98 + ); 93 99 }, 94 100 dispose: (_, cache) => cache.dispose(), 95 101 ),
+80
lib/models/feed_state.dart
··· 1 + import 'post.dart'; 2 + 3 + /// Sentinel value for copyWith to distinguish "not provided" from "null" 4 + const _sentinel = Object(); 5 + 6 + /// Per-feed state container 7 + /// 8 + /// Holds all state for a single feed (Discover or For You) including posts, 9 + /// pagination, loading states, and scroll position. 10 + class FeedState { 11 + const FeedState({ 12 + this.posts = const [], 13 + this.cursor, 14 + this.hasMore = true, 15 + this.isLoading = false, 16 + this.isLoadingMore = false, 17 + this.error, 18 + this.scrollPosition = 0.0, 19 + this.lastRefreshTime, 20 + }); 21 + 22 + /// Create a default empty state 23 + factory FeedState.initial() { 24 + return const FeedState(); 25 + } 26 + 27 + /// Feed posts 28 + final List<FeedViewPost> posts; 29 + 30 + /// Pagination cursor for next page 31 + final String? cursor; 32 + 33 + /// Whether more pages are available 34 + final bool hasMore; 35 + 36 + /// Initial load in progress 37 + final bool isLoading; 38 + 39 + /// Pagination (load more) in progress 40 + final bool isLoadingMore; 41 + 42 + /// Error message if any 43 + final String? error; 44 + 45 + /// Cached scroll position for this feed 46 + final double scrollPosition; 47 + 48 + /// Last refresh timestamp for staleness checks 49 + final DateTime? lastRefreshTime; 50 + 51 + /// Create a copy with modified fields (immutable updates) 52 + /// 53 + /// Nullable fields (cursor, error, lastRefreshTime) use a sentinel pattern 54 + /// to distinguish between "not provided" and "explicitly set to null". 55 + /// Pass null explicitly to clear these fields. 56 + FeedState copyWith({ 57 + List<FeedViewPost>? posts, 58 + Object? cursor = _sentinel, 59 + bool? hasMore, 60 + bool? isLoading, 61 + bool? isLoadingMore, 62 + Object? error = _sentinel, 63 + double? scrollPosition, 64 + Object? lastRefreshTime = _sentinel, 65 + }) { 66 + return FeedState( 67 + posts: posts ?? this.posts, 68 + cursor: cursor == _sentinel ? this.cursor : cursor as String?, 69 + hasMore: hasMore ?? this.hasMore, 70 + isLoading: isLoading ?? this.isLoading, 71 + isLoadingMore: isLoadingMore ?? this.isLoadingMore, 72 + error: error == _sentinel ? this.error : error as String?, 73 + scrollPosition: scrollPosition ?? this.scrollPosition, 74 + lastRefreshTime: 75 + lastRefreshTime == _sentinel 76 + ? this.lastRefreshTime 77 + : lastRefreshTime as DateTime?, 78 + ); 79 + } 80 + }
-335
lib/providers/feed_provider.dart
··· 1 - import 'dart:async'; 2 - 3 - import 'package:flutter/foundation.dart'; 4 - import '../models/post.dart'; 5 - import '../services/coves_api_service.dart'; 6 - import 'auth_provider.dart'; 7 - import 'vote_provider.dart'; 8 - 9 - /// Feed types available in the app 10 - enum FeedType { 11 - /// All posts across the network 12 - discover, 13 - 14 - /// Posts from subscribed communities (authenticated only) 15 - forYou, 16 - } 17 - 18 - /// Feed Provider 19 - /// 20 - /// Manages feed state and fetching logic. 21 - /// Supports both authenticated timeline and public discover feed. 22 - /// 23 - /// IMPORTANT: Accepts AuthProvider reference to fetch fresh access 24 - /// tokens before each authenticated request (critical for atProto OAuth 25 - /// token rotation). 26 - class FeedProvider with ChangeNotifier { 27 - FeedProvider( 28 - this._authProvider, { 29 - CovesApiService? apiService, 30 - VoteProvider? voteProvider, 31 - }) : _voteProvider = voteProvider { 32 - // Use injected service (for testing) or create new one (for production) 33 - // Pass token getter, refresh handler, and sign out handler to API service 34 - // for automatic fresh token retrieval and automatic token refresh on 401 35 - _apiService = 36 - apiService ?? 37 - CovesApiService( 38 - tokenGetter: _authProvider.getAccessToken, 39 - tokenRefresher: _authProvider.refreshToken, 40 - signOutHandler: _authProvider.signOut, 41 - ); 42 - 43 - // Track initial auth state 44 - _wasAuthenticated = _authProvider.isAuthenticated; 45 - 46 - // [P0 FIX] Listen to auth state changes and clear feed on sign-out 47 - // This prevents privacy bug where logged-out users see their private 48 - // timeline until they manually refresh. 49 - _authProvider.addListener(_onAuthChanged); 50 - } 51 - 52 - /// Handle authentication state changes 53 - /// 54 - /// Only clears and reloads feed when transitioning from authenticated 55 - /// to unauthenticated (actual sign-out), not when staying unauthenticated 56 - /// (e.g., failed sign-in attempt). This prevents unnecessary API calls. 57 - void _onAuthChanged() { 58 - final isAuthenticated = _authProvider.isAuthenticated; 59 - 60 - // Only reload if transitioning from authenticated → unauthenticated 61 - if (_wasAuthenticated && !isAuthenticated && _posts.isNotEmpty) { 62 - if (kDebugMode) { 63 - debugPrint('🔒 User signed out - clearing feed'); 64 - } 65 - // Reset feed type to Discover since For You requires auth 66 - _feedType = FeedType.discover; 67 - reset(); 68 - // Automatically load the public discover feed 69 - loadFeed(refresh: true); 70 - } 71 - 72 - // Update tracked state 73 - _wasAuthenticated = isAuthenticated; 74 - } 75 - 76 - final AuthProvider _authProvider; 77 - late final CovesApiService _apiService; 78 - final VoteProvider? _voteProvider; 79 - 80 - // Track previous auth state to detect transitions 81 - bool _wasAuthenticated = false; 82 - 83 - // Feed state 84 - List<FeedViewPost> _posts = []; 85 - bool _isLoading = false; 86 - bool _isLoadingMore = false; 87 - String? _error; 88 - String? _cursor; 89 - bool _hasMore = true; 90 - 91 - // Feed configuration 92 - String _sort = 'hot'; 93 - String? _timeframe; 94 - FeedType _feedType = FeedType.discover; 95 - 96 - // Time update mechanism for periodic UI refreshes 97 - Timer? _timeUpdateTimer; 98 - DateTime? _currentTime; 99 - 100 - // Getters 101 - List<FeedViewPost> get posts => _posts; 102 - bool get isLoading => _isLoading; 103 - bool get isLoadingMore => _isLoadingMore; 104 - String? get error => _error; 105 - bool get hasMore => _hasMore; 106 - String get sort => _sort; 107 - String? get timeframe => _timeframe; 108 - DateTime? get currentTime => _currentTime; 109 - FeedType get feedType => _feedType; 110 - 111 - /// Check if For You feed is available (requires authentication) 112 - bool get isForYouAvailable => _authProvider.isAuthenticated; 113 - 114 - /// Start periodic time updates for "time ago" strings 115 - /// 116 - /// Updates currentTime every minute to trigger UI rebuilds for 117 - /// post timestamps. This ensures "5m ago" updates to "6m ago" without 118 - /// requiring user interaction. 119 - void startTimeUpdates() { 120 - // Cancel existing timer if any 121 - _timeUpdateTimer?.cancel(); 122 - 123 - // Update current time immediately 124 - _currentTime = DateTime.now(); 125 - notifyListeners(); 126 - 127 - // Set up periodic updates (every minute) 128 - _timeUpdateTimer = Timer.periodic(const Duration(minutes: 1), (_) { 129 - _currentTime = DateTime.now(); 130 - notifyListeners(); 131 - }); 132 - 133 - if (kDebugMode) { 134 - debugPrint('⏰ Started periodic time updates for feed timestamps'); 135 - } 136 - } 137 - 138 - /// Stop periodic time updates 139 - void stopTimeUpdates() { 140 - _timeUpdateTimer?.cancel(); 141 - _timeUpdateTimer = null; 142 - _currentTime = null; 143 - 144 - if (kDebugMode) { 145 - debugPrint('⏰ Stopped periodic time updates'); 146 - } 147 - } 148 - 149 - /// Load feed based on current feed type 150 - /// 151 - /// This method encapsulates the business logic of deciding which feed 152 - /// to fetch based on the selected feed type. 153 - Future<void> loadFeed({bool refresh = false}) async { 154 - // For You requires authentication - fall back to Discover if not 155 - if (_feedType == FeedType.forYou && _authProvider.isAuthenticated) { 156 - await fetchTimeline(refresh: refresh); 157 - } else { 158 - await fetchDiscover(refresh: refresh); 159 - } 160 - 161 - // Start time updates when feed is loaded 162 - if (_posts.isNotEmpty && _timeUpdateTimer == null) { 163 - startTimeUpdates(); 164 - } 165 - } 166 - 167 - /// Switch feed type and reload 168 - Future<void> setFeedType(FeedType type) async { 169 - if (_feedType == type) { 170 - return; 171 - } 172 - 173 - // For You requires authentication 174 - if (type == FeedType.forYou && !_authProvider.isAuthenticated) { 175 - return; 176 - } 177 - 178 - _feedType = type; 179 - // Reset pagination state but keep posts visible until new feed loads 180 - _cursor = null; 181 - _hasMore = true; 182 - _error = null; 183 - notifyListeners(); 184 - 185 - // Load new feed - old posts stay visible until new ones arrive 186 - await loadFeed(refresh: true); 187 - } 188 - 189 - /// Common feed fetching logic (DRY principle - eliminates code 190 - /// duplication) 191 - Future<void> _fetchFeed({ 192 - required bool refresh, 193 - required Future<TimelineResponse> Function() fetcher, 194 - required String feedName, 195 - }) async { 196 - if (_isLoading || _isLoadingMore) { 197 - return; 198 - } 199 - 200 - try { 201 - if (refresh) { 202 - _isLoading = true; 203 - // DON'T clear _posts, _cursor, or _hasMore yet 204 - // Keep existing data visible until refresh succeeds 205 - // This prevents transient failures from wiping the user's feed 206 - // and pagination state 207 - _error = null; 208 - } else { 209 - _isLoadingMore = true; 210 - } 211 - notifyListeners(); 212 - 213 - final response = await fetcher(); 214 - 215 - // Only update state after successful fetch 216 - if (refresh) { 217 - _posts = response.feed; 218 - } else { 219 - // Create new list instance to trigger context.select rebuilds 220 - // Using spread operator instead of addAll to ensure reference changes 221 - _posts = [..._posts, ...response.feed]; 222 - } 223 - 224 - _cursor = response.cursor; 225 - _hasMore = response.cursor != null; 226 - _error = null; 227 - 228 - if (kDebugMode) { 229 - debugPrint('✅ $feedName loaded: ${_posts.length} posts total'); 230 - } 231 - 232 - // Initialize vote state from viewer data in feed response 233 - // IMPORTANT: Call setInitialVoteState for ALL feed items, even when 234 - // viewer.vote is null. This ensures that if a user removed their vote 235 - // on another device, the local state is cleared on refresh. 236 - if (_authProvider.isAuthenticated && _voteProvider != null) { 237 - for (final feedItem in response.feed) { 238 - final viewer = feedItem.post.viewer; 239 - _voteProvider.setInitialVoteState( 240 - postUri: feedItem.post.uri, 241 - voteDirection: viewer?.vote, 242 - voteUri: viewer?.voteUri, 243 - ); 244 - } 245 - } 246 - } on Exception catch (e) { 247 - _error = e.toString(); 248 - if (kDebugMode) { 249 - debugPrint('❌ Failed to fetch $feedName: $e'); 250 - } 251 - } finally { 252 - _isLoading = false; 253 - _isLoadingMore = false; 254 - notifyListeners(); 255 - } 256 - } 257 - 258 - /// Fetch timeline feed (authenticated) 259 - /// 260 - /// Fetches the user's personalized timeline. 261 - /// Authentication is handled automatically via tokenGetter. 262 - Future<void> fetchTimeline({bool refresh = false}) => _fetchFeed( 263 - refresh: refresh, 264 - fetcher: 265 - () => _apiService.getTimeline( 266 - sort: _sort, 267 - timeframe: _timeframe, 268 - cursor: refresh ? null : _cursor, 269 - ), 270 - feedName: 'Timeline', 271 - ); 272 - 273 - /// Fetch discover feed (public) 274 - /// 275 - /// Fetches the public discover feed. 276 - /// Does not require authentication. 277 - Future<void> fetchDiscover({bool refresh = false}) => _fetchFeed( 278 - refresh: refresh, 279 - fetcher: 280 - () => _apiService.getDiscover( 281 - sort: _sort, 282 - timeframe: _timeframe, 283 - cursor: refresh ? null : _cursor, 284 - ), 285 - feedName: 'Discover', 286 - ); 287 - 288 - /// Load more posts (pagination) 289 - Future<void> loadMore() async { 290 - if (!_hasMore || _isLoadingMore) { 291 - return; 292 - } 293 - await loadFeed(); 294 - } 295 - 296 - /// Change sort order 297 - void setSort(String newSort, {String? newTimeframe}) { 298 - _sort = newSort; 299 - _timeframe = newTimeframe; 300 - notifyListeners(); 301 - } 302 - 303 - /// Retry loading after error 304 - Future<void> retry() async { 305 - _error = null; 306 - await loadFeed(refresh: true); 307 - } 308 - 309 - /// Clear error 310 - void clearError() { 311 - _error = null; 312 - notifyListeners(); 313 - } 314 - 315 - /// Reset feed state 316 - void reset() { 317 - _posts = []; 318 - _cursor = null; 319 - _hasMore = true; 320 - _error = null; 321 - _isLoading = false; 322 - _isLoadingMore = false; 323 - notifyListeners(); 324 - } 325 - 326 - @override 327 - void dispose() { 328 - // Stop time updates and cancel timer 329 - stopTimeUpdates(); 330 - // Remove auth listener to prevent memory leaks 331 - _authProvider.removeListener(_onAuthChanged); 332 - _apiService.dispose(); 333 - super.dispose(); 334 - } 335 - }
+415
lib/providers/multi_feed_provider.dart
··· 1 + import 'dart:async'; 2 + 3 + import 'package:flutter/foundation.dart'; 4 + import '../models/feed_state.dart'; 5 + import '../models/post.dart'; 6 + import '../services/coves_api_service.dart'; 7 + import 'auth_provider.dart'; 8 + import 'vote_provider.dart'; 9 + 10 + /// Feed types available in the app 11 + enum FeedType { 12 + /// All posts across the network 13 + discover, 14 + 15 + /// Posts from subscribed communities (authenticated only) 16 + forYou, 17 + } 18 + 19 + /// Multi-Feed Provider 20 + /// 21 + /// Manages independent state for multiple feeds (Discover and For You). 22 + /// Each feed maintains its own posts, scroll position, and pagination state. 23 + /// 24 + /// IMPORTANT: Accepts AuthProvider reference to fetch fresh access 25 + /// tokens before each authenticated request (critical for atProto OAuth 26 + /// token rotation). 27 + class MultiFeedProvider with ChangeNotifier { 28 + MultiFeedProvider( 29 + this._authProvider, { 30 + CovesApiService? apiService, 31 + VoteProvider? voteProvider, 32 + }) : _voteProvider = voteProvider { 33 + // Use injected service (for testing) or create new one (for production) 34 + // Pass token getter, refresh handler, and sign out handler to API service 35 + // for automatic fresh token retrieval and automatic token refresh on 401 36 + _apiService = 37 + apiService ?? 38 + CovesApiService( 39 + tokenGetter: _authProvider.getAccessToken, 40 + tokenRefresher: _authProvider.refreshToken, 41 + signOutHandler: _authProvider.signOut, 42 + ); 43 + 44 + // Track initial auth state 45 + _wasAuthenticated = _authProvider.isAuthenticated; 46 + 47 + // Listen to auth state changes and clear For You feed on sign-out 48 + // This prevents privacy bug where logged-out users see their 49 + // private timeline until they manually refresh. 50 + _authProvider.addListener(_onAuthChanged); 51 + } 52 + 53 + /// Handle authentication state changes 54 + /// 55 + /// Only clears For You feed when transitioning from authenticated to 56 + /// unauthenticated (actual sign-out), not when staying unauthenticated 57 + /// (e.g., failed sign-in attempt). This prevents unnecessary API calls. 58 + void _onAuthChanged() { 59 + final isAuthenticated = _authProvider.isAuthenticated; 60 + 61 + // Only clear For You feed if transitioning from authenticated to 62 + // unauthenticated 63 + if (_wasAuthenticated && !isAuthenticated) { 64 + if (kDebugMode) { 65 + debugPrint('🔒 User signed out - clearing For You feed'); 66 + } 67 + // Clear For You feed state, keep Discover intact 68 + _feedStates.remove(FeedType.forYou); 69 + 70 + // Switch to Discover if currently on For You 71 + if (_currentFeedType == FeedType.forYou) { 72 + _currentFeedType = FeedType.discover; 73 + } 74 + 75 + notifyListeners(); 76 + } 77 + 78 + // Update tracked state 79 + _wasAuthenticated = isAuthenticated; 80 + } 81 + 82 + final AuthProvider _authProvider; 83 + late final CovesApiService _apiService; 84 + final VoteProvider? _voteProvider; 85 + 86 + // Track previous auth state to detect transitions 87 + bool _wasAuthenticated = false; 88 + 89 + // Per-feed state storage 90 + final Map<FeedType, FeedState> _feedStates = {}; 91 + 92 + // Currently active feed 93 + FeedType _currentFeedType = FeedType.discover; 94 + 95 + // Feed configuration (shared across feeds) 96 + String _sort = 'hot'; 97 + String? _timeframe; 98 + 99 + // Time update mechanism for periodic UI refreshes 100 + Timer? _timeUpdateTimer; 101 + DateTime? _currentTime; 102 + 103 + // Getters 104 + FeedType get currentFeedType => _currentFeedType; 105 + String get sort => _sort; 106 + String? get timeframe => _timeframe; 107 + DateTime? get currentTime => _currentTime; 108 + 109 + /// Check if For You feed is available (requires authentication) 110 + bool get isForYouAvailable => _authProvider.isAuthenticated; 111 + 112 + /// Get state for a specific feed (creates default if missing) 113 + FeedState getState(FeedType type) { 114 + return _feedStates[type] ?? FeedState.initial(); 115 + } 116 + 117 + /// Set the current active feed type 118 + /// 119 + /// This just updates which feed is active, does NOT load data. 120 + /// The UI should call loadFeed() separately if needed. 121 + void setCurrentFeed(FeedType type) { 122 + if (_currentFeedType == type) { 123 + return; 124 + } 125 + 126 + // For You requires authentication 127 + if (type == FeedType.forYou && !_authProvider.isAuthenticated) { 128 + return; 129 + } 130 + 131 + _currentFeedType = type; 132 + notifyListeners(); 133 + } 134 + 135 + /// Save scroll position for a feed (passive, no notifyListeners) 136 + /// 137 + /// This is called frequently during scrolling, so we don't trigger 138 + /// rebuilds. The scroll position is persisted in the feed state for 139 + /// restoration when the user switches back to this feed. 140 + void saveScrollPosition(FeedType type, double position) { 141 + final currentState = getState(type); 142 + _feedStates[type] = currentState.copyWith(scrollPosition: position); 143 + // Intentionally NOT calling notifyListeners() - this is a passive save 144 + } 145 + 146 + /// Start periodic time updates for "time ago" strings 147 + /// 148 + /// Updates currentTime every minute to trigger UI rebuilds for 149 + /// post timestamps. This ensures "5m ago" updates to "6m ago" without 150 + /// requiring user interaction. 151 + void startTimeUpdates() { 152 + // Cancel existing timer if any 153 + _timeUpdateTimer?.cancel(); 154 + 155 + // Update current time immediately 156 + _currentTime = DateTime.now(); 157 + notifyListeners(); 158 + 159 + // Set up periodic updates (every minute) 160 + _timeUpdateTimer = Timer.periodic(const Duration(minutes: 1), (_) { 161 + _currentTime = DateTime.now(); 162 + notifyListeners(); 163 + }); 164 + 165 + if (kDebugMode) { 166 + debugPrint('⏰ Started periodic time updates for feed timestamps'); 167 + } 168 + } 169 + 170 + /// Stop periodic time updates 171 + void stopTimeUpdates() { 172 + _timeUpdateTimer?.cancel(); 173 + _timeUpdateTimer = null; 174 + _currentTime = null; 175 + 176 + if (kDebugMode) { 177 + debugPrint('⏰ Stopped periodic time updates'); 178 + } 179 + } 180 + 181 + /// Load feed based on feed type 182 + /// 183 + /// This method encapsulates the business logic of deciding which feed 184 + /// to fetch based on the selected feed type. 185 + Future<void> loadFeed(FeedType type, {bool refresh = false}) async { 186 + // For You requires authentication - fall back to Discover if not 187 + if (type == FeedType.forYou && _authProvider.isAuthenticated) { 188 + await _fetchTimeline(type, refresh: refresh); 189 + } else { 190 + await _fetchDiscover(type, refresh: refresh); 191 + } 192 + 193 + // Start time updates when feed is loaded 194 + final state = getState(type); 195 + if (state.posts.isNotEmpty && _timeUpdateTimer == null) { 196 + startTimeUpdates(); 197 + } 198 + } 199 + 200 + /// Load more posts for a feed (pagination) 201 + Future<void> loadMore(FeedType type) async { 202 + final state = getState(type); 203 + 204 + if (!state.hasMore || state.isLoadingMore) { 205 + return; 206 + } 207 + 208 + await loadFeed(type); 209 + } 210 + 211 + /// Common feed fetching logic (DRY principle - eliminates code 212 + /// duplication) 213 + Future<void> _fetchFeed({ 214 + required FeedType type, 215 + required bool refresh, 216 + required Future<TimelineResponse> Function() fetcher, 217 + required String feedName, 218 + }) async { 219 + final currentState = getState(type); 220 + 221 + if (currentState.isLoading || currentState.isLoadingMore) { 222 + return; 223 + } 224 + 225 + // Capture session identity before fetch to detect any auth change 226 + // (sign-out, or sign-in as different user) during the request 227 + final sessionDidBeforeFetch = _authProvider.did; 228 + 229 + try { 230 + if (refresh) { 231 + // Start loading, keep existing data visible 232 + _feedStates[type] = currentState.copyWith(isLoading: true, error: null); 233 + } else { 234 + // Pagination 235 + _feedStates[type] = currentState.copyWith(isLoadingMore: true); 236 + } 237 + notifyListeners(); 238 + 239 + final response = await fetcher(); 240 + 241 + // SECURITY: If session changed during fetch, discard the response 242 + // to prevent cross-session data leaks. This handles: 243 + // - User signed out (DID became null) 244 + // - User signed out and back in as same user (unlikely but safe) 245 + // - User signed out and different user signed in (DID changed) 246 + // This is especially important for the For You feed which contains 247 + // private timeline data. 248 + if (type == FeedType.forYou && 249 + sessionDidBeforeFetch != _authProvider.did) { 250 + if (kDebugMode) { 251 + debugPrint( 252 + '🔒 Discarding $feedName response - session changed during fetch', 253 + ); 254 + } 255 + // Remove the feed state entirely (don't write back stale data) 256 + // _onAuthChanged already removed this, but ensure it stays removed 257 + _feedStates.remove(type); 258 + notifyListeners(); 259 + return; 260 + } 261 + 262 + // Only update state after successful fetch 263 + final List<FeedViewPost> newPosts; 264 + if (refresh) { 265 + newPosts = response.feed; 266 + } else { 267 + // Create new list instance to trigger context.select rebuilds 268 + // Using spread operator instead of addAll to ensure reference changes 269 + newPosts = [...currentState.posts, ...response.feed]; 270 + } 271 + 272 + _feedStates[type] = currentState.copyWith( 273 + posts: newPosts, 274 + cursor: response.cursor, 275 + hasMore: response.cursor != null, 276 + error: null, 277 + isLoading: false, 278 + isLoadingMore: false, 279 + lastRefreshTime: 280 + refresh ? DateTime.now() : currentState.lastRefreshTime, 281 + ); 282 + 283 + if (kDebugMode) { 284 + debugPrint('✅ $feedName loaded: ${newPosts.length} posts total'); 285 + } 286 + 287 + // Initialize vote state from viewer data in feed response 288 + // IMPORTANT: Call setInitialVoteState for ALL feed items, even 289 + // when viewer.vote is null. This ensures that if a user removed 290 + // their vote on another device, the local state is cleared on 291 + // refresh. 292 + if (_authProvider.isAuthenticated && _voteProvider != null) { 293 + for (final feedItem in response.feed) { 294 + final viewer = feedItem.post.viewer; 295 + _voteProvider.setInitialVoteState( 296 + postUri: feedItem.post.uri, 297 + voteDirection: viewer?.vote, 298 + voteUri: viewer?.voteUri, 299 + ); 300 + } 301 + } 302 + } on Exception catch (e) { 303 + // SECURITY: Also check session change in error path to prevent 304 + // leaking stale data when a fetch fails after sign-out 305 + if (type == FeedType.forYou && 306 + sessionDidBeforeFetch != _authProvider.did) { 307 + if (kDebugMode) { 308 + debugPrint( 309 + '🔒 Discarding $feedName error - session changed during fetch', 310 + ); 311 + } 312 + _feedStates.remove(type); 313 + notifyListeners(); 314 + return; 315 + } 316 + 317 + _feedStates[type] = currentState.copyWith( 318 + error: e.toString(), 319 + isLoading: false, 320 + isLoadingMore: false, 321 + ); 322 + 323 + if (kDebugMode) { 324 + debugPrint('❌ Failed to fetch $feedName: $e'); 325 + } 326 + } 327 + 328 + notifyListeners(); 329 + } 330 + 331 + /// Fetch timeline feed (authenticated) 332 + /// 333 + /// Fetches the user's personalized timeline. 334 + /// Authentication is handled automatically via tokenGetter. 335 + Future<void> _fetchTimeline(FeedType type, {bool refresh = false}) { 336 + final currentState = getState(type); 337 + 338 + return _fetchFeed( 339 + type: type, 340 + refresh: refresh, 341 + fetcher: 342 + () => _apiService.getTimeline( 343 + sort: _sort, 344 + timeframe: _timeframe, 345 + cursor: refresh ? null : currentState.cursor, 346 + ), 347 + feedName: 'Timeline', 348 + ); 349 + } 350 + 351 + /// Fetch discover feed (public) 352 + /// 353 + /// Fetches the public discover feed. 354 + /// Does not require authentication. 355 + Future<void> _fetchDiscover(FeedType type, {bool refresh = false}) { 356 + final currentState = getState(type); 357 + 358 + return _fetchFeed( 359 + type: type, 360 + refresh: refresh, 361 + fetcher: 362 + () => _apiService.getDiscover( 363 + sort: _sort, 364 + timeframe: _timeframe, 365 + cursor: refresh ? null : currentState.cursor, 366 + ), 367 + feedName: 'Discover', 368 + ); 369 + } 370 + 371 + /// Change sort order 372 + void setSort(String newSort, {String? newTimeframe}) { 373 + _sort = newSort; 374 + _timeframe = newTimeframe; 375 + notifyListeners(); 376 + } 377 + 378 + /// Retry loading after error for a specific feed 379 + Future<void> retry(FeedType type) async { 380 + final currentState = getState(type); 381 + _feedStates[type] = currentState.copyWith(error: null); 382 + notifyListeners(); 383 + 384 + await loadFeed(type); 385 + } 386 + 387 + /// Clear error for a specific feed 388 + void clearError(FeedType type) { 389 + final currentState = getState(type); 390 + _feedStates[type] = currentState.copyWith(error: null); 391 + notifyListeners(); 392 + } 393 + 394 + /// Reset feed state for a specific feed 395 + void reset(FeedType type) { 396 + _feedStates[type] = FeedState.initial(); 397 + notifyListeners(); 398 + } 399 + 400 + /// Reset all feeds 401 + void resetAll() { 402 + _feedStates.clear(); 403 + notifyListeners(); 404 + } 405 + 406 + @override 407 + void dispose() { 408 + // Stop time updates and cancel timer 409 + stopTimeUpdates(); 410 + // Remove auth listener to prevent memory leaks 411 + _authProvider.removeListener(_onAuthChanged); 412 + _apiService.dispose(); 413 + super.dispose(); 414 + } 415 + }
+190 -244
lib/screens/home/feed_screen.dart
··· 2 2 import 'package:provider/provider.dart'; 3 3 4 4 import '../../constants/app_colors.dart'; 5 - import '../../models/post.dart'; 6 5 import '../../providers/auth_provider.dart'; 7 - import '../../providers/feed_provider.dart'; 6 + import '../../providers/multi_feed_provider.dart'; 7 + import '../../widgets/feed_page.dart'; 8 8 import '../../widgets/icons/bluesky_icons.dart'; 9 - import '../../widgets/post_card.dart'; 10 9 11 10 /// Header layout constants 12 11 const double _kHeaderHeight = 44; 13 12 const double _kTabUnderlineWidth = 28; 14 13 const double _kTabUnderlineHeight = 3; 15 - const double _kHeaderContentPadding = _kHeaderHeight; 16 14 17 15 class FeedScreen extends StatefulWidget { 18 16 const FeedScreen({super.key, this.onSearchTap}); ··· 25 23 } 26 24 27 25 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; 29 30 30 31 @override 31 32 void initState() { 32 33 super.initState(); 33 - _scrollController.addListener(_onScroll); 34 34 35 - // Fetch feed after frame is built 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 36 49 WidgetsBinding.instance.addPostFrameCallback((_) { 37 - // Check if widget is still mounted before loading 38 50 if (mounted) { 39 - _loadFeed(); 51 + _loadInitialFeed(); 40 52 } 41 53 }); 42 54 } 43 55 44 56 @override 45 57 void dispose() { 46 - _scrollController.dispose(); 58 + _authProvider.removeListener(_onAuthChanged); 59 + _pageController.dispose(); 60 + for (final controller in _scrollControllers.values) { 61 + controller.dispose(); 62 + } 47 63 super.dispose(); 48 64 } 49 65 50 - /// Load feed - business logic is now in FeedProvider 51 - void _loadFeed() { 52 - Provider.of<FeedProvider>(context, listen: false).loadFeed(refresh: true); 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 + } 53 100 } 54 101 55 - void _onScroll() { 56 - if (_scrollController.position.pixels >= 57 - _scrollController.position.maxScrollExtent - 200) { 58 - Provider.of<FeedProvider>(context, listen: false).loadMore(); 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)); 59 111 } 112 + return _scrollControllers[type]!; 60 113 } 61 114 62 - Future<void> _onRefresh() async { 63 - final feedProvider = Provider.of<FeedProvider>(context, listen: false); 64 - await feedProvider.loadFeed(refresh: true); 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 + } 65 131 } 66 132 67 133 @override 68 134 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 71 136 final isAuthenticated = context.select<AuthProvider, bool>( 72 137 (p) => p.isAuthenticated, 73 138 ); 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, 139 + final currentFeed = context.select<MultiFeedProvider, FeedType>( 140 + (p) => p.currentFeedType, 90 141 ); 91 142 92 143 return Scaffold( ··· 94 145 body: SafeArea( 95 146 child: Stack( 96 147 children: [ 97 - // Feed content (behind header) 98 - _buildBody( 99 - isLoading: isLoading, 100 - error: error, 101 - posts: posts, 102 - isLoadingMore: isLoadingMore, 148 + // Feed content with PageView for swipe navigation 149 + _buildBody(isAuthenticated: isAuthenticated), 150 + // Transparent header overlay 151 + _buildHeader( 152 + feedType: currentFeed, 103 153 isAuthenticated: isAuthenticated, 104 - currentTime: currentTime, 105 154 ), 106 - // Transparent header overlay 107 - _buildHeader(feedType: feedType, isAuthenticated: isAuthenticated), 108 155 ], 109 156 ), 110 157 ), ··· 182 229 _buildFeedTypeTab( 183 230 label: 'Discover', 184 231 isActive: feedType == FeedType.discover, 185 - onTap: () => _switchToFeedType(FeedType.discover), 232 + onTap: () => _switchToFeedType(FeedType.discover, 0), 186 233 ), 187 234 const SizedBox(width: 24), 188 235 _buildFeedTypeTab( 189 236 label: 'For You', 190 237 isActive: feedType == FeedType.forYou, 191 - onTap: () => _switchToFeedType(FeedType.forYou), 238 + onTap: () => _switchToFeedType(FeedType.forYou, 1), 192 239 ), 193 240 ], 194 241 ); ··· 237 284 ); 238 285 } 239 286 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 + 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); 242 303 } 243 304 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 - } 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); 259 312 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 - ); 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); 307 316 } 317 + } 308 318 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 - ); 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); 342 344 } 343 345 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 - ), 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 + ], 421 361 ); 422 362 } 423 363 424 - /// Transform technical error messages into user-friendly ones 425 - String _getUserFriendlyError(String error) { 426 - final lowerError = error.toLowerCase(); 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); 427 369 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 - } 370 + // Handle error: treat null and empty string as no error 371 + final error = state.error; 372 + final hasError = error != null && error.isNotEmpty; 444 373 445 - // Fallback to generic message for unknown errors 446 - return 'Something went wrong. Please try again'; 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 + ); 447 393 } 448 394 }
+286
lib/widgets/feed_page.dart
··· 1 + import 'package:flutter/material.dart'; 2 + 3 + import '../constants/app_colors.dart'; 4 + import '../models/post.dart'; 5 + import '../providers/multi_feed_provider.dart'; 6 + import 'post_card.dart'; 7 + 8 + /// FeedPage widget for rendering a single feed's content 9 + /// 10 + /// Displays a feed with: 11 + /// - Loading state (spinner when loading initial posts) 12 + /// - Error state (error message with retry button) 13 + /// - Empty state (no posts message) 14 + /// - Posts list (RefreshIndicator + ListView.builder with PostCard widgets) 15 + /// - Pagination footer (loading indicator or error retry at bottom) 16 + /// 17 + /// This widget is used within a PageView to render individual feeds 18 + /// (Discover, For You) in the feed screen. 19 + /// 20 + /// Uses AutomaticKeepAliveClientMixin to keep the page alive when swiping 21 + /// between feeds, preventing scroll position jumps during transitions. 22 + class FeedPage extends StatefulWidget { 23 + const FeedPage({ 24 + required this.feedType, 25 + required this.posts, 26 + required this.isLoading, 27 + required this.isLoadingMore, 28 + required this.error, 29 + required this.scrollController, 30 + required this.onRefresh, 31 + required this.onRetry, 32 + required this.onClearErrorAndLoadMore, 33 + required this.isAuthenticated, 34 + required this.currentTime, 35 + super.key, 36 + }); 37 + 38 + final FeedType feedType; 39 + final List<FeedViewPost> posts; 40 + final bool isLoading; 41 + final bool isLoadingMore; 42 + final String? error; 43 + final ScrollController scrollController; 44 + final Future<void> Function() onRefresh; 45 + final VoidCallback onRetry; 46 + final VoidCallback onClearErrorAndLoadMore; 47 + final bool isAuthenticated; 48 + final DateTime? currentTime; 49 + 50 + @override 51 + State<FeedPage> createState() => _FeedPageState(); 52 + } 53 + 54 + class _FeedPageState extends State<FeedPage> 55 + with AutomaticKeepAliveClientMixin { 56 + @override 57 + bool get wantKeepAlive => true; 58 + 59 + @override 60 + Widget build(BuildContext context) { 61 + // Required call for AutomaticKeepAliveClientMixin 62 + super.build(context); 63 + 64 + // Loading state (only show full-screen loader for initial load, 65 + // not refresh) 66 + if (widget.isLoading && widget.posts.isEmpty) { 67 + return const Center( 68 + child: CircularProgressIndicator(color: AppColors.primary), 69 + ); 70 + } 71 + 72 + // Error state (only show full-screen error when no posts loaded 73 + // yet). If we have posts but pagination failed, we'll show the error 74 + // at the bottom 75 + if (widget.error != null && widget.posts.isEmpty) { 76 + return Center( 77 + child: Padding( 78 + padding: const EdgeInsets.all(24), 79 + child: Column( 80 + mainAxisAlignment: MainAxisAlignment.center, 81 + children: [ 82 + const Icon( 83 + Icons.error_outline, 84 + size: 64, 85 + color: AppColors.primary, 86 + ), 87 + const SizedBox(height: 16), 88 + const Text( 89 + 'Failed to load feed', 90 + style: TextStyle( 91 + fontSize: 20, 92 + color: AppColors.textPrimary, 93 + fontWeight: FontWeight.bold, 94 + ), 95 + ), 96 + const SizedBox(height: 8), 97 + Text( 98 + _getUserFriendlyError(widget.error!), 99 + style: const TextStyle( 100 + fontSize: 14, 101 + color: AppColors.textSecondary, 102 + ), 103 + textAlign: TextAlign.center, 104 + ), 105 + const SizedBox(height: 24), 106 + ElevatedButton( 107 + onPressed: widget.onRetry, 108 + style: ElevatedButton.styleFrom( 109 + backgroundColor: AppColors.primary, 110 + ), 111 + child: const Text('Retry'), 112 + ), 113 + ], 114 + ), 115 + ), 116 + ); 117 + } 118 + 119 + // Empty state - wrapped in RefreshIndicator so users can pull to refresh 120 + if (widget.posts.isEmpty) { 121 + return RefreshIndicator( 122 + onRefresh: widget.onRefresh, 123 + color: AppColors.primary, 124 + child: CustomScrollView( 125 + physics: const AlwaysScrollableScrollPhysics(), 126 + slivers: [ 127 + SliverFillRemaining( 128 + hasScrollBody: false, 129 + child: Center( 130 + child: Padding( 131 + padding: const EdgeInsets.all(24), 132 + child: Column( 133 + mainAxisAlignment: MainAxisAlignment.center, 134 + children: [ 135 + const Icon( 136 + Icons.forum, 137 + size: 64, 138 + color: AppColors.primary, 139 + ), 140 + const SizedBox(height: 24), 141 + Text( 142 + widget.isAuthenticated 143 + ? 'No posts yet' 144 + : 'No posts to discover', 145 + style: const TextStyle( 146 + fontSize: 20, 147 + color: AppColors.textPrimary, 148 + fontWeight: FontWeight.bold, 149 + ), 150 + ), 151 + const SizedBox(height: 8), 152 + Text( 153 + widget.isAuthenticated 154 + ? 'Subscribe to communities to see ' 155 + 'posts in your feed' 156 + : 'Check back later for new posts', 157 + style: const TextStyle( 158 + fontSize: 14, 159 + color: AppColors.textSecondary, 160 + ), 161 + textAlign: TextAlign.center, 162 + ), 163 + ], 164 + ), 165 + ), 166 + ), 167 + ), 168 + ], 169 + ), 170 + ); 171 + } 172 + 173 + // Posts list 174 + return RefreshIndicator( 175 + onRefresh: widget.onRefresh, 176 + color: AppColors.primary, 177 + child: ListView.builder( 178 + controller: widget.scrollController, 179 + // Smooth bouncy scroll physics (iOS-style) with always-scrollable 180 + // for pull-to-refresh support 181 + physics: const BouncingScrollPhysics( 182 + parent: AlwaysScrollableScrollPhysics(), 183 + ), 184 + // Pre-render items 800px above/below viewport for smoother scrolling 185 + cacheExtent: 800, 186 + // Add top padding so content isn't hidden behind transparent header 187 + padding: const EdgeInsets.only(top: 44), 188 + // Add extra item for loading indicator or pagination error 189 + itemCount: 190 + widget.posts.length + 191 + (widget.isLoadingMore || widget.error != null ? 1 : 0), 192 + itemBuilder: (context, index) { 193 + // Footer: loading indicator or error message 194 + if (index == widget.posts.length) { 195 + // Show loading indicator for pagination 196 + if (widget.isLoadingMore) { 197 + return const Center( 198 + child: Padding( 199 + padding: EdgeInsets.all(16), 200 + child: CircularProgressIndicator(color: AppColors.primary), 201 + ), 202 + ); 203 + } 204 + // Show error message for pagination failures 205 + if (widget.error != null) { 206 + return Container( 207 + margin: const EdgeInsets.all(16), 208 + padding: const EdgeInsets.all(16), 209 + decoration: BoxDecoration( 210 + color: AppColors.background, 211 + borderRadius: BorderRadius.circular(8), 212 + border: Border.all(color: AppColors.primary), 213 + ), 214 + child: Column( 215 + children: [ 216 + const Icon( 217 + Icons.error_outline, 218 + color: AppColors.primary, 219 + size: 32, 220 + ), 221 + const SizedBox(height: 8), 222 + Text( 223 + _getUserFriendlyError(widget.error!), 224 + style: const TextStyle( 225 + color: AppColors.textSecondary, 226 + fontSize: 14, 227 + ), 228 + textAlign: TextAlign.center, 229 + ), 230 + const SizedBox(height: 12), 231 + TextButton( 232 + onPressed: widget.onClearErrorAndLoadMore, 233 + style: TextButton.styleFrom( 234 + foregroundColor: AppColors.primary, 235 + ), 236 + child: const Text('Retry'), 237 + ), 238 + ], 239 + ), 240 + ); 241 + } 242 + } 243 + 244 + final post = widget.posts[index]; 245 + // RepaintBoundary isolates each post card to prevent unnecessary 246 + // repaints of other items during scrolling 247 + return RepaintBoundary( 248 + child: Semantics( 249 + label: 250 + 'Feed post in ${post.post.community.name} by ' 251 + '${post.post.author.displayName ?? post.post.author.handle}. ' 252 + '${post.post.title ?? ""}', 253 + button: true, 254 + child: PostCard(post: post, currentTime: widget.currentTime), 255 + ), 256 + ); 257 + }, 258 + ), 259 + ); 260 + } 261 + 262 + /// Transform technical error messages into user-friendly ones 263 + String _getUserFriendlyError(String error) { 264 + final lowerError = error.toLowerCase(); 265 + 266 + if (lowerError.contains('socketexception') || 267 + lowerError.contains('network') || 268 + lowerError.contains('connection refused')) { 269 + return 'Please check your internet connection'; 270 + } else if (lowerError.contains('timeoutexception') || 271 + lowerError.contains('timeout')) { 272 + return 'Request timed out. Please try again'; 273 + } else if (lowerError.contains('401') || 274 + lowerError.contains('unauthorized')) { 275 + return 'Authentication failed. Please sign in again'; 276 + } else if (lowerError.contains('404') || lowerError.contains('not found')) { 277 + return 'Content not found'; 278 + } else if (lowerError.contains('500') || 279 + lowerError.contains('internal server')) { 280 + return 'Server error. Please try again later'; 281 + } 282 + 283 + // Fallback to generic message for unknown errors 284 + return 'Something went wrong. Please try again'; 285 + } 286 + }
+4 -4
pubspec.lock
··· 548 548 dependency: transitive 549 549 description: 550 550 name: meta 551 - sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" 551 + sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c 552 552 url: "https://pub.dev" 553 553 source: hosted 554 - version: "1.17.0" 554 + version: "1.16.0" 555 555 mime: 556 556 dependency: transitive 557 557 description: ··· 937 937 dependency: transitive 938 938 description: 939 939 name: test_api 940 - sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55 940 + sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00" 941 941 url: "https://pub.dev" 942 942 source: hosted 943 - version: "0.7.7" 943 + version: "0.7.6" 944 944 typed_data: 945 945 dependency: transitive 946 946 description:
-715
test/providers/feed_provider_test.dart
··· 1 - import 'package:coves_flutter/models/post.dart'; 2 - import 'package:coves_flutter/providers/auth_provider.dart'; 3 - import 'package:coves_flutter/providers/feed_provider.dart'; 4 - import 'package:coves_flutter/providers/vote_provider.dart'; 5 - import 'package:coves_flutter/services/coves_api_service.dart'; 6 - import 'package:flutter_test/flutter_test.dart'; 7 - import 'package:mockito/annotations.dart'; 8 - import 'package:mockito/mockito.dart'; 9 - 10 - import 'feed_provider_test.mocks.dart'; 11 - 12 - // Generate mocks 13 - @GenerateMocks([AuthProvider, CovesApiService, VoteProvider]) 14 - void main() { 15 - group('FeedProvider', () { 16 - late FeedProvider feedProvider; 17 - late MockAuthProvider mockAuthProvider; 18 - late MockCovesApiService mockApiService; 19 - 20 - setUp(() { 21 - mockAuthProvider = MockAuthProvider(); 22 - mockApiService = MockCovesApiService(); 23 - 24 - // Mock default auth state 25 - when(mockAuthProvider.isAuthenticated).thenReturn(false); 26 - 27 - // Mock the token getter 28 - when( 29 - mockAuthProvider.getAccessToken(), 30 - ).thenAnswer((_) async => 'test-token'); 31 - 32 - // Create feed provider with injected mock service 33 - feedProvider = FeedProvider(mockAuthProvider, apiService: mockApiService); 34 - }); 35 - 36 - tearDown(() { 37 - feedProvider.dispose(); 38 - }); 39 - 40 - group('loadFeed', () { 41 - test('should load discover feed when authenticated by default', () async { 42 - when(mockAuthProvider.isAuthenticated).thenReturn(true); 43 - 44 - final mockResponse = TimelineResponse( 45 - feed: [_createMockPost()], 46 - cursor: 'next-cursor', 47 - ); 48 - 49 - when( 50 - mockApiService.getDiscover( 51 - sort: anyNamed('sort'), 52 - timeframe: anyNamed('timeframe'), 53 - limit: anyNamed('limit'), 54 - cursor: anyNamed('cursor'), 55 - ), 56 - ).thenAnswer((_) async => mockResponse); 57 - 58 - await feedProvider.loadFeed(refresh: true); 59 - 60 - expect(feedProvider.posts.length, 1); 61 - expect(feedProvider.error, null); 62 - expect(feedProvider.isLoading, false); 63 - }); 64 - 65 - test('should load timeline when feed type is For You', () async { 66 - when(mockAuthProvider.isAuthenticated).thenReturn(true); 67 - 68 - final mockResponse = TimelineResponse( 69 - feed: [_createMockPost()], 70 - cursor: 'next-cursor', 71 - ); 72 - 73 - when( 74 - mockApiService.getTimeline( 75 - sort: anyNamed('sort'), 76 - timeframe: anyNamed('timeframe'), 77 - limit: anyNamed('limit'), 78 - cursor: anyNamed('cursor'), 79 - ), 80 - ).thenAnswer((_) async => mockResponse); 81 - 82 - await feedProvider.setFeedType(FeedType.forYou); 83 - 84 - expect(feedProvider.posts.length, 1); 85 - expect(feedProvider.error, null); 86 - expect(feedProvider.isLoading, false); 87 - }); 88 - 89 - test('should load discover feed when not authenticated', () async { 90 - when(mockAuthProvider.isAuthenticated).thenReturn(false); 91 - 92 - final mockResponse = TimelineResponse( 93 - feed: [_createMockPost()], 94 - cursor: 'next-cursor', 95 - ); 96 - 97 - when( 98 - mockApiService.getDiscover( 99 - sort: anyNamed('sort'), 100 - timeframe: anyNamed('timeframe'), 101 - limit: anyNamed('limit'), 102 - cursor: anyNamed('cursor'), 103 - ), 104 - ).thenAnswer((_) async => mockResponse); 105 - 106 - await feedProvider.loadFeed(refresh: true); 107 - 108 - expect(feedProvider.posts.length, 1); 109 - expect(feedProvider.error, null); 110 - }); 111 - }); 112 - 113 - group('fetchTimeline', () { 114 - test('should fetch timeline successfully', () async { 115 - final mockResponse = TimelineResponse( 116 - feed: [_createMockPost(), _createMockPost()], 117 - cursor: 'next-cursor', 118 - ); 119 - 120 - when( 121 - mockApiService.getTimeline( 122 - sort: anyNamed('sort'), 123 - timeframe: anyNamed('timeframe'), 124 - limit: anyNamed('limit'), 125 - cursor: anyNamed('cursor'), 126 - ), 127 - ).thenAnswer((_) async => mockResponse); 128 - 129 - await feedProvider.fetchTimeline(refresh: true); 130 - 131 - expect(feedProvider.posts.length, 2); 132 - expect(feedProvider.hasMore, true); 133 - expect(feedProvider.error, null); 134 - }); 135 - 136 - test('should handle network errors', () async { 137 - when( 138 - mockApiService.getTimeline( 139 - sort: anyNamed('sort'), 140 - timeframe: anyNamed('timeframe'), 141 - limit: anyNamed('limit'), 142 - cursor: anyNamed('cursor'), 143 - ), 144 - ).thenThrow(Exception('Network error')); 145 - 146 - await feedProvider.fetchTimeline(refresh: true); 147 - 148 - expect(feedProvider.error, isNotNull); 149 - expect(feedProvider.isLoading, false); 150 - }); 151 - 152 - test('should append posts when not refreshing', () async { 153 - // First load 154 - final firstResponse = TimelineResponse( 155 - feed: [_createMockPost()], 156 - cursor: 'cursor-1', 157 - ); 158 - 159 - when( 160 - mockApiService.getTimeline( 161 - sort: anyNamed('sort'), 162 - timeframe: anyNamed('timeframe'), 163 - limit: anyNamed('limit'), 164 - cursor: anyNamed('cursor'), 165 - ), 166 - ).thenAnswer((_) async => firstResponse); 167 - 168 - await feedProvider.fetchTimeline(refresh: true); 169 - expect(feedProvider.posts.length, 1); 170 - 171 - // Second load (pagination) 172 - final secondResponse = TimelineResponse( 173 - feed: [_createMockPost()], 174 - cursor: 'cursor-2', 175 - ); 176 - 177 - when( 178 - mockApiService.getTimeline( 179 - sort: anyNamed('sort'), 180 - timeframe: anyNamed('timeframe'), 181 - limit: anyNamed('limit'), 182 - cursor: 'cursor-1', 183 - ), 184 - ).thenAnswer((_) async => secondResponse); 185 - 186 - await feedProvider.fetchTimeline(); 187 - expect(feedProvider.posts.length, 2); 188 - }); 189 - 190 - test('should replace posts when refreshing', () async { 191 - // First load 192 - final firstResponse = TimelineResponse( 193 - feed: [_createMockPost()], 194 - cursor: 'cursor-1', 195 - ); 196 - 197 - when( 198 - mockApiService.getTimeline( 199 - sort: anyNamed('sort'), 200 - timeframe: anyNamed('timeframe'), 201 - limit: anyNamed('limit'), 202 - cursor: anyNamed('cursor'), 203 - ), 204 - ).thenAnswer((_) async => firstResponse); 205 - 206 - await feedProvider.fetchTimeline(refresh: true); 207 - expect(feedProvider.posts.length, 1); 208 - 209 - // Refresh 210 - final refreshResponse = TimelineResponse( 211 - feed: [_createMockPost(), _createMockPost()], 212 - cursor: 'cursor-2', 213 - ); 214 - 215 - when( 216 - mockApiService.getTimeline( 217 - sort: anyNamed('sort'), 218 - timeframe: anyNamed('timeframe'), 219 - limit: anyNamed('limit'), 220 - ), 221 - ).thenAnswer((_) async => refreshResponse); 222 - 223 - await feedProvider.fetchTimeline(refresh: true); 224 - expect(feedProvider.posts.length, 2); 225 - }); 226 - 227 - test('should set hasMore to false when no cursor', () async { 228 - final response = TimelineResponse(feed: [_createMockPost()]); 229 - 230 - when( 231 - mockApiService.getTimeline( 232 - sort: anyNamed('sort'), 233 - timeframe: anyNamed('timeframe'), 234 - limit: anyNamed('limit'), 235 - cursor: anyNamed('cursor'), 236 - ), 237 - ).thenAnswer((_) async => response); 238 - 239 - await feedProvider.fetchTimeline(refresh: true); 240 - 241 - expect(feedProvider.hasMore, false); 242 - }); 243 - }); 244 - 245 - group('fetchDiscover', () { 246 - test('should fetch discover feed successfully', () async { 247 - final mockResponse = TimelineResponse( 248 - feed: [_createMockPost()], 249 - cursor: 'next-cursor', 250 - ); 251 - 252 - when( 253 - mockApiService.getDiscover( 254 - sort: anyNamed('sort'), 255 - timeframe: anyNamed('timeframe'), 256 - limit: anyNamed('limit'), 257 - cursor: anyNamed('cursor'), 258 - ), 259 - ).thenAnswer((_) async => mockResponse); 260 - 261 - await feedProvider.fetchDiscover(refresh: true); 262 - 263 - expect(feedProvider.posts.length, 1); 264 - expect(feedProvider.error, null); 265 - }); 266 - 267 - test('should handle empty feed', () async { 268 - final emptyResponse = TimelineResponse(feed: []); 269 - 270 - when( 271 - mockApiService.getDiscover( 272 - sort: anyNamed('sort'), 273 - timeframe: anyNamed('timeframe'), 274 - limit: anyNamed('limit'), 275 - cursor: anyNamed('cursor'), 276 - ), 277 - ).thenAnswer((_) async => emptyResponse); 278 - 279 - await feedProvider.fetchDiscover(refresh: true); 280 - 281 - expect(feedProvider.posts.isEmpty, true); 282 - expect(feedProvider.hasMore, false); 283 - }); 284 - }); 285 - 286 - group('loadMore', () { 287 - test('should load more posts', () async { 288 - when(mockAuthProvider.isAuthenticated).thenReturn(true); 289 - 290 - // Initial load 291 - final firstResponse = TimelineResponse( 292 - feed: [_createMockPost()], 293 - cursor: 'cursor-1', 294 - ); 295 - 296 - when( 297 - mockApiService.getTimeline( 298 - sort: anyNamed('sort'), 299 - timeframe: anyNamed('timeframe'), 300 - limit: anyNamed('limit'), 301 - cursor: anyNamed('cursor'), 302 - ), 303 - ).thenAnswer((_) async => firstResponse); 304 - 305 - await feedProvider.setFeedType(FeedType.forYou); 306 - 307 - // Load more 308 - final secondResponse = TimelineResponse( 309 - feed: [_createMockPost()], 310 - cursor: 'cursor-2', 311 - ); 312 - 313 - when( 314 - mockApiService.getTimeline( 315 - sort: anyNamed('sort'), 316 - timeframe: anyNamed('timeframe'), 317 - limit: anyNamed('limit'), 318 - cursor: 'cursor-1', 319 - ), 320 - ).thenAnswer((_) async => secondResponse); 321 - 322 - await feedProvider.loadMore(); 323 - 324 - expect(feedProvider.posts.length, 2); 325 - }); 326 - 327 - test('should not load more if already loading', () async { 328 - when(mockAuthProvider.isAuthenticated).thenReturn(true); 329 - 330 - final response = TimelineResponse( 331 - feed: [_createMockPost()], 332 - cursor: 'cursor-1', 333 - ); 334 - 335 - when( 336 - mockApiService.getTimeline( 337 - sort: anyNamed('sort'), 338 - timeframe: anyNamed('timeframe'), 339 - limit: anyNamed('limit'), 340 - cursor: anyNamed('cursor'), 341 - ), 342 - ).thenAnswer((_) async => response); 343 - 344 - await feedProvider.setFeedType(FeedType.forYou); 345 - await feedProvider.loadMore(); 346 - 347 - // Should not make additional calls while loading 348 - }); 349 - 350 - test('should not load more if hasMore is false', () async { 351 - final response = TimelineResponse(feed: [_createMockPost()]); 352 - 353 - when( 354 - mockApiService.getTimeline( 355 - sort: anyNamed('sort'), 356 - timeframe: anyNamed('timeframe'), 357 - limit: anyNamed('limit'), 358 - cursor: anyNamed('cursor'), 359 - ), 360 - ).thenAnswer((_) async => response); 361 - 362 - await feedProvider.fetchTimeline(refresh: true); 363 - expect(feedProvider.hasMore, false); 364 - 365 - await feedProvider.loadMore(); 366 - // Should not attempt to load more 367 - }); 368 - }); 369 - 370 - group('retry', () { 371 - test('should retry after error', () async { 372 - when(mockAuthProvider.isAuthenticated).thenReturn(true); 373 - 374 - // Simulate error 375 - when( 376 - mockApiService.getTimeline( 377 - sort: anyNamed('sort'), 378 - timeframe: anyNamed('timeframe'), 379 - limit: anyNamed('limit'), 380 - cursor: anyNamed('cursor'), 381 - ), 382 - ).thenThrow(Exception('Network error')); 383 - 384 - await feedProvider.setFeedType(FeedType.forYou); 385 - expect(feedProvider.error, isNotNull); 386 - 387 - // Retry 388 - final successResponse = TimelineResponse( 389 - feed: [_createMockPost()], 390 - cursor: 'cursor', 391 - ); 392 - 393 - when( 394 - mockApiService.getTimeline( 395 - sort: anyNamed('sort'), 396 - timeframe: anyNamed('timeframe'), 397 - limit: anyNamed('limit'), 398 - cursor: anyNamed('cursor'), 399 - ), 400 - ).thenAnswer((_) async => successResponse); 401 - 402 - await feedProvider.retry(); 403 - 404 - expect(feedProvider.error, null); 405 - expect(feedProvider.posts.length, 1); 406 - }); 407 - }); 408 - 409 - group('State Management', () { 410 - test('should notify listeners on state change', () async { 411 - var notificationCount = 0; 412 - feedProvider.addListener(() { 413 - notificationCount++; 414 - }); 415 - 416 - final mockResponse = TimelineResponse( 417 - feed: [_createMockPost()], 418 - cursor: 'cursor', 419 - ); 420 - 421 - when( 422 - mockApiService.getTimeline( 423 - sort: anyNamed('sort'), 424 - timeframe: anyNamed('timeframe'), 425 - limit: anyNamed('limit'), 426 - cursor: anyNamed('cursor'), 427 - ), 428 - ).thenAnswer((_) async => mockResponse); 429 - 430 - await feedProvider.fetchTimeline(refresh: true); 431 - 432 - expect(notificationCount, greaterThan(0)); 433 - }); 434 - 435 - test('should manage loading states correctly', () async { 436 - final mockResponse = TimelineResponse( 437 - feed: [_createMockPost()], 438 - cursor: 'cursor', 439 - ); 440 - 441 - when( 442 - mockApiService.getTimeline( 443 - sort: anyNamed('sort'), 444 - timeframe: anyNamed('timeframe'), 445 - limit: anyNamed('limit'), 446 - cursor: anyNamed('cursor'), 447 - ), 448 - ).thenAnswer((_) async { 449 - await Future.delayed(const Duration(milliseconds: 100)); 450 - return mockResponse; 451 - }); 452 - 453 - final loadFuture = feedProvider.fetchTimeline(refresh: true); 454 - 455 - // Should be loading 456 - expect(feedProvider.isLoading, true); 457 - 458 - await loadFuture; 459 - 460 - // Should not be loading anymore 461 - expect(feedProvider.isLoading, false); 462 - }); 463 - }); 464 - 465 - group('Vote state initialization from viewer data', () { 466 - late MockVoteProvider mockVoteProvider; 467 - late FeedProvider feedProviderWithVotes; 468 - 469 - setUp(() { 470 - mockVoteProvider = MockVoteProvider(); 471 - feedProviderWithVotes = FeedProvider( 472 - mockAuthProvider, 473 - apiService: mockApiService, 474 - voteProvider: mockVoteProvider, 475 - ); 476 - }); 477 - 478 - tearDown(() { 479 - feedProviderWithVotes.dispose(); 480 - }); 481 - 482 - test('should initialize vote state when viewer.vote is "up"', () async { 483 - when(mockAuthProvider.isAuthenticated).thenReturn(true); 484 - 485 - final mockResponse = TimelineResponse( 486 - feed: [ 487 - _createMockPostWithViewer( 488 - uri: 'at://did:plc:test/social.coves.post.record/1', 489 - vote: 'up', 490 - voteUri: 'at://did:plc:test/social.coves.feed.vote/vote1', 491 - ), 492 - ], 493 - cursor: 'cursor', 494 - ); 495 - 496 - when( 497 - mockApiService.getTimeline( 498 - sort: anyNamed('sort'), 499 - timeframe: anyNamed('timeframe'), 500 - limit: anyNamed('limit'), 501 - cursor: anyNamed('cursor'), 502 - ), 503 - ).thenAnswer((_) async => mockResponse); 504 - 505 - await feedProviderWithVotes.fetchTimeline(refresh: true); 506 - 507 - verify( 508 - mockVoteProvider.setInitialVoteState( 509 - postUri: 'at://did:plc:test/social.coves.post.record/1', 510 - voteDirection: 'up', 511 - voteUri: 'at://did:plc:test/social.coves.feed.vote/vote1', 512 - ), 513 - ).called(1); 514 - }); 515 - 516 - test('should initialize vote state when viewer.vote is "down"', () async { 517 - when(mockAuthProvider.isAuthenticated).thenReturn(true); 518 - 519 - final mockResponse = TimelineResponse( 520 - feed: [ 521 - _createMockPostWithViewer( 522 - uri: 'at://did:plc:test/social.coves.post.record/1', 523 - vote: 'down', 524 - voteUri: 'at://did:plc:test/social.coves.feed.vote/vote1', 525 - ), 526 - ], 527 - cursor: 'cursor', 528 - ); 529 - 530 - when( 531 - mockApiService.getTimeline( 532 - sort: anyNamed('sort'), 533 - timeframe: anyNamed('timeframe'), 534 - limit: anyNamed('limit'), 535 - cursor: anyNamed('cursor'), 536 - ), 537 - ).thenAnswer((_) async => mockResponse); 538 - 539 - await feedProviderWithVotes.fetchTimeline(refresh: true); 540 - 541 - verify( 542 - mockVoteProvider.setInitialVoteState( 543 - postUri: 'at://did:plc:test/social.coves.post.record/1', 544 - voteDirection: 'down', 545 - voteUri: 'at://did:plc:test/social.coves.feed.vote/vote1', 546 - ), 547 - ).called(1); 548 - }); 549 - 550 - test( 551 - 'should clear stale vote state when viewer.vote is null on refresh', 552 - () async { 553 - when(mockAuthProvider.isAuthenticated).thenReturn(true); 554 - 555 - // Feed item with null vote (user removed vote on another device) 556 - final mockResponse = TimelineResponse( 557 - feed: [ 558 - _createMockPostWithViewer( 559 - uri: 'at://did:plc:test/social.coves.post.record/1', 560 - vote: null, 561 - voteUri: null, 562 - ), 563 - ], 564 - cursor: 'cursor', 565 - ); 566 - 567 - when( 568 - mockApiService.getTimeline( 569 - sort: anyNamed('sort'), 570 - timeframe: anyNamed('timeframe'), 571 - limit: anyNamed('limit'), 572 - cursor: anyNamed('cursor'), 573 - ), 574 - ).thenAnswer((_) async => mockResponse); 575 - 576 - await feedProviderWithVotes.fetchTimeline(refresh: true); 577 - 578 - // Should call setInitialVoteState with null to clear stale state 579 - verify( 580 - mockVoteProvider.setInitialVoteState( 581 - postUri: 'at://did:plc:test/social.coves.post.record/1', 582 - voteDirection: null, 583 - voteUri: null, 584 - ), 585 - ).called(1); 586 - }, 587 - ); 588 - 589 - test( 590 - 'should initialize vote state for all feed items including no viewer', 591 - () async { 592 - when(mockAuthProvider.isAuthenticated).thenReturn(true); 593 - 594 - final mockResponse = TimelineResponse( 595 - feed: [ 596 - _createMockPostWithViewer( 597 - uri: 'at://did:plc:test/social.coves.post.record/1', 598 - vote: 'up', 599 - voteUri: 'at://did:plc:test/social.coves.feed.vote/vote1', 600 - ), 601 - _createMockPost(), // No viewer state 602 - ], 603 - cursor: 'cursor', 604 - ); 605 - 606 - when( 607 - mockApiService.getTimeline( 608 - sort: anyNamed('sort'), 609 - timeframe: anyNamed('timeframe'), 610 - limit: anyNamed('limit'), 611 - cursor: anyNamed('cursor'), 612 - ), 613 - ).thenAnswer((_) async => mockResponse); 614 - 615 - await feedProviderWithVotes.fetchTimeline(refresh: true); 616 - 617 - // Should be called for both posts 618 - verify( 619 - mockVoteProvider.setInitialVoteState( 620 - postUri: anyNamed('postUri'), 621 - voteDirection: anyNamed('voteDirection'), 622 - voteUri: anyNamed('voteUri'), 623 - ), 624 - ).called(2); 625 - }, 626 - ); 627 - 628 - test('should not initialize vote state when not authenticated', () async { 629 - when(mockAuthProvider.isAuthenticated).thenReturn(false); 630 - 631 - final mockResponse = TimelineResponse( 632 - feed: [ 633 - _createMockPostWithViewer( 634 - uri: 'at://did:plc:test/social.coves.post.record/1', 635 - vote: 'up', 636 - voteUri: 'at://did:plc:test/social.coves.feed.vote/vote1', 637 - ), 638 - ], 639 - cursor: 'cursor', 640 - ); 641 - 642 - when( 643 - mockApiService.getDiscover( 644 - sort: anyNamed('sort'), 645 - timeframe: anyNamed('timeframe'), 646 - limit: anyNamed('limit'), 647 - cursor: anyNamed('cursor'), 648 - ), 649 - ).thenAnswer((_) async => mockResponse); 650 - 651 - await feedProviderWithVotes.fetchDiscover(refresh: true); 652 - 653 - // Should NOT call setInitialVoteState when not authenticated 654 - verifyNever( 655 - mockVoteProvider.setInitialVoteState( 656 - postUri: anyNamed('postUri'), 657 - voteDirection: anyNamed('voteDirection'), 658 - voteUri: anyNamed('voteUri'), 659 - ), 660 - ); 661 - }); 662 - }); 663 - }); 664 - } 665 - 666 - // Helper function to create mock posts 667 - FeedViewPost _createMockPost() { 668 - return FeedViewPost( 669 - post: PostView( 670 - uri: 'at://did:plc:test/app.bsky.feed.post/test', 671 - cid: 'test-cid', 672 - rkey: 'test-rkey', 673 - author: AuthorView( 674 - did: 'did:plc:author', 675 - handle: 'test.user', 676 - displayName: 'Test User', 677 - ), 678 - community: CommunityRef(did: 'did:plc:community', name: 'test-community'), 679 - createdAt: DateTime.parse('2025-01-01T12:00:00Z'), 680 - indexedAt: DateTime.parse('2025-01-01T12:00:00Z'), 681 - text: 'Test body', 682 - title: 'Test Post', 683 - stats: PostStats(score: 42, upvotes: 50, downvotes: 8, commentCount: 5), 684 - facets: [], 685 - ), 686 - ); 687 - } 688 - 689 - // Helper function to create mock posts with viewer state 690 - FeedViewPost _createMockPostWithViewer({ 691 - required String uri, 692 - String? vote, 693 - String? voteUri, 694 - }) { 695 - return FeedViewPost( 696 - post: PostView( 697 - uri: uri, 698 - cid: 'test-cid', 699 - rkey: 'test-rkey', 700 - author: AuthorView( 701 - did: 'did:plc:author', 702 - handle: 'test.user', 703 - displayName: 'Test User', 704 - ), 705 - community: CommunityRef(did: 'did:plc:community', name: 'test-community'), 706 - createdAt: DateTime.parse('2025-01-01T12:00:00Z'), 707 - indexedAt: DateTime.parse('2025-01-01T12:00:00Z'), 708 - text: 'Test body', 709 - title: 'Test Post', 710 - stats: PostStats(score: 42, upvotes: 50, downvotes: 8, commentCount: 5), 711 - facets: [], 712 - viewer: ViewerState(vote: vote, voteUri: voteUri), 713 - ), 714 - ); 715 - }
+4 -2
test/widget_test.dart
··· 1 1 import 'package:coves_flutter/main.dart'; 2 2 import 'package:coves_flutter/providers/auth_provider.dart'; 3 - import 'package:coves_flutter/providers/feed_provider.dart'; 3 + import 'package:coves_flutter/providers/multi_feed_provider.dart'; 4 4 import 'package:flutter/material.dart'; 5 5 import 'package:flutter_test/flutter_test.dart'; 6 6 import 'package:provider/provider.dart'; ··· 15 15 MultiProvider( 16 16 providers: [ 17 17 ChangeNotifierProvider.value(value: authProvider), 18 - ChangeNotifierProvider(create: (_) => FeedProvider(authProvider)), 18 + ChangeNotifierProvider( 19 + create: (_) => MultiFeedProvider(authProvider), 20 + ), 19 21 ], 20 22 child: const CovesApp(), 21 23 ),
+84 -55
test/widgets/feed_screen_test.dart
··· 1 + import 'package:coves_flutter/models/feed_state.dart'; 1 2 import 'package:coves_flutter/models/post.dart'; 2 3 import 'package:coves_flutter/providers/auth_provider.dart'; 3 - import 'package:coves_flutter/providers/feed_provider.dart'; 4 + import 'package:coves_flutter/providers/multi_feed_provider.dart'; 4 5 import 'package:coves_flutter/providers/vote_provider.dart'; 5 6 import 'package:coves_flutter/screens/home/feed_screen.dart'; 6 7 import 'package:coves_flutter/services/vote_service.dart'; ··· 52 53 } 53 54 } 54 55 55 - // Fake FeedProvider for testing 56 - class FakeFeedProvider extends FeedProvider { 57 - FakeFeedProvider() : super(FakeAuthProvider()); 56 + // Fake MultiFeedProvider for testing 57 + class FakeMultiFeedProvider extends MultiFeedProvider { 58 + FakeMultiFeedProvider() : super(FakeAuthProvider()); 58 59 59 - List<FeedViewPost> _posts = []; 60 - bool _isLoading = false; 61 - bool _isLoadingMore = false; 62 - String? _error; 63 - bool _hasMore = true; 60 + final Map<FeedType, FeedState> _states = { 61 + FeedType.discover: FeedState.initial(), 62 + FeedType.forYou: FeedState.initial(), 63 + }; 64 + 64 65 int _loadFeedCallCount = 0; 65 66 int _retryCallCount = 0; 66 67 67 - @override 68 - List<FeedViewPost> get posts => _posts; 69 - 70 - @override 71 - bool get isLoading => _isLoading; 72 - 73 - @override 74 - bool get isLoadingMore => _isLoadingMore; 75 - 76 - @override 77 - String? get error => _error; 68 + int get loadFeedCallCount => _loadFeedCallCount; 69 + int get retryCallCount => _retryCallCount; 78 70 79 71 @override 80 - bool get hasMore => _hasMore; 72 + FeedState getState(FeedType type) => _states[type] ?? FeedState.initial(); 81 73 82 - int get loadFeedCallCount => _loadFeedCallCount; 83 - int get retryCallCount => _retryCallCount; 74 + void setStateForType(FeedType type, FeedState state) { 75 + _states[type] = state; 76 + notifyListeners(); 77 + } 84 78 85 - void setPosts(List<FeedViewPost> value) { 86 - _posts = value; 79 + void setPosts(FeedType type, List<FeedViewPost> posts) { 80 + _states[type] = _states[type]!.copyWith(posts: posts); 87 81 notifyListeners(); 88 82 } 89 83 90 - void setLoading({required bool value}) { 91 - _isLoading = value; 84 + void setLoading(FeedType type, {required bool value}) { 85 + _states[type] = _states[type]!.copyWith(isLoading: value); 92 86 notifyListeners(); 93 87 } 94 88 95 - void setLoadingMore({required bool value}) { 96 - _isLoadingMore = value; 89 + void setLoadingMore(FeedType type, {required bool value}) { 90 + _states[type] = _states[type]!.copyWith(isLoadingMore: value); 97 91 notifyListeners(); 98 92 } 99 93 100 - void setError(String? value) { 101 - _error = value; 94 + void setError(FeedType type, String? value) { 95 + _states[type] = _states[type]!.copyWith(error: value); 102 96 notifyListeners(); 103 97 } 104 98 105 - void setHasMore({required bool value}) { 106 - _hasMore = value; 99 + void setHasMore(FeedType type, {required bool value}) { 100 + _states[type] = _states[type]!.copyWith(hasMore: value); 107 101 notifyListeners(); 108 102 } 109 103 110 104 @override 111 - Future<void> loadFeed({bool refresh = false}) async { 105 + Future<void> loadFeed(FeedType type, {bool refresh = false}) async { 112 106 _loadFeedCallCount++; 113 107 } 114 108 115 109 @override 116 - Future<void> retry() async { 110 + Future<void> retry(FeedType type) async { 117 111 _retryCallCount++; 118 112 } 119 113 120 114 @override 121 - Future<void> loadMore() async { 115 + Future<void> loadMore(FeedType type) async { 116 + // No-op for testing 117 + } 118 + 119 + @override 120 + void saveScrollPosition(FeedType type, double position) { 122 121 // No-op for testing 123 122 } 124 123 } ··· 126 125 void main() { 127 126 group('FeedScreen Widget Tests', () { 128 127 late FakeAuthProvider fakeAuthProvider; 129 - late FakeFeedProvider fakeFeedProvider; 128 + late FakeMultiFeedProvider fakeFeedProvider; 130 129 late FakeVoteProvider fakeVoteProvider; 131 130 132 131 setUp(() { 133 132 fakeAuthProvider = FakeAuthProvider(); 134 - fakeFeedProvider = FakeFeedProvider(); 133 + fakeFeedProvider = FakeMultiFeedProvider(); 135 134 fakeVoteProvider = FakeVoteProvider(); 136 135 }); 137 136 ··· 139 138 return MultiProvider( 140 139 providers: [ 141 140 ChangeNotifierProvider<AuthProvider>.value(value: fakeAuthProvider), 142 - ChangeNotifierProvider<FeedProvider>.value(value: fakeFeedProvider), 141 + ChangeNotifierProvider<MultiFeedProvider>.value( 142 + value: fakeFeedProvider, 143 + ), 143 144 ChangeNotifierProvider<VoteProvider>.value(value: fakeVoteProvider), 144 145 ], 145 146 child: const MaterialApp(home: FeedScreen()), ··· 149 150 testWidgets('should display loading indicator when loading', ( 150 151 tester, 151 152 ) async { 152 - fakeFeedProvider.setLoading(value: true); 153 + fakeFeedProvider.setLoading(FeedType.discover, value: true); 153 154 154 155 await tester.pumpWidget(createTestWidget()); 155 156 ··· 157 158 }); 158 159 159 160 testWidgets('should display error state with retry button', (tester) async { 160 - fakeFeedProvider.setError('Network error'); 161 + fakeFeedProvider.setError(FeedType.discover, 'Network error'); 161 162 162 163 await tester.pumpWidget(createTestWidget()); 163 164 ··· 177 178 }); 178 179 179 180 testWidgets('should display empty state when no posts', (tester) async { 180 - fakeFeedProvider.setPosts([]); 181 + fakeFeedProvider.setPosts(FeedType.discover, []); 181 182 fakeAuthProvider.setAuthenticated(value: false); 182 183 183 184 await tester.pumpWidget(createTestWidget()); ··· 189 190 testWidgets('should display different empty state when authenticated', ( 190 191 tester, 191 192 ) async { 192 - fakeFeedProvider.setPosts([]); 193 + fakeFeedProvider.setPosts(FeedType.discover, []); 193 194 fakeAuthProvider.setAuthenticated(value: true); 194 195 195 196 await tester.pumpWidget(createTestWidget()); ··· 207 208 _createMockPost('Test Post 2'), 208 209 ]; 209 210 210 - fakeFeedProvider.setPosts(mockPosts); 211 + fakeFeedProvider.setPosts(FeedType.discover, mockPosts); 211 212 212 213 await tester.pumpWidget(createTestWidget()); 213 214 ··· 239 240 240 241 testWidgets('should handle pull-to-refresh', (tester) async { 241 242 final mockPosts = [_createMockPost('Test Post')]; 242 - fakeFeedProvider.setPosts(mockPosts); 243 + fakeFeedProvider.setPosts(FeedType.discover, mockPosts); 243 244 244 245 await tester.pumpWidget(createTestWidget()); 245 246 await tester.pumpAndSettle(); ··· 247 248 // Verify RefreshIndicator exists 248 249 expect(find.byType(RefreshIndicator), findsOneWidget); 249 250 250 - // The loadFeed is called once on init 251 - expect(fakeFeedProvider.loadFeedCallCount, 1); 251 + // loadFeed is called once for initial load (or twice if authenticated) 252 + expect(fakeFeedProvider.loadFeedCallCount, greaterThanOrEqualTo(1)); 252 253 }); 253 254 254 255 testWidgets('should show loading indicator at bottom when loading more', ( ··· 256 257 ) async { 257 258 final mockPosts = [_createMockPost('Test Post')]; 258 259 fakeFeedProvider 259 - ..setPosts(mockPosts) 260 - ..setLoadingMore(value: true); 260 + ..setPosts(FeedType.discover, mockPosts) 261 + ..setLoadingMore(FeedType.discover, value: true); 261 262 262 263 await tester.pumpWidget(createTestWidget()); 263 264 ··· 303 304 ), 304 305 ); 305 306 306 - fakeFeedProvider.setPosts([mockPost]); 307 + fakeFeedProvider.setPosts(FeedType.discover, [mockPost]); 307 308 308 309 await tester.pumpWidget(createTestWidget()); 309 310 ··· 313 314 314 315 testWidgets('should display community and author info', (tester) async { 315 316 final mockPost = _createMockPost('Test Post'); 316 - fakeFeedProvider.setPosts([mockPost]); 317 + fakeFeedProvider.setPosts(FeedType.discover, [mockPost]); 317 318 318 319 await tester.pumpWidget(createTestWidget()); 319 320 320 - // Check for community handle parts (displayed as !test-community@coves.social) 321 + // Check for community handle parts (displayed as !test-community@...) 321 322 expect(find.textContaining('!test-community'), findsOneWidget); 322 323 expect(find.text('@test.user'), findsOneWidget); 323 324 }); ··· 326 327 await tester.pumpWidget(createTestWidget()); 327 328 await tester.pumpAndSettle(); 328 329 329 - expect(fakeFeedProvider.loadFeedCallCount, 1); 330 + expect(fakeFeedProvider.loadFeedCallCount, greaterThanOrEqualTo(1)); 330 331 }); 331 332 332 333 testWidgets('should have proper accessibility semantics', (tester) async { 333 334 final mockPost = _createMockPost('Accessible Post'); 334 - fakeFeedProvider.setPosts([mockPost]); 335 + fakeFeedProvider.setPosts(FeedType.discover, [mockPost]); 335 336 336 337 await tester.pumpWidget(createTestWidget()); 337 338 await tester.pumpAndSettle(); ··· 341 342 342 343 // Verify post card exists (which contains Semantics wrapper) 343 344 expect(find.text('Accessible Post'), findsOneWidget); 344 - // Check for community handle parts (displayed as !test-community@coves.social) 345 + // Check for community handle parts 345 346 expect(find.textContaining('!test-community'), findsOneWidget); 346 347 expect(find.textContaining('@coves.social'), findsOneWidget); 347 348 }); ··· 355 356 356 357 // If we get here without errors, dispose was called properly 357 358 expect(true, true); 359 + }); 360 + 361 + testWidgets('should support swipe navigation when authenticated', ( 362 + tester, 363 + ) async { 364 + fakeAuthProvider.setAuthenticated(value: true); 365 + fakeFeedProvider 366 + ..setPosts(FeedType.discover, [_createMockPost('Post 1')]) 367 + ..setPosts(FeedType.forYou, [_createMockPost('Post 2')]); 368 + 369 + await tester.pumpWidget(createTestWidget()); 370 + await tester.pumpAndSettle(); 371 + 372 + // PageView should exist for authenticated users 373 + expect(find.byType(PageView), findsOneWidget); 374 + }); 375 + 376 + testWidgets('should not have PageView when not authenticated', ( 377 + tester, 378 + ) async { 379 + fakeAuthProvider.setAuthenticated(value: false); 380 + fakeFeedProvider.setPosts(FeedType.discover, [_createMockPost('Post 1')]); 381 + 382 + await tester.pumpWidget(createTestWidget()); 383 + await tester.pumpAndSettle(); 384 + 385 + // PageView should not exist for unauthenticated users 386 + expect(find.byType(PageView), findsNothing); 358 387 }); 359 388 }); 360 389 }