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