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