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