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