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}