Main coves client
1import 'dart:async';
2
3import 'package:flutter/foundation.dart';
4import '../models/feed_state.dart';
5import '../models/post.dart';
6import '../services/coves_api_service.dart';
7import 'auth_provider.dart';
8import 'vote_provider.dart';
9
10/// Feed types available in the app
11enum FeedType {
12 /// All posts across the network
13 discover,
14
15 /// Posts from subscribed communities (authenticated only)
16 forYou,
17}
18
19/// Multi-Feed Provider
20///
21/// Manages independent state for multiple feeds (Discover and For You).
22/// Each feed maintains its own posts, scroll position, and pagination state.
23///
24/// IMPORTANT: Accepts AuthProvider reference to fetch fresh access
25/// tokens before each authenticated request (critical for atProto OAuth
26/// token rotation).
27class MultiFeedProvider with ChangeNotifier {
28 MultiFeedProvider(
29 this._authProvider, {
30 CovesApiService? apiService,
31 VoteProvider? voteProvider,
32 }) : _voteProvider = voteProvider {
33 // Use injected service (for testing) or create new one (for production)
34 // Pass token getter, refresh handler, and sign out handler to API service
35 // for automatic fresh token retrieval and automatic token refresh on 401
36 _apiService =
37 apiService ??
38 CovesApiService(
39 tokenGetter: _authProvider.getAccessToken,
40 tokenRefresher: _authProvider.refreshToken,
41 signOutHandler: _authProvider.signOut,
42 );
43
44 // Track initial auth state
45 _wasAuthenticated = _authProvider.isAuthenticated;
46
47 // Listen to auth state changes and clear For You feed on sign-out
48 // This prevents privacy bug where logged-out users see their
49 // private timeline until they manually refresh.
50 _authProvider.addListener(_onAuthChanged);
51 }
52
53 /// Handle authentication state changes
54 ///
55 /// Only clears For You feed when transitioning from authenticated to
56 /// unauthenticated (actual sign-out), not when staying unauthenticated
57 /// (e.g., failed sign-in attempt). This prevents unnecessary API calls.
58 void _onAuthChanged() {
59 final isAuthenticated = _authProvider.isAuthenticated;
60
61 // Only clear For You feed if transitioning from authenticated to
62 // unauthenticated
63 if (_wasAuthenticated && !isAuthenticated) {
64 if (kDebugMode) {
65 debugPrint('🔒 User signed out - clearing For You feed');
66 }
67 // Clear For You feed state, keep Discover intact
68 _feedStates.remove(FeedType.forYou);
69
70 // Switch to Discover if currently on For You
71 if (_currentFeedType == FeedType.forYou) {
72 _currentFeedType = FeedType.discover;
73 }
74
75 notifyListeners();
76 }
77
78 // Update tracked state
79 _wasAuthenticated = isAuthenticated;
80 }
81
82 final AuthProvider _authProvider;
83 late final CovesApiService _apiService;
84 final VoteProvider? _voteProvider;
85
86 // Track previous auth state to detect transitions
87 bool _wasAuthenticated = false;
88
89 // Per-feed state storage
90 final Map<FeedType, FeedState> _feedStates = {};
91
92 // Currently active feed
93 FeedType _currentFeedType = FeedType.discover;
94
95 // Feed configuration (shared across feeds)
96 String _sort = 'hot';
97 String? _timeframe;
98
99 // Time update mechanism for periodic UI refreshes
100 Timer? _timeUpdateTimer;
101 DateTime? _currentTime;
102
103 // Getters
104 FeedType get currentFeedType => _currentFeedType;
105 String get sort => _sort;
106 String? get timeframe => _timeframe;
107 DateTime? get currentTime => _currentTime;
108
109 /// Check if For You feed is available (requires authentication)
110 bool get isForYouAvailable => _authProvider.isAuthenticated;
111
112 /// Get state for a specific feed (creates default if missing)
113 FeedState getState(FeedType type) {
114 return _feedStates[type] ?? FeedState.initial();
115 }
116
117 /// Set the current active feed type
118 ///
119 /// This just updates which feed is active, does NOT load data.
120 /// The UI should call loadFeed() separately if needed.
121 void setCurrentFeed(FeedType type) {
122 if (_currentFeedType == type) {
123 return;
124 }
125
126 // For You requires authentication
127 if (type == FeedType.forYou && !_authProvider.isAuthenticated) {
128 return;
129 }
130
131 _currentFeedType = type;
132 notifyListeners();
133 }
134
135 /// Save scroll position for a feed (passive, no notifyListeners)
136 ///
137 /// This is called frequently during scrolling, so we don't trigger
138 /// rebuilds. The scroll position is persisted in the feed state for
139 /// restoration when the user switches back to this feed.
140 void saveScrollPosition(FeedType type, double position) {
141 final currentState = getState(type);
142 _feedStates[type] = currentState.copyWith(scrollPosition: position);
143 // Intentionally NOT calling notifyListeners() - this is a passive save
144 }
145
146 /// Start periodic time updates for "time ago" strings
147 ///
148 /// Updates currentTime every minute to trigger UI rebuilds for
149 /// post timestamps. This ensures "5m ago" updates to "6m ago" without
150 /// requiring user interaction.
151 void startTimeUpdates() {
152 // Cancel existing timer if any
153 _timeUpdateTimer?.cancel();
154
155 // Update current time immediately
156 _currentTime = DateTime.now();
157 notifyListeners();
158
159 // Set up periodic updates (every minute)
160 _timeUpdateTimer = Timer.periodic(const Duration(minutes: 1), (_) {
161 _currentTime = DateTime.now();
162 notifyListeners();
163 });
164
165 if (kDebugMode) {
166 debugPrint('⏰ Started periodic time updates for feed timestamps');
167 }
168 }
169
170 /// Stop periodic time updates
171 void stopTimeUpdates() {
172 _timeUpdateTimer?.cancel();
173 _timeUpdateTimer = null;
174 _currentTime = null;
175
176 if (kDebugMode) {
177 debugPrint('⏰ Stopped periodic time updates');
178 }
179 }
180
181 /// Load feed based on feed type
182 ///
183 /// This method encapsulates the business logic of deciding which feed
184 /// to fetch based on the selected feed type.
185 Future<void> loadFeed(FeedType type, {bool refresh = false}) async {
186 // For You requires authentication - fall back to Discover if not
187 if (type == FeedType.forYou && _authProvider.isAuthenticated) {
188 await _fetchTimeline(type, refresh: refresh);
189 } else {
190 await _fetchDiscover(type, refresh: refresh);
191 }
192
193 // Start time updates when feed is loaded
194 final state = getState(type);
195 if (state.posts.isNotEmpty && _timeUpdateTimer == null) {
196 startTimeUpdates();
197 }
198 }
199
200 /// Load more posts for a feed (pagination)
201 Future<void> loadMore(FeedType type) async {
202 final state = getState(type);
203
204 if (!state.hasMore || state.isLoadingMore) {
205 return;
206 }
207
208 await loadFeed(type);
209 }
210
211 /// Common feed fetching logic (DRY principle - eliminates code
212 /// duplication)
213 Future<void> _fetchFeed({
214 required FeedType type,
215 required bool refresh,
216 required Future<TimelineResponse> Function() fetcher,
217 required String feedName,
218 }) async {
219 final currentState = getState(type);
220
221 if (currentState.isLoading || currentState.isLoadingMore) {
222 return;
223 }
224
225 // Capture session identity before fetch to detect any auth change
226 // (sign-out, or sign-in as different user) during the request
227 final sessionDidBeforeFetch = _authProvider.did;
228
229 try {
230 if (refresh) {
231 // Start loading, keep existing data visible
232 _feedStates[type] = currentState.copyWith(isLoading: true, error: null);
233 } else {
234 // Pagination
235 _feedStates[type] = currentState.copyWith(isLoadingMore: true);
236 }
237 notifyListeners();
238
239 final response = await fetcher();
240
241 // SECURITY: If session changed during fetch, discard the response
242 // to prevent cross-session data leaks. This handles:
243 // - User signed out (DID became null)
244 // - User signed out and back in as same user (unlikely but safe)
245 // - User signed out and different user signed in (DID changed)
246 // This is especially important for the For You feed which contains
247 // private timeline data.
248 if (type == FeedType.forYou &&
249 sessionDidBeforeFetch != _authProvider.did) {
250 if (kDebugMode) {
251 debugPrint(
252 '🔒 Discarding $feedName response - session changed during fetch',
253 );
254 }
255 // Remove the feed state entirely (don't write back stale data)
256 // _onAuthChanged already removed this, but ensure it stays removed
257 _feedStates.remove(type);
258 notifyListeners();
259 return;
260 }
261
262 // Only update state after successful fetch
263 final List<FeedViewPost> newPosts;
264 if (refresh) {
265 newPosts = response.feed;
266 } else {
267 // Create new list instance to trigger context.select rebuilds
268 // Using spread operator instead of addAll to ensure reference changes
269 newPosts = [...currentState.posts, ...response.feed];
270 }
271
272 _feedStates[type] = currentState.copyWith(
273 posts: newPosts,
274 cursor: response.cursor,
275 hasMore: response.cursor != null,
276 error: null,
277 isLoading: false,
278 isLoadingMore: false,
279 lastRefreshTime:
280 refresh ? DateTime.now() : currentState.lastRefreshTime,
281 );
282
283 if (kDebugMode) {
284 debugPrint('✅ $feedName loaded: ${newPosts.length} posts total');
285 }
286
287 // Initialize vote state from viewer data in feed response
288 // IMPORTANT: Call setInitialVoteState for ALL feed items, even
289 // when viewer.vote is null. This ensures that if a user removed
290 // their vote on another device, the local state is cleared on
291 // refresh.
292 if (_authProvider.isAuthenticated && _voteProvider != null) {
293 for (final feedItem in response.feed) {
294 final viewer = feedItem.post.viewer;
295 _voteProvider.setInitialVoteState(
296 postUri: feedItem.post.uri,
297 voteDirection: viewer?.vote,
298 voteUri: viewer?.voteUri,
299 );
300 }
301 }
302 } on Exception catch (e) {
303 // SECURITY: Also check session change in error path to prevent
304 // leaking stale data when a fetch fails after sign-out
305 if (type == FeedType.forYou &&
306 sessionDidBeforeFetch != _authProvider.did) {
307 if (kDebugMode) {
308 debugPrint(
309 '🔒 Discarding $feedName error - session changed during fetch',
310 );
311 }
312 _feedStates.remove(type);
313 notifyListeners();
314 return;
315 }
316
317 _feedStates[type] = currentState.copyWith(
318 error: e.toString(),
319 isLoading: false,
320 isLoadingMore: false,
321 );
322
323 if (kDebugMode) {
324 debugPrint('❌ Failed to fetch $feedName: $e');
325 }
326 }
327
328 notifyListeners();
329 }
330
331 /// Fetch timeline feed (authenticated)
332 ///
333 /// Fetches the user's personalized timeline.
334 /// Authentication is handled automatically via tokenGetter.
335 Future<void> _fetchTimeline(FeedType type, {bool refresh = false}) {
336 final currentState = getState(type);
337
338 return _fetchFeed(
339 type: type,
340 refresh: refresh,
341 fetcher:
342 () => _apiService.getTimeline(
343 sort: _sort,
344 timeframe: _timeframe,
345 cursor: refresh ? null : currentState.cursor,
346 ),
347 feedName: 'Timeline',
348 );
349 }
350
351 /// Fetch discover feed (public)
352 ///
353 /// Fetches the public discover feed.
354 /// Does not require authentication.
355 Future<void> _fetchDiscover(FeedType type, {bool refresh = false}) {
356 final currentState = getState(type);
357
358 return _fetchFeed(
359 type: type,
360 refresh: refresh,
361 fetcher:
362 () => _apiService.getDiscover(
363 sort: _sort,
364 timeframe: _timeframe,
365 cursor: refresh ? null : currentState.cursor,
366 ),
367 feedName: 'Discover',
368 );
369 }
370
371 /// Change sort order
372 void setSort(String newSort, {String? newTimeframe}) {
373 _sort = newSort;
374 _timeframe = newTimeframe;
375 notifyListeners();
376 }
377
378 /// Retry loading after error for a specific feed
379 Future<void> retry(FeedType type) async {
380 final currentState = getState(type);
381 _feedStates[type] = currentState.copyWith(error: null);
382 notifyListeners();
383
384 await loadFeed(type);
385 }
386
387 /// Clear error for a specific feed
388 void clearError(FeedType type) {
389 final currentState = getState(type);
390 _feedStates[type] = currentState.copyWith(error: null);
391 notifyListeners();
392 }
393
394 /// Reset feed state for a specific feed
395 void reset(FeedType type) {
396 _feedStates[type] = FeedState.initial();
397 notifyListeners();
398 }
399
400 /// Reset all feeds
401 void resetAll() {
402 _feedStates.clear();
403 notifyListeners();
404 }
405
406 @override
407 void dispose() {
408 // Stop time updates and cancel timer
409 stopTimeUpdates();
410 // Remove auth listener to prevent memory leaks
411 _authProvider.removeListener(_onAuthChanged);
412 _apiService.dispose();
413 super.dispose();
414 }
415}