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