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