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 to API service for automatic fresh token retrieval 28 _apiService = 29 apiService ?? 30 CovesApiService(tokenGetter: _authProvider.getAccessToken); 31 32 // Track initial auth state 33 _wasAuthenticated = _authProvider.isAuthenticated; 34 35 // [P0 FIX] Listen to auth state changes and clear feed on sign-out 36 // This prevents privacy bug where logged-out users see their private 37 // timeline until they manually refresh. 38 _authProvider.addListener(_onAuthChanged); 39 } 40 41 /// Handle authentication state changes 42 /// 43 /// Only clears and reloads feed when transitioning from authenticated 44 /// to unauthenticated (actual sign-out), not when staying unauthenticated 45 /// (e.g., failed sign-in attempt). This prevents unnecessary API calls. 46 void _onAuthChanged() { 47 final isAuthenticated = _authProvider.isAuthenticated; 48 49 // Only reload if transitioning from authenticated → unauthenticated 50 if (_wasAuthenticated && !isAuthenticated && _posts.isNotEmpty) { 51 if (kDebugMode) { 52 debugPrint('🔒 User signed out - clearing feed'); 53 } 54 reset(); 55 // Automatically load the public discover feed 56 loadFeed(refresh: true); 57 } 58 59 // Update tracked state 60 _wasAuthenticated = isAuthenticated; 61 } 62 63 final AuthProvider _authProvider; 64 late final CovesApiService _apiService; 65 final VoteProvider? _voteProvider; 66 final VoteService? _voteService; 67 68 // Track previous auth state to detect transitions 69 bool _wasAuthenticated = false; 70 71 // Feed state 72 List<FeedViewPost> _posts = []; 73 bool _isLoading = false; 74 bool _isLoadingMore = false; 75 String? _error; 76 String? _cursor; 77 bool _hasMore = true; 78 79 // Feed configuration 80 String _sort = 'hot'; 81 String? _timeframe; 82 83 // Time update mechanism for periodic UI refreshes 84 Timer? _timeUpdateTimer; 85 DateTime? _currentTime; 86 87 // Getters 88 List<FeedViewPost> get posts => _posts; 89 bool get isLoading => _isLoading; 90 bool get isLoadingMore => _isLoadingMore; 91 String? get error => _error; 92 bool get hasMore => _hasMore; 93 String get sort => _sort; 94 String? get timeframe => _timeframe; 95 DateTime? get currentTime => _currentTime; 96 97 /// Start periodic time updates for "time ago" strings 98 /// 99 /// Updates currentTime every minute to trigger UI rebuilds for 100 /// post timestamps. This ensures "5m ago" updates to "6m ago" without 101 /// requiring user interaction. 102 void startTimeUpdates() { 103 // Cancel existing timer if any 104 _timeUpdateTimer?.cancel(); 105 106 // Update current time immediately 107 _currentTime = DateTime.now(); 108 notifyListeners(); 109 110 // Set up periodic updates (every minute) 111 _timeUpdateTimer = Timer.periodic(const Duration(minutes: 1), (_) { 112 _currentTime = DateTime.now(); 113 notifyListeners(); 114 }); 115 116 if (kDebugMode) { 117 debugPrint('⏰ Started periodic time updates for feed timestamps'); 118 } 119 } 120 121 /// Stop periodic time updates 122 void stopTimeUpdates() { 123 _timeUpdateTimer?.cancel(); 124 _timeUpdateTimer = null; 125 _currentTime = null; 126 127 if (kDebugMode) { 128 debugPrint('⏰ Stopped periodic time updates'); 129 } 130 } 131 132 /// Load feed based on authentication state (business logic 133 /// encapsulation) 134 /// 135 /// This method encapsulates the business logic of deciding which feed 136 /// to fetch. Previously this logic was in the UI layer (FeedScreen), 137 /// violating clean architecture. 138 Future<void> loadFeed({bool refresh = false}) async { 139 if (_authProvider.isAuthenticated) { 140 await fetchTimeline(refresh: refresh); 141 } else { 142 await fetchDiscover(refresh: refresh); 143 } 144 145 // Start time updates when feed is loaded 146 if (_posts.isNotEmpty && _timeUpdateTimer == null) { 147 startTimeUpdates(); 148 } 149 } 150 151 /// Common feed fetching logic (DRY principle - eliminates code 152 /// duplication) 153 Future<void> _fetchFeed({ 154 required bool refresh, 155 required Future<TimelineResponse> Function() fetcher, 156 required String feedName, 157 }) async { 158 if (_isLoading || _isLoadingMore) { 159 return; 160 } 161 162 try { 163 if (refresh) { 164 _isLoading = true; 165 // DON'T clear _posts, _cursor, or _hasMore yet 166 // Keep existing data visible until refresh succeeds 167 // This prevents transient failures from wiping the user's feed 168 // and pagination state 169 _error = null; 170 } else { 171 _isLoadingMore = true; 172 } 173 notifyListeners(); 174 175 final response = await fetcher(); 176 177 // Only update state after successful fetch 178 if (refresh) { 179 _posts = response.feed; 180 } else { 181 // Create new list instance to trigger context.select rebuilds 182 // Using spread operator instead of addAll to ensure reference changes 183 _posts = [..._posts, ...response.feed]; 184 } 185 186 _cursor = response.cursor; 187 _hasMore = response.cursor != null; 188 _error = null; 189 190 if (kDebugMode) { 191 debugPrint('$feedName loaded: ${_posts.length} posts total'); 192 } 193 194 // Load initial vote state from PDS (only if authenticated) 195 if (_authProvider.isAuthenticated && 196 _voteProvider != null && 197 _voteService != null) { 198 try { 199 final userVotes = await _voteService.getUserVotes(); 200 _voteProvider.loadInitialVotes(userVotes); 201 } on Exception catch (e) { 202 if (kDebugMode) { 203 debugPrint('⚠️ Failed to load vote state: $e'); 204 } 205 // Don't fail the feed load if vote loading fails 206 // Keep silent per PR review discussion 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}