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}