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