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