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}