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