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