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 Provider 10/// 11/// Manages feed state and fetching logic. 12/// Supports both authenticated timeline and public discover feed. 13/// 14/// IMPORTANT: Accepts AuthProvider reference to fetch fresh access 15/// tokens before each authenticated request (critical for atProto OAuth 16/// token rotation). 17class FeedProvider with ChangeNotifier { 18 FeedProvider( 19 this._authProvider, { 20 CovesApiService? apiService, 21 VoteProvider? voteProvider, 22 }) : _voteProvider = voteProvider { 23 // Use injected service (for testing) or create new one (for production) 24 // Pass token getter, refresh handler, and sign out handler to API service 25 // for automatic fresh token retrieval and automatic token refresh on 401 26 _apiService = 27 apiService ?? 28 CovesApiService( 29 tokenGetter: _authProvider.getAccessToken, 30 tokenRefresher: _authProvider.refreshToken, 31 signOutHandler: _authProvider.signOut, 32 ); 33 34 // Track initial auth state 35 _wasAuthenticated = _authProvider.isAuthenticated; 36 37 // [P0 FIX] Listen to auth state changes and clear feed on sign-out 38 // This prevents privacy bug where logged-out users see their private 39 // timeline until they manually refresh. 40 _authProvider.addListener(_onAuthChanged); 41 } 42 43 /// Handle authentication state changes 44 /// 45 /// Only clears and reloads feed when transitioning from authenticated 46 /// to unauthenticated (actual sign-out), not when staying unauthenticated 47 /// (e.g., failed sign-in attempt). This prevents unnecessary API calls. 48 void _onAuthChanged() { 49 final isAuthenticated = _authProvider.isAuthenticated; 50 51 // Only reload if transitioning from authenticated → unauthenticated 52 if (_wasAuthenticated && !isAuthenticated && _posts.isNotEmpty) { 53 if (kDebugMode) { 54 debugPrint('🔒 User signed out - clearing feed'); 55 } 56 reset(); 57 // Automatically load the public discover feed 58 loadFeed(refresh: true); 59 } 60 61 // Update tracked state 62 _wasAuthenticated = isAuthenticated; 63 } 64 65 final AuthProvider _authProvider; 66 late final CovesApiService _apiService; 67 final VoteProvider? _voteProvider; 68 69 // Track previous auth state to detect transitions 70 bool _wasAuthenticated = false; 71 72 // Feed state 73 List<FeedViewPost> _posts = []; 74 bool _isLoading = false; 75 bool _isLoadingMore = false; 76 String? _error; 77 String? _cursor; 78 bool _hasMore = true; 79 80 // Feed configuration 81 String _sort = 'hot'; 82 String? _timeframe; 83 84 // Time update mechanism for periodic UI refreshes 85 Timer? _timeUpdateTimer; 86 DateTime? _currentTime; 87 88 // Getters 89 List<FeedViewPost> get posts => _posts; 90 bool get isLoading => _isLoading; 91 bool get isLoadingMore => _isLoadingMore; 92 String? get error => _error; 93 bool get hasMore => _hasMore; 94 String get sort => _sort; 95 String? get timeframe => _timeframe; 96 DateTime? get currentTime => _currentTime; 97 98 /// Start periodic time updates for "time ago" strings 99 /// 100 /// Updates currentTime every minute to trigger UI rebuilds for 101 /// post timestamps. This ensures "5m ago" updates to "6m ago" without 102 /// requiring user interaction. 103 void startTimeUpdates() { 104 // Cancel existing timer if any 105 _timeUpdateTimer?.cancel(); 106 107 // Update current time immediately 108 _currentTime = DateTime.now(); 109 notifyListeners(); 110 111 // Set up periodic updates (every minute) 112 _timeUpdateTimer = Timer.periodic(const Duration(minutes: 1), (_) { 113 _currentTime = DateTime.now(); 114 notifyListeners(); 115 }); 116 117 if (kDebugMode) { 118 debugPrint('⏰ Started periodic time updates for feed timestamps'); 119 } 120 } 121 122 /// Stop periodic time updates 123 void stopTimeUpdates() { 124 _timeUpdateTimer?.cancel(); 125 _timeUpdateTimer = null; 126 _currentTime = null; 127 128 if (kDebugMode) { 129 debugPrint('⏰ Stopped periodic time updates'); 130 } 131 } 132 133 /// Load feed based on authentication state (business logic 134 /// encapsulation) 135 /// 136 /// This method encapsulates the business logic of deciding which feed 137 /// to fetch. Previously this logic was in the UI layer (FeedScreen), 138 /// violating clean architecture. 139 Future<void> loadFeed({bool refresh = false}) async { 140 if (_authProvider.isAuthenticated) { 141 await fetchTimeline(refresh: refresh); 142 } else { 143 await fetchDiscover(refresh: refresh); 144 } 145 146 // Start time updates when feed is loaded 147 if (_posts.isNotEmpty && _timeUpdateTimer == null) { 148 startTimeUpdates(); 149 } 150 } 151 152 /// Common feed fetching logic (DRY principle - eliminates code 153 /// duplication) 154 Future<void> _fetchFeed({ 155 required bool refresh, 156 required Future<TimelineResponse> Function() fetcher, 157 required String feedName, 158 }) async { 159 if (_isLoading || _isLoadingMore) { 160 return; 161 } 162 163 try { 164 if (refresh) { 165 _isLoading = true; 166 // DON'T clear _posts, _cursor, or _hasMore yet 167 // Keep existing data visible until refresh succeeds 168 // This prevents transient failures from wiping the user's feed 169 // and pagination state 170 _error = null; 171 } else { 172 _isLoadingMore = true; 173 } 174 notifyListeners(); 175 176 final response = await fetcher(); 177 178 // Only update state after successful fetch 179 if (refresh) { 180 _posts = response.feed; 181 } else { 182 // Create new list instance to trigger context.select rebuilds 183 // Using spread operator instead of addAll to ensure reference changes 184 _posts = [..._posts, ...response.feed]; 185 } 186 187 _cursor = response.cursor; 188 _hasMore = response.cursor != null; 189 _error = null; 190 191 if (kDebugMode) { 192 debugPrint('$feedName loaded: ${_posts.length} posts total'); 193 } 194 195 // Initialize vote state from viewer data in feed response 196 // IMPORTANT: Call setInitialVoteState for ALL feed items, even when 197 // viewer.vote is null. This ensures that if a user removed their vote 198 // on another device, the local state is cleared on refresh. 199 if (_authProvider.isAuthenticated && _voteProvider != null) { 200 for (final feedItem in response.feed) { 201 final viewer = feedItem.post.viewer; 202 _voteProvider.setInitialVoteState( 203 postUri: feedItem.post.uri, 204 voteDirection: viewer?.vote, 205 voteUri: viewer?.voteUri, 206 ); 207 } 208 } 209 } on Exception catch (e) { 210 _error = e.toString(); 211 if (kDebugMode) { 212 debugPrint('❌ Failed to fetch $feedName: $e'); 213 } 214 } finally { 215 _isLoading = false; 216 _isLoadingMore = false; 217 notifyListeners(); 218 } 219 } 220 221 /// Fetch timeline feed (authenticated) 222 /// 223 /// Fetches the user's personalized timeline. 224 /// Authentication is handled automatically via tokenGetter. 225 Future<void> fetchTimeline({bool refresh = false}) => _fetchFeed( 226 refresh: refresh, 227 fetcher: 228 () => _apiService.getTimeline( 229 sort: _sort, 230 timeframe: _timeframe, 231 cursor: refresh ? null : _cursor, 232 ), 233 feedName: 'Timeline', 234 ); 235 236 /// Fetch discover feed (public) 237 /// 238 /// Fetches the public discover feed. 239 /// Does not require authentication. 240 Future<void> fetchDiscover({bool refresh = false}) => _fetchFeed( 241 refresh: refresh, 242 fetcher: 243 () => _apiService.getDiscover( 244 sort: _sort, 245 timeframe: _timeframe, 246 cursor: refresh ? null : _cursor, 247 ), 248 feedName: 'Discover', 249 ); 250 251 /// Load more posts (pagination) 252 Future<void> loadMore() async { 253 if (!_hasMore || _isLoadingMore) { 254 return; 255 } 256 await loadFeed(); 257 } 258 259 /// Change sort order 260 void setSort(String newSort, {String? newTimeframe}) { 261 _sort = newSort; 262 _timeframe = newTimeframe; 263 notifyListeners(); 264 } 265 266 /// Retry loading after error 267 Future<void> retry() async { 268 _error = null; 269 await loadFeed(refresh: true); 270 } 271 272 /// Clear error 273 void clearError() { 274 _error = null; 275 notifyListeners(); 276 } 277 278 /// Reset feed state 279 void reset() { 280 _posts = []; 281 _cursor = null; 282 _hasMore = true; 283 _error = null; 284 _isLoading = false; 285 _isLoadingMore = false; 286 notifyListeners(); 287 } 288 289 @override 290 void dispose() { 291 // Stop time updates and cancel timer 292 stopTimeUpdates(); 293 // Remove auth listener to prevent memory leaks 294 _authProvider.removeListener(_onAuthChanged); 295 _apiService.dispose(); 296 super.dispose(); 297 } 298}