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