at main 13 kB view raw
1import 'dart:async'; 2 3import 'package:flutter/foundation.dart'; 4import '../models/feed_state.dart'; 5import '../models/post.dart'; 6import '../services/coves_api_service.dart'; 7import 'auth_provider.dart'; 8import 'vote_provider.dart'; 9 10/// Feed types available in the app 11enum 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). 27class 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}