Main coves client
1import 'dart:async' show Timer, unawaited;
2
3import 'package:flutter/foundation.dart';
4import '../models/comment.dart';
5import '../services/coves_api_service.dart';
6import '../services/vote_service.dart';
7import 'auth_provider.dart';
8import 'vote_provider.dart';
9
10/// Comments Provider
11///
12/// Manages comment state and fetching logic for a specific post.
13/// Supports sorting (hot/top/new), pagination, and vote integration.
14///
15/// IMPORTANT: Accepts AuthProvider reference to fetch fresh access
16/// tokens before each authenticated request (critical for atProto OAuth
17/// token rotation).
18class CommentsProvider with ChangeNotifier {
19 CommentsProvider(
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 // Listen to auth state changes and clear comments on sign-out
41 _authProvider.addListener(_onAuthChanged);
42 }
43
44 /// Handle authentication state changes
45 ///
46 /// Clears comment state when user signs out to prevent privacy issues.
47 void _onAuthChanged() {
48 final isAuthenticated = _authProvider.isAuthenticated;
49
50 // Only clear if transitioning from authenticated → unauthenticated
51 if (_wasAuthenticated && !isAuthenticated && _comments.isNotEmpty) {
52 if (kDebugMode) {
53 debugPrint('🔒 User signed out - clearing comments');
54 }
55 reset();
56 }
57
58 // Update tracked state
59 _wasAuthenticated = isAuthenticated;
60 }
61
62 final AuthProvider _authProvider;
63 late final CovesApiService _apiService;
64 final VoteProvider? _voteProvider;
65 final VoteService? _voteService;
66
67 // Track previous auth state to detect transitions
68 bool _wasAuthenticated = false;
69
70 // Comment state
71 List<ThreadViewComment> _comments = [];
72 bool _isLoading = false;
73 bool _isLoadingMore = false;
74 String? _error;
75 String? _cursor;
76 bool _hasMore = true;
77
78 // Current post URI being viewed
79 String? _postUri;
80
81 // Comment configuration
82 String _sort = 'hot';
83 String? _timeframe;
84
85 // Flag to track if a refresh should be scheduled after current load
86 bool _pendingRefresh = false;
87
88 // Time update mechanism for periodic UI refreshes
89 Timer? _timeUpdateTimer;
90 final ValueNotifier<DateTime?> _currentTimeNotifier = ValueNotifier(null);
91
92 // Getters
93 List<ThreadViewComment> get comments => _comments;
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 ValueNotifier<DateTime?> get currentTimeNotifier => _currentTimeNotifier;
101
102 /// Start periodic time updates for "time ago" strings
103 ///
104 /// Updates currentTime every minute to trigger UI rebuilds for
105 /// comment timestamps. This ensures "5m ago" updates to "6m ago" without
106 /// requiring user interaction.
107 ///
108 /// Uses ValueNotifier to avoid triggering full provider rebuilds.
109 void startTimeUpdates() {
110 // Cancel existing timer if any
111 _timeUpdateTimer?.cancel();
112
113 // Update current time immediately
114 _currentTimeNotifier.value = DateTime.now();
115
116 // Set up periodic updates (every minute)
117 _timeUpdateTimer = Timer.periodic(const Duration(minutes: 1), (_) {
118 _currentTimeNotifier.value = DateTime.now();
119 });
120
121 if (kDebugMode) {
122 debugPrint('⏰ Started periodic time updates for comment timestamps');
123 }
124 }
125
126 /// Stop periodic time updates
127 void stopTimeUpdates() {
128 _timeUpdateTimer?.cancel();
129 _timeUpdateTimer = null;
130 _currentTimeNotifier.value = null;
131
132 if (kDebugMode) {
133 debugPrint('⏰ Stopped periodic time updates');
134 }
135 }
136
137 /// Load comments for a specific post
138 Future<void> loadComments({
139 required String postUri,
140 bool refresh = false,
141 }) async {
142 // If loading for a different post, reset state
143 if (postUri != _postUri) {
144 reset();
145 _postUri = postUri;
146 }
147
148 // If already loading, schedule a refresh to happen after current load
149 if (_isLoading || _isLoadingMore) {
150 if (refresh) {
151 _pendingRefresh = true;
152 if (kDebugMode) {
153 debugPrint(
154 '⏳ Load in progress - scheduled refresh for after completion',
155 );
156 }
157 }
158 return;
159 }
160
161 try {
162 if (refresh) {
163 _isLoading = true;
164 _error = null;
165 _pendingRefresh = false; // Clear any pending refresh
166 } else {
167 _isLoadingMore = true;
168 }
169 notifyListeners();
170
171 if (kDebugMode) {
172 debugPrint('📡 Fetching comments: sort=$_sort, postUri=$postUri');
173 }
174
175 final response = await _apiService.getComments(
176 postUri: postUri,
177 sort: _sort,
178 timeframe: _timeframe,
179 cursor: refresh ? null : _cursor,
180 );
181
182 // Only update state after successful fetch
183 if (refresh) {
184 _comments = response.comments;
185 } else {
186 // Create new list instance to trigger rebuilds
187 _comments = [..._comments, ...response.comments];
188 }
189
190 _cursor = response.cursor;
191 _hasMore = response.cursor != null;
192 _error = null;
193
194 if (kDebugMode) {
195 debugPrint('✅ Comments loaded: ${_comments.length} comments total');
196 }
197
198 // Load initial vote state from PDS (only if authenticated)
199 if (_authProvider.isAuthenticated &&
200 _voteProvider != null &&
201 _voteService != null) {
202 try {
203 final userVotes = await _voteService.getUserVotes();
204 _voteProvider.loadInitialVotes(userVotes);
205 } on Exception catch (e) {
206 if (kDebugMode) {
207 debugPrint('⚠️ Failed to load vote state: $e');
208 }
209 // Don't fail the comments load if vote loading fails
210 }
211 }
212
213 // Start time updates when comments are loaded
214 if (_comments.isNotEmpty && _timeUpdateTimer == null) {
215 startTimeUpdates();
216 }
217 } on Exception catch (e) {
218 _error = e.toString();
219 if (kDebugMode) {
220 debugPrint('❌ Failed to fetch comments: $e');
221 }
222 } finally {
223 _isLoading = false;
224 _isLoadingMore = false;
225 notifyListeners();
226
227 // If a refresh was scheduled during this load, execute it now
228 if (_pendingRefresh && _postUri != null) {
229 if (kDebugMode) {
230 debugPrint('🔄 Executing pending refresh');
231 }
232 _pendingRefresh = false;
233 // Schedule refresh without awaiting to avoid blocking
234 // This is intentional - we want the refresh to happen asynchronously
235 unawaited(loadComments(postUri: _postUri!, refresh: true));
236 }
237 }
238 }
239
240 /// Refresh comments (pull-to-refresh)
241 ///
242 /// Reloads comments from the beginning for the current post.
243 Future<void> refreshComments() async {
244 if (_postUri == null) {
245 if (kDebugMode) {
246 debugPrint('⚠️ Cannot refresh - no post loaded');
247 }
248 return;
249 }
250 await loadComments(postUri: _postUri!, refresh: true);
251 }
252
253 /// Load more comments (pagination)
254 Future<void> loadMoreComments() async {
255 if (!_hasMore || _isLoadingMore || _postUri == null) {
256 return;
257 }
258 await loadComments(postUri: _postUri!);
259 }
260
261 /// Change sort order
262 ///
263 /// Updates the sort option and triggers a refresh of comments.
264 /// Available options: 'hot', 'top', 'new'
265 ///
266 /// Returns true if sort change succeeded, false if reload failed.
267 /// On failure, reverts to previous sort option.
268 Future<bool> setSortOption(String newSort) async {
269 if (_sort == newSort) {
270 return true;
271 }
272
273 final previousSort = _sort;
274 _sort = newSort;
275 notifyListeners();
276
277 // Reload comments with new sort
278 if (_postUri != null) {
279 try {
280 await loadComments(postUri: _postUri!, refresh: true);
281 return true;
282 } on Exception catch (e) {
283 // Revert to previous sort option on failure
284 _sort = previousSort;
285 notifyListeners();
286
287 if (kDebugMode) {
288 debugPrint('Failed to apply sort option: $e');
289 }
290
291 return false;
292 }
293 }
294
295 return true;
296 }
297
298 /// Vote on a comment
299 ///
300 /// Delegates to VoteProvider for optimistic updates and API calls.
301 /// The VoteProvider handles:
302 /// - Optimistic UI updates
303 /// - API call to user's PDS
304 /// - Rollback on error
305 ///
306 /// Parameters:
307 /// - [commentUri]: AT-URI of the comment
308 /// - [commentCid]: Content ID of the comment
309 /// - [voteType]: Vote direction ('up' or 'down')
310 ///
311 /// Returns:
312 /// - true if vote was created
313 /// - false if vote was removed (toggled off)
314 Future<bool> voteOnComment({
315 required String commentUri,
316 required String commentCid,
317 String voteType = 'up',
318 }) async {
319 if (_voteProvider == null) {
320 throw Exception('VoteProvider not available');
321 }
322
323 try {
324 final result = await _voteProvider.toggleVote(
325 postUri: commentUri,
326 postCid: commentCid,
327 direction: voteType,
328 );
329
330 if (kDebugMode) {
331 debugPrint('✅ Comment vote ${result ? 'created' : 'removed'}');
332 }
333
334 return result;
335 } on Exception catch (e) {
336 if (kDebugMode) {
337 debugPrint('❌ Failed to vote on comment: $e');
338 }
339 rethrow;
340 }
341 }
342
343 /// Retry loading after error
344 Future<void> retry() async {
345 _error = null;
346 if (_postUri != null) {
347 await loadComments(postUri: _postUri!, refresh: true);
348 }
349 }
350
351 /// Clear error
352 void clearError() {
353 _error = null;
354 notifyListeners();
355 }
356
357 /// Reset comment state
358 void reset() {
359 _comments = [];
360 _cursor = null;
361 _hasMore = true;
362 _error = null;
363 _isLoading = false;
364 _isLoadingMore = false;
365 _postUri = null;
366 _pendingRefresh = false;
367 notifyListeners();
368 }
369
370 @override
371 void dispose() {
372 // Stop time updates and cancel timer (also sets value to null)
373 stopTimeUpdates();
374 // Remove auth listener to prevent memory leaks
375 _authProvider.removeListener(_onAuthChanged);
376 _apiService.dispose();
377 // Dispose the ValueNotifier last
378 _currentTimeNotifier.dispose();
379 super.dispose();
380 }
381}