Merge branch 'feat/comments-provider-cache'

Add per-post CommentsProvider caching with LRU eviction for instant
back-navigation, scroll position restoration, and draft text preservation.

+16 -19
lib/main.dart
··· 8 8 import 'constants/app_colors.dart'; 9 9 import 'models/post.dart'; 10 10 import 'providers/auth_provider.dart'; 11 - import 'providers/comments_provider.dart'; 12 11 import 'providers/feed_provider.dart'; 13 12 import 'providers/vote_provider.dart'; 14 13 import 'screens/auth/login_screen.dart'; ··· 16 15 import 'screens/home/post_detail_screen.dart'; 17 16 import 'screens/landing_screen.dart'; 18 17 import 'services/comment_service.dart'; 18 + import 'services/comments_provider_cache.dart'; 19 19 import 'services/streamable_service.dart'; 20 20 import 'services/vote_service.dart'; 21 21 import 'widgets/loading_error_states.dart'; ··· 75 75 return previous ?? FeedProvider(auth, voteProvider: vote); 76 76 }, 77 77 ), 78 - ChangeNotifierProxyProvider2< 79 - AuthProvider, 80 - VoteProvider, 81 - CommentsProvider 82 - >( 83 - create: 84 - (context) => CommentsProvider( 85 - authProvider, 86 - voteProvider: context.read<VoteProvider>(), 87 - commentService: commentService, 88 - ), 78 + // CommentsProviderCache manages per-post CommentsProvider instances 79 + // with LRU eviction and sign-out cleanup 80 + ProxyProvider2<AuthProvider, VoteProvider, CommentsProviderCache>( 81 + create: (context) => CommentsProviderCache( 82 + authProvider: authProvider, 83 + voteProvider: context.read<VoteProvider>(), 84 + commentService: commentService, 85 + ), 89 86 update: (context, auth, vote, previous) { 90 - // Reuse existing provider to maintain state across rebuilds 91 - return previous ?? 92 - CommentsProvider( 93 - auth, 94 - voteProvider: vote, 95 - commentService: commentService, 96 - ); 87 + // Reuse existing cache 88 + return previous ?? CommentsProviderCache( 89 + authProvider: auth, 90 + voteProvider: vote, 91 + commentService: commentService, 92 + ); 97 93 }, 94 + dispose: (_, cache) => cache.dispose(), 98 95 ), 99 96 // StreamableService for video embeds 100 97 Provider<StreamableService>(create: (_) => StreamableService()),
+122 -111
lib/providers/comments_provider.dart
··· 12 12 /// Comments Provider 13 13 /// 14 14 /// Manages comment state and fetching logic for a specific post. 15 - /// Supports sorting (hot/top/new), pagination, and vote integration. 15 + /// Each provider instance is bound to a single post (immutable postUri/postCid). 16 + /// Supports sorting (hot/top/new), pagination, vote integration, scroll position, 17 + /// and draft text preservation. 18 + /// 19 + /// IMPORTANT: Provider instances are managed by CommentsProviderCache which 20 + /// handles LRU eviction and sign-out cleanup. Do not create directly in widgets. 16 21 /// 17 22 /// IMPORTANT: Accepts AuthProvider reference to fetch fresh access 18 23 /// tokens before each authenticated request (critical for atProto OAuth ··· 20 25 class CommentsProvider with ChangeNotifier { 21 26 CommentsProvider( 22 27 this._authProvider, { 28 + required String postUri, 29 + required String postCid, 23 30 CovesApiService? apiService, 24 31 VoteProvider? voteProvider, 25 32 CommentService? commentService, 26 - }) : _voteProvider = voteProvider, 33 + }) : _postUri = postUri, 34 + _postCid = postCid, 35 + _voteProvider = voteProvider, 27 36 _commentService = commentService { 28 37 // Use injected service (for testing) or create new one (for production) 29 38 // Pass token getter, refresh handler, and sign out handler to API service ··· 35 44 tokenRefresher: _authProvider.refreshToken, 36 45 signOutHandler: _authProvider.signOut, 37 46 ); 38 - 39 - // Track initial auth state 40 - _wasAuthenticated = _authProvider.isAuthenticated; 41 - 42 - // Listen to auth state changes and clear comments on sign-out 43 - _authProvider.addListener(_onAuthChanged); 44 47 } 45 48 46 49 /// Maximum comment length in characters (matches backend limit) 47 50 /// Note: This counts Unicode grapheme clusters, so emojis count correctly 48 51 static const int maxCommentLength = 10000; 49 52 50 - /// Handle authentication state changes 51 - /// 52 - /// Clears comment state when user signs out to prevent privacy issues. 53 - void _onAuthChanged() { 54 - final isAuthenticated = _authProvider.isAuthenticated; 55 - 56 - // Only clear if transitioning from authenticated → unauthenticated 57 - if (_wasAuthenticated && !isAuthenticated && _comments.isNotEmpty) { 58 - if (kDebugMode) { 59 - debugPrint('🔒 User signed out - clearing comments'); 60 - } 61 - reset(); 62 - } 63 - 64 - // Update tracked state 65 - _wasAuthenticated = isAuthenticated; 66 - } 53 + /// Default staleness threshold for background refresh 54 + static const Duration stalenessThreshold = Duration(minutes: 5); 67 55 68 56 final AuthProvider _authProvider; 69 57 late final CovesApiService _apiService; 70 58 final VoteProvider? _voteProvider; 71 59 final CommentService? _commentService; 72 60 73 - // Track previous auth state to detect transitions 74 - bool _wasAuthenticated = false; 61 + // Post context - immutable per provider instance 62 + final String _postUri; 63 + final String _postCid; 75 64 76 65 // Comment state 77 66 List<ThreadViewComment> _comments = []; ··· 84 73 // Collapsed thread state - stores URIs of collapsed comments 85 74 final Set<String> _collapsedComments = {}; 86 75 87 - // Current post being viewed 88 - String? _postUri; 89 - String? _postCid; 76 + // Scroll position state (replaces ScrollStateService for this post) 77 + double _scrollPosition = 0; 78 + 79 + // Draft reply text - stored per-parent-URI (null key = top-level reply to post) 80 + // This allows users to have separate drafts for different comments within the same post 81 + final Map<String?, String> _drafts = {}; 82 + 83 + // Staleness tracking for background refresh 84 + DateTime? _lastRefreshTime; 90 85 91 86 // Comment configuration 92 87 String _sort = 'hot'; ··· 99 94 Timer? _timeUpdateTimer; 100 95 final ValueNotifier<DateTime?> _currentTimeNotifier = ValueNotifier(null); 101 96 97 + bool _isDisposed = false; 98 + 99 + void _safeNotifyListeners() { 100 + if (_isDisposed) return; 101 + notifyListeners(); 102 + } 103 + 102 104 // Getters 105 + String get postUri => _postUri; 106 + String get postCid => _postCid; 103 107 List<ThreadViewComment> get comments => _comments; 104 108 bool get isLoading => _isLoading; 105 109 bool get isLoadingMore => _isLoadingMore; ··· 109 113 String? get timeframe => _timeframe; 110 114 ValueNotifier<DateTime?> get currentTimeNotifier => _currentTimeNotifier; 111 115 Set<String> get collapsedComments => Set.unmodifiable(_collapsedComments); 116 + double get scrollPosition => _scrollPosition; 117 + DateTime? get lastRefreshTime => _lastRefreshTime; 118 + 119 + /// Get draft text for a specific parent URI 120 + /// 121 + /// [parentUri] - URI of parent comment (null for top-level post reply) 122 + /// Returns the draft text, or empty string if no draft exists 123 + String getDraft({String? parentUri}) => _drafts[parentUri] ?? ''; 124 + 125 + /// Legacy getters for backward compatibility 126 + /// @deprecated Use getDraft(parentUri: ...) instead 127 + String get draftText => _drafts.values.firstOrNull ?? ''; 128 + String? get draftParentUri => _drafts.keys.firstOrNull; 129 + 130 + /// Check if cached data is stale and should be refreshed in background 131 + bool get isStale { 132 + if (_lastRefreshTime == null) { 133 + return true; 134 + } 135 + return DateTime.now().difference(_lastRefreshTime!) > stalenessThreshold; 136 + } 137 + 138 + /// Save scroll position (called on every scroll event) 139 + void saveScrollPosition(double position) { 140 + _scrollPosition = position; 141 + // No notifyListeners - this is passive state save 142 + } 143 + 144 + /// Save draft reply text 145 + /// 146 + /// [text] - The draft text content 147 + /// [parentUri] - URI of parent comment (null for top-level post reply) 148 + /// 149 + /// Each parent URI gets its own draft, so switching between replies 150 + /// preserves drafts for each context. 151 + void saveDraft(String text, {String? parentUri}) { 152 + if (text.trim().isEmpty) { 153 + // Remove empty drafts to avoid clutter 154 + _drafts.remove(parentUri); 155 + } else { 156 + _drafts[parentUri] = text; 157 + } 158 + // No notifyListeners - this is passive state save 159 + } 160 + 161 + /// Clear draft text for a specific parent (call after successful submission) 162 + /// 163 + /// [parentUri] - URI of parent comment (null for top-level post reply) 164 + void clearDraft({String? parentUri}) { 165 + _drafts.remove(parentUri); 166 + } 112 167 113 168 /// Toggle collapsed state for a comment thread 114 169 /// ··· 120 175 } else { 121 176 _collapsedComments.add(uri); 122 177 } 123 - notifyListeners(); 178 + _safeNotifyListeners(); 124 179 } 125 180 126 181 /// Check if a specific comment is collapsed ··· 161 216 } 162 217 } 163 218 164 - /// Load comments for a specific post 219 + /// Load comments for this provider's post 165 220 /// 166 221 /// Parameters: 167 - /// - [postUri]: AT-URI of the post 168 - /// - [postCid]: CID of the post (needed for creating comments) 169 - /// - [refresh]: Whether to refresh from the beginning 170 - Future<void> loadComments({ 171 - required String postUri, 172 - required String postCid, 173 - bool refresh = false, 174 - }) async { 175 - // If loading for a different post, reset state 176 - if (postUri != _postUri) { 177 - reset(); 178 - _postUri = postUri; 179 - _postCid = postCid; 180 - } 181 - 222 + /// - [refresh]: Whether to refresh from the beginning (true) or paginate (false) 223 + Future<void> loadComments({bool refresh = false}) async { 182 224 // If already loading, schedule a refresh to happen after current load 183 225 if (_isLoading || _isLoadingMore) { 184 226 if (refresh) { ··· 200 242 } else { 201 243 _isLoadingMore = true; 202 244 } 203 - notifyListeners(); 245 + _safeNotifyListeners(); 204 246 205 247 if (kDebugMode) { 206 - debugPrint('📡 Fetching comments: sort=$_sort, postUri=$postUri'); 248 + debugPrint('📡 Fetching comments: sort=$_sort, postUri=$_postUri'); 207 249 } 208 250 209 251 final response = await _apiService.getComments( 210 - postUri: postUri, 252 + postUri: _postUri, 211 253 sort: _sort, 212 254 timeframe: _timeframe, 213 255 cursor: refresh ? null : _cursor, 214 256 ); 215 257 258 + if (_isDisposed) return; 259 + 216 260 // Only update state after successful fetch 217 261 if (refresh) { 218 262 _comments = response.comments; 263 + _lastRefreshTime = DateTime.now(); 219 264 } else { 220 265 // Create new list instance to trigger rebuilds 221 266 _comments = [..._comments, ...response.comments]; ··· 246 291 startTimeUpdates(); 247 292 } 248 293 } on Exception catch (e) { 294 + if (_isDisposed) return; 249 295 _error = e.toString(); 250 296 if (kDebugMode) { 251 297 debugPrint('❌ Failed to fetch comments: $e'); 252 298 } 253 299 } finally { 300 + if (_isDisposed) return; 254 301 _isLoading = false; 255 302 _isLoadingMore = false; 256 - notifyListeners(); 303 + _safeNotifyListeners(); 257 304 258 305 // If a refresh was scheduled during this load, execute it now 259 - if (_pendingRefresh && _postUri != null) { 306 + if (_pendingRefresh) { 260 307 if (kDebugMode) { 261 308 debugPrint('🔄 Executing pending refresh'); 262 309 } 263 310 _pendingRefresh = false; 264 311 // Schedule refresh without awaiting to avoid blocking 265 312 // This is intentional - we want the refresh to happen asynchronously 266 - unawaited( 267 - loadComments(postUri: _postUri!, postCid: _postCid!, refresh: true), 268 - ); 313 + unawaited(loadComments(refresh: true)); 269 314 } 270 315 } 271 316 } ··· 274 319 /// 275 320 /// Reloads comments from the beginning for the current post. 276 321 Future<void> refreshComments() async { 277 - if (_postUri == null || _postCid == null) { 278 - if (kDebugMode) { 279 - debugPrint('⚠️ Cannot refresh - no post loaded'); 280 - } 281 - return; 282 - } 283 - await loadComments(postUri: _postUri!, postCid: _postCid!, refresh: true); 322 + await loadComments(refresh: true); 284 323 } 285 324 286 325 /// Load more comments (pagination) 287 326 Future<void> loadMoreComments() async { 288 - if (!_hasMore || _isLoadingMore || _postUri == null || _postCid == null) { 327 + if (!_hasMore || _isLoadingMore) { 289 328 return; 290 329 } 291 - await loadComments(postUri: _postUri!, postCid: _postCid!); 330 + await loadComments(); 292 331 } 293 332 294 333 /// Change sort order ··· 305 344 306 345 final previousSort = _sort; 307 346 _sort = newSort; 308 - notifyListeners(); 347 + _safeNotifyListeners(); 309 348 310 349 // Reload comments with new sort 311 - if (_postUri != null && _postCid != null) { 312 - try { 313 - await loadComments( 314 - postUri: _postUri!, 315 - postCid: _postCid!, 316 - refresh: true, 317 - ); 318 - return true; 319 - } on Exception catch (e) { 320 - // Revert to previous sort option on failure 321 - _sort = previousSort; 322 - notifyListeners(); 350 + try { 351 + await loadComments(refresh: true); 352 + return true; 353 + } on Exception catch (e) { 354 + if (_isDisposed) return false; 355 + // Revert to previous sort option on failure 356 + _sort = previousSort; 357 + _safeNotifyListeners(); 323 358 324 - if (kDebugMode) { 325 - debugPrint('Failed to apply sort option: $e'); 326 - } 327 - 328 - return false; 359 + if (kDebugMode) { 360 + debugPrint('Failed to apply sort option: $e'); 329 361 } 330 - } 331 362 332 - return true; 363 + return false; 364 + } 333 365 } 334 366 335 367 /// Vote on a comment ··· 415 447 416 448 if (_commentService == null) { 417 449 throw ApiException('CommentService not available'); 418 - } 419 - 420 - if (_postUri == null || _postCid == null) { 421 - throw ApiException('No post loaded - cannot create comment'); 422 450 } 423 451 424 452 // Root is always the original post 425 - final rootUri = _postUri!; 426 - final rootCid = _postCid!; 453 + final rootUri = _postUri; 454 + final rootCid = _postCid; 427 455 428 456 // Parent depends on whether this is a top-level or nested reply 429 457 final String parentUri; ··· 492 520 /// Retry loading after error 493 521 Future<void> retry() async { 494 522 _error = null; 495 - if (_postUri != null && _postCid != null) { 496 - await loadComments(postUri: _postUri!, postCid: _postCid!, refresh: true); 497 - } 523 + await loadComments(refresh: true); 498 524 } 499 525 500 526 /// Clear error 501 527 void clearError() { 502 528 _error = null; 503 - notifyListeners(); 504 - } 505 - 506 - /// Reset comment state 507 - void reset() { 508 - _comments = []; 509 - _cursor = null; 510 - _hasMore = true; 511 - _error = null; 512 - _isLoading = false; 513 - _isLoadingMore = false; 514 - _postUri = null; 515 - _postCid = null; 516 - _pendingRefresh = false; 517 - _collapsedComments.clear(); 518 - notifyListeners(); 529 + _safeNotifyListeners(); 519 530 } 520 531 521 532 @override 522 533 void dispose() { 534 + _isDisposed = true; 523 535 // Stop time updates and cancel timer (also sets value to null) 524 536 stopTimeUpdates(); 525 - // Remove auth listener to prevent memory leaks 526 - _authProvider.removeListener(_onAuthChanged); 537 + // Dispose API service 527 538 _apiService.dispose(); 528 539 // Dispose the ValueNotifier last 529 540 _currentTimeNotifier.dispose();
+119 -10
lib/screens/compose/reply_screen.dart
··· 1 1 import 'dart:async'; 2 2 import 'dart:math' as math; 3 3 4 + import 'package:flutter/foundation.dart'; 4 5 import 'package:flutter/material.dart'; 5 6 import 'package:flutter/services.dart'; 6 7 import 'package:provider/provider.dart'; ··· 8 9 import '../../constants/app_colors.dart'; 9 10 import '../../models/comment.dart'; 10 11 import '../../models/post.dart'; 12 + import '../../providers/auth_provider.dart'; 11 13 import '../../providers/comments_provider.dart'; 12 14 import '../../widgets/comment_thread.dart'; 13 15 import '../../widgets/post_card.dart'; ··· 32 34 this.post, 33 35 this.comment, 34 36 required this.onSubmit, 37 + required this.commentsProvider, 35 38 super.key, 36 39 }) : assert( 37 40 (post != null) != (comment != null), ··· 47 50 /// Callback when user submits reply 48 51 final Future<void> Function(String content) onSubmit; 49 52 53 + /// CommentsProvider for draft save/restore and time updates 54 + final CommentsProvider commentsProvider; 55 + 50 56 @override 51 57 State<ReplyScreen> createState() => _ReplyScreenState(); 52 58 } ··· 58 64 bool _hasText = false; 59 65 bool _isKeyboardOpening = false; 60 66 bool _isSubmitting = false; 67 + bool _authInvalidated = false; 61 68 double _lastKeyboardHeight = 0; 62 69 Timer? _bannerDismissTimer; 63 70 ··· 68 75 _textController.addListener(_onTextChanged); 69 76 _focusNode.addListener(_onFocusChanged); 70 77 71 - // Autofocus with delay (Thunder approach - let screen render first) 72 - Future.delayed(const Duration(milliseconds: 300), () { 78 + // Restore draft and autofocus after frame is built 79 + WidgetsBinding.instance.addPostFrameCallback((_) { 73 80 if (mounted) { 74 - _isKeyboardOpening = true; 75 - _focusNode.requestFocus(); 81 + _setupAuthListener(); 82 + _restoreDraft(); 83 + 84 + // Autofocus with delay (Thunder approach - let screen render first) 85 + Future.delayed(const Duration(milliseconds: 300), () { 86 + if (mounted) { 87 + _isKeyboardOpening = true; 88 + _focusNode.requestFocus(); 89 + } 90 + }); 76 91 } 77 92 }); 78 93 } 79 94 95 + void _setupAuthListener() { 96 + try { 97 + context.read<AuthProvider>().addListener(_onAuthChanged); 98 + } on Exception { 99 + // AuthProvider may not be available (e.g., tests) 100 + } 101 + } 102 + 103 + void _onAuthChanged() { 104 + if (!mounted || _authInvalidated) return; 105 + 106 + try { 107 + final authProvider = context.read<AuthProvider>(); 108 + if (!authProvider.isAuthenticated) { 109 + _authInvalidated = true; 110 + if (mounted) { 111 + Navigator.of(context).pop(); 112 + } 113 + } 114 + } on Exception { 115 + // AuthProvider may not be available 116 + } 117 + } 118 + 119 + /// Restore draft text if available for this reply context 120 + void _restoreDraft() { 121 + try { 122 + final commentsProvider = context.read<CommentsProvider>(); 123 + final ourParentUri = widget.comment?.comment.uri; 124 + 125 + // Get draft for this specific parent URI 126 + final draft = commentsProvider.getDraft(parentUri: ourParentUri); 127 + 128 + if (draft.isNotEmpty) { 129 + _textController.text = draft; 130 + setState(() { 131 + _hasText = true; 132 + }); 133 + } 134 + } on Exception catch (e) { 135 + // CommentsProvider might not be available (e.g., during testing) 136 + if (kDebugMode) { 137 + debugPrint('📝 Draft not restored: $e'); 138 + } 139 + } 140 + } 141 + 80 142 void _onFocusChanged() { 81 143 // When text field gains focus, scroll to bottom as keyboard opens 82 144 if (_focusNode.hasFocus) { ··· 87 149 @override 88 150 void didChangeMetrics() { 89 151 super.didChangeMetrics(); 152 + // Guard against being called after widget is deactivated 153 + // (can happen during keyboard animation while navigating away) 154 + if (!mounted) return; 155 + 90 156 final keyboardHeight = View.of(context).viewInsets.bottom; 91 157 92 158 // Detect keyboard closing and unfocus text field ··· 120 186 @override 121 187 void dispose() { 122 188 _bannerDismissTimer?.cancel(); 189 + try { 190 + context.read<AuthProvider>().removeListener(_onAuthChanged); 191 + } on Exception { 192 + // AuthProvider may not be available 193 + } 123 194 WidgetsBinding.instance.removeObserver(this); 124 195 _textController.dispose(); 125 196 _focusNode.dispose(); ··· 137 208 } 138 209 139 210 Future<void> _handleSubmit() async { 211 + if (_authInvalidated) { 212 + return; 213 + } 214 + 140 215 final content = _textController.text.trim(); 141 216 if (content.isEmpty) { 142 217 return; ··· 152 227 153 228 try { 154 229 await widget.onSubmit(content); 230 + // Clear draft on success 231 + try { 232 + if (mounted) { 233 + final parentUri = widget.comment?.comment.uri; 234 + context.read<CommentsProvider>().clearDraft(parentUri: parentUri); 235 + } 236 + } on Exception catch (e) { 237 + // CommentsProvider might not be available 238 + if (kDebugMode) { 239 + debugPrint('📝 Draft not cleared: $e'); 240 + } 241 + } 155 242 // Pop screen after successful submission 156 243 if (mounted) { 157 244 Navigator.of(context).pop(); ··· 213 300 } 214 301 215 302 void _handleCancel() { 303 + // Save draft before closing (if text is not empty) 304 + _saveDraft(); 216 305 Navigator.of(context).pop(); 217 306 } 218 307 308 + /// Save current text as draft 309 + void _saveDraft() { 310 + try { 311 + final commentsProvider = context.read<CommentsProvider>(); 312 + commentsProvider.saveDraft( 313 + _textController.text, 314 + parentUri: widget.comment?.comment.uri, 315 + ); 316 + } on Exception catch (e) { 317 + // CommentsProvider might not be available 318 + if (kDebugMode) { 319 + debugPrint('📝 Draft not saved: $e'); 320 + } 321 + } 322 + } 323 + 219 324 @override 220 325 Widget build(BuildContext context) { 221 - return GestureDetector( 222 - onTap: () { 223 - // Dismiss keyboard when tapping outside 224 - FocusManager.instance.primaryFocus?.unfocus(); 225 - }, 226 - child: Scaffold( 326 + // Provide CommentsProvider to descendant widgets (Consumer in _ContextPreview) 327 + return ChangeNotifierProvider.value( 328 + value: widget.commentsProvider, 329 + child: GestureDetector( 330 + onTap: () { 331 + // Dismiss keyboard when tapping outside 332 + FocusManager.instance.primaryFocus?.unfocus(); 333 + }, 334 + child: Scaffold( 227 335 backgroundColor: AppColors.background, 228 336 resizeToAvoidBottomInset: false, // Thunder approach 229 337 appBar: AppBar( ··· 303 411 ), 304 412 ], 305 413 ), 414 + ), 306 415 ), 307 416 ); 308 417 }
+23 -8
lib/screens/home/focused_thread_screen.dart
··· 4 4 import '../../constants/app_colors.dart'; 5 5 import '../../models/comment.dart'; 6 6 import '../../providers/auth_provider.dart'; 7 + import '../../providers/comments_provider.dart'; 7 8 import '../../widgets/comment_card.dart'; 8 9 import '../../widgets/comment_thread.dart'; 9 10 import '../../widgets/status_bar_overlay.dart'; ··· 25 26 /// any collapsed state is reset. This is by design - it allows users to 26 27 /// explore deep threads without their collapse choices persisting across 27 28 /// navigation, keeping the focused view clean and predictable. 29 + /// 30 + /// ## Provider Sharing 31 + /// Receives the parent's CommentsProvider for draft text preservation and 32 + /// consistent vote state display. 28 33 class FocusedThreadScreen extends StatelessWidget { 29 34 const FocusedThreadScreen({ 30 35 required this.thread, 31 36 required this.ancestors, 32 37 required this.onReply, 38 + required this.commentsProvider, 33 39 super.key, 34 40 }); 35 41 ··· 42 48 /// Callback when user replies to a comment 43 49 final Future<void> Function(String content, ThreadViewComment parent) onReply; 44 50 51 + /// Parent's CommentsProvider for draft preservation and vote state 52 + final CommentsProvider commentsProvider; 53 + 45 54 @override 46 55 Widget build(BuildContext context) { 47 - return Scaffold( 48 - backgroundColor: AppColors.background, 49 - body: _FocusedThreadBody( 50 - thread: thread, 51 - ancestors: ancestors, 52 - onReply: onReply, 56 + // Expose parent's CommentsProvider for ReplyScreen draft access 57 + return ChangeNotifierProvider.value( 58 + value: commentsProvider, 59 + child: Scaffold( 60 + backgroundColor: AppColors.background, 61 + body: _FocusedThreadBody( 62 + thread: thread, 63 + ancestors: ancestors, 64 + onReply: onReply, 65 + ), 53 66 ), 54 67 ); 55 68 } ··· 126 139 127 140 Navigator.of(context).push( 128 141 MaterialPageRoute<void>( 129 - builder: (context) => ReplyScreen( 142 + builder: (navigatorContext) => ReplyScreen( 130 143 comment: comment, 131 144 onSubmit: (content) => widget.onReply(content, comment), 145 + commentsProvider: context.read<CommentsProvider>(), 132 146 ), 133 147 ), 134 148 ); ··· 141 155 ) { 142 156 Navigator.of(context).push( 143 157 MaterialPageRoute<void>( 144 - builder: (context) => FocusedThreadScreen( 158 + builder: (navigatorContext) => FocusedThreadScreen( 145 159 thread: thread, 146 160 ancestors: ancestors, 147 161 onReply: widget.onReply, 162 + commentsProvider: context.read<CommentsProvider>(), 148 163 ), 149 164 ), 150 165 );
+268 -111
lib/screens/home/post_detail_screen.dart
··· 1 1 import 'package:cached_network_image/cached_network_image.dart'; 2 + import 'package:flutter/foundation.dart'; 2 3 import 'package:flutter/material.dart'; 3 4 import 'package:flutter/services.dart'; 4 5 import 'package:provider/provider.dart'; ··· 10 11 import '../../providers/auth_provider.dart'; 11 12 import '../../providers/comments_provider.dart'; 12 13 import '../../providers/vote_provider.dart'; 14 + import '../../services/comments_provider_cache.dart'; 13 15 import '../../utils/community_handle_utils.dart'; 14 16 import '../../utils/error_messages.dart'; 15 17 import '../../widgets/comment_thread.dart'; ··· 48 50 final ScrollController _scrollController = ScrollController(); 49 51 final GlobalKey _commentsHeaderKey = GlobalKey(); 50 52 51 - // Current sort option 52 - String _currentSort = 'hot'; 53 + // Cached provider from CommentsProviderCache 54 + late CommentsProvider _commentsProvider; 55 + CommentsProviderCache? _commentsCache; 56 + 57 + // Track initialization state 58 + bool _isInitialized = false; 59 + 60 + // Track if provider has been invalidated (e.g., by sign-out) 61 + bool _providerInvalidated = false; 53 62 54 63 @override 55 64 void initState() { 56 65 super.initState(); 57 - 58 - // Initialize scroll controller for pagination 59 66 _scrollController.addListener(_onScroll); 60 67 61 - // Load comments after frame is built using provider from tree 68 + // Initialize provider after frame is built 62 69 WidgetsBinding.instance.addPostFrameCallback((_) { 63 70 if (mounted) { 64 - _loadComments(); 71 + _initializeProvider(); 72 + _setupAuthListener(); 73 + } 74 + }); 75 + } 76 + 77 + /// Listen for auth state changes to handle sign-out 78 + void _setupAuthListener() { 79 + final authProvider = context.read<AuthProvider>(); 80 + authProvider.addListener(_onAuthChanged); 81 + } 82 + 83 + /// Handle auth state changes (specifically sign-out) 84 + void _onAuthChanged() { 85 + if (!mounted) return; 86 + 87 + final authProvider = context.read<AuthProvider>(); 88 + 89 + // If user signed out while viewing this screen, navigate back 90 + // The CommentsProviderCache has already disposed our provider 91 + if (!authProvider.isAuthenticated && _isInitialized && !_providerInvalidated) { 92 + _providerInvalidated = true; 93 + 94 + if (kDebugMode) { 95 + debugPrint('🚪 User signed out - cleaning up PostDetailScreen'); 96 + } 97 + 98 + // Remove listener from provider (it's disposed but this is safe) 99 + try { 100 + _commentsProvider.removeListener(_onProviderChanged); 101 + } on Exception { 102 + // Provider already disposed - expected 103 + } 104 + 105 + // Navigate back to feed 106 + if (mounted) { 107 + Navigator.of(context).popUntil((route) => route.isFirst); 108 + } 109 + } 110 + } 111 + 112 + /// Initialize provider from cache and restore state 113 + void _initializeProvider() { 114 + // Get or create provider from cache 115 + final cache = context.read<CommentsProviderCache>(); 116 + _commentsCache = cache; 117 + _commentsProvider = cache.acquireProvider( 118 + postUri: widget.post.post.uri, 119 + postCid: widget.post.post.cid, 120 + ); 121 + 122 + // Listen for changes to trigger rebuilds 123 + _commentsProvider.addListener(_onProviderChanged); 124 + 125 + // Check if we already have cached data 126 + if (_commentsProvider.comments.isNotEmpty) { 127 + // Already have data - restore scroll position immediately 128 + if (kDebugMode) { 129 + debugPrint( 130 + '📦 Using cached comments (${_commentsProvider.comments.length})', 131 + ); 132 + } 133 + _restoreScrollPosition(); 134 + 135 + // Background refresh if data is stale 136 + if (_commentsProvider.isStale) { 137 + if (kDebugMode) { 138 + debugPrint('🔄 Data stale, refreshing in background'); 139 + } 140 + _commentsProvider.loadComments(refresh: true); 65 141 } 142 + } else { 143 + // No cached data - load fresh 144 + _commentsProvider.loadComments(refresh: true); 145 + } 146 + 147 + setState(() { 148 + _isInitialized = true; 66 149 }); 67 150 } 68 151 69 152 @override 70 153 void dispose() { 154 + // Remove auth listener 155 + try { 156 + context.read<AuthProvider>().removeListener(_onAuthChanged); 157 + } on Exception { 158 + // Context may not be valid during dispose 159 + } 160 + 161 + // Release provider pin in cache (prevents LRU eviction disposing an active 162 + // provider while this screen is in the navigation stack). 163 + if (_isInitialized) { 164 + try { 165 + _commentsCache?.releaseProvider(widget.post.post.uri); 166 + } on Exception { 167 + // Cache may already be disposed 168 + } 169 + } 170 + 171 + // Remove provider listener if not already invalidated 172 + if (_isInitialized && !_providerInvalidated) { 173 + try { 174 + _commentsProvider.removeListener(_onProviderChanged); 175 + } on Exception { 176 + // Provider may already be disposed 177 + } 178 + } 71 179 _scrollController.dispose(); 72 180 super.dispose(); 73 181 } 74 182 75 - /// Load comments for the current post 76 - void _loadComments() { 77 - context.read<CommentsProvider>().loadComments( 78 - postUri: widget.post.post.uri, 79 - postCid: widget.post.post.cid, 80 - refresh: true, 81 - ); 183 + /// Handle provider changes 184 + void _onProviderChanged() { 185 + if (mounted) { 186 + setState(() {}); 187 + } 82 188 } 83 189 84 - /// Handle sort changes from dropdown 85 - Future<void> _onSortChanged(String newSort) async { 86 - final previousSort = _currentSort; 190 + /// Restore scroll position from provider 191 + void _restoreScrollPosition() { 192 + final savedPosition = _commentsProvider.scrollPosition; 193 + if (savedPosition <= 0) { 194 + return; 195 + } 196 + 197 + WidgetsBinding.instance.addPostFrameCallback((_) { 198 + if (!mounted || !_scrollController.hasClients) { 199 + return; 200 + } 87 201 88 - setState(() { 89 - _currentSort = newSort; 202 + final maxExtent = _scrollController.position.maxScrollExtent; 203 + final targetPosition = savedPosition.clamp(0.0, maxExtent); 204 + 205 + if (targetPosition > 0) { 206 + _scrollController.jumpTo(targetPosition); 207 + if (kDebugMode) { 208 + debugPrint('📍 Restored scroll to $targetPosition (max: $maxExtent)'); 209 + } 210 + } 90 211 }); 212 + } 91 213 92 - final commentsProvider = context.read<CommentsProvider>(); 93 - final success = await commentsProvider.setSortOption(newSort); 214 + /// Handle sort changes from dropdown 215 + Future<void> _onSortChanged(String newSort) async { 216 + final success = await _commentsProvider.setSortOption(newSort); 94 217 95 - // Show error snackbar and revert UI if sort change failed 218 + // Show error snackbar if sort change failed 96 219 if (!success && mounted) { 97 - setState(() { 98 - _currentSort = previousSort; 99 - }); 100 - 101 220 ScaffoldMessenger.of(context).showSnackBar( 102 221 SnackBar( 103 222 content: const Text('Failed to change sort order. Please try again.'), ··· 118 237 119 238 /// Handle scroll for pagination 120 239 void _onScroll() { 240 + // Don't interact with disposed provider 241 + if (_providerInvalidated) return; 242 + 243 + // Save scroll position to provider on every scroll event 244 + if (_scrollController.hasClients) { 245 + _commentsProvider.saveScrollPosition(_scrollController.position.pixels); 246 + } 247 + 248 + // Load more comments when near bottom 121 249 if (_scrollController.position.pixels >= 122 250 _scrollController.position.maxScrollExtent - 200) { 123 - context.read<CommentsProvider>().loadMoreComments(); 251 + _commentsProvider.loadMoreComments(); 124 252 } 125 253 } 126 254 127 255 /// Handle pull-to-refresh 128 256 Future<void> _onRefresh() async { 129 - final commentsProvider = context.read<CommentsProvider>(); 130 - await commentsProvider.refreshComments(); 257 + // Don't interact with disposed provider 258 + if (_providerInvalidated) return; 259 + 260 + await _commentsProvider.refreshComments(); 131 261 } 132 262 133 263 @override 134 264 Widget build(BuildContext context) { 135 - return Scaffold( 136 - backgroundColor: AppColors.background, 137 - body: _buildContent(), 138 - bottomNavigationBar: _buildActionBar(), 265 + // Show loading until provider is initialized 266 + if (!_isInitialized) { 267 + return const Scaffold( 268 + backgroundColor: AppColors.background, 269 + body: FullScreenLoading(), 270 + ); 271 + } 272 + 273 + // If provider was invalidated (sign-out), show loading while navigating away 274 + if (_providerInvalidated) { 275 + return const Scaffold( 276 + backgroundColor: AppColors.background, 277 + body: FullScreenLoading(), 278 + ); 279 + } 280 + 281 + // Provide the cached CommentsProvider to descendant widgets 282 + return ChangeNotifierProvider.value( 283 + value: _commentsProvider, 284 + child: Scaffold( 285 + backgroundColor: AppColors.background, 286 + body: _buildContent(), 287 + bottomNavigationBar: _buildActionBar(), 288 + ), 139 289 ); 140 290 } 141 291 ··· 365 515 Navigator.of(context).push( 366 516 MaterialPageRoute<void>( 367 517 builder: 368 - (context) => 369 - ReplyScreen(post: widget.post, onSubmit: _handleCommentSubmit), 518 + (context) => ReplyScreen( 519 + post: widget.post, 520 + onSubmit: _handleCommentSubmit, 521 + commentsProvider: _commentsProvider, 522 + ), 370 523 ), 371 524 ); 372 525 } 373 526 374 527 /// Handle comment submission (reply to post) 375 528 Future<void> _handleCommentSubmit(String content) async { 376 - final commentsProvider = context.read<CommentsProvider>(); 377 529 final messenger = ScaffoldMessenger.of(context); 378 530 379 531 try { 380 - await commentsProvider.createComment(content: content); 532 + await _commentsProvider.createComment(content: content); 381 533 382 534 if (mounted) { 383 535 messenger.showSnackBar( ··· 407 559 String content, 408 560 ThreadViewComment parentComment, 409 561 ) async { 410 - final commentsProvider = context.read<CommentsProvider>(); 411 562 final messenger = ScaffoldMessenger.of(context); 412 563 413 564 try { 414 - await commentsProvider.createComment( 565 + await _commentsProvider.createComment( 415 566 content: content, 416 567 parentComment: parentComment, 417 568 ); ··· 460 611 (context) => ReplyScreen( 461 612 comment: comment, 462 613 onSubmit: (content) => _handleCommentReply(content, comment), 614 + commentsProvider: _commentsProvider, 463 615 ), 464 616 ), 465 617 ); ··· 472 624 ) { 473 625 Navigator.of(context).push( 474 626 MaterialPageRoute<void>( 475 - builder: (context) => FocusedThreadScreen( 476 - thread: thread, 477 - ancestors: ancestors, 478 - onReply: _handleCommentReply, 479 - ), 627 + builder: 628 + (context) => FocusedThreadScreen( 629 + thread: thread, 630 + ancestors: ancestors, 631 + onReply: _handleCommentReply, 632 + commentsProvider: _commentsProvider, 633 + ), 480 634 ), 481 635 ); 482 636 } ··· 539 693 SliverSafeArea( 540 694 top: false, 541 695 sliver: SliverList( 542 - delegate: SliverChildBuilderDelegate( 543 - (context, index) { 544 - // Post card (index 0) 545 - if (index == 0) { 546 - return Column( 547 - children: [ 548 - // Reuse PostCard (hide comment button in 549 - // detail view) 550 - // Use ValueListenableBuilder to only rebuild 551 - // when time changes 552 - _PostHeader( 553 - post: widget.post, 554 - currentTimeNotifier: 555 - commentsProvider.currentTimeNotifier, 556 - ), 696 + delegate: SliverChildBuilderDelegate( 697 + (context, index) { 698 + // Post card (index 0) 699 + if (index == 0) { 700 + return Column( 701 + children: [ 702 + // Reuse PostCard (hide comment button in 703 + // detail view) 704 + // Use ValueListenableBuilder to only rebuild 705 + // when time changes 706 + _PostHeader( 707 + post: widget.post, 708 + currentTimeNotifier: 709 + commentsProvider.currentTimeNotifier, 710 + ), 711 + 712 + // Visual divider before comments section 713 + Container( 714 + margin: const EdgeInsets.symmetric( 715 + vertical: 16, 716 + ), 717 + height: 1, 718 + color: AppColors.border, 719 + ), 557 720 558 - // Visual divider before comments section 559 - Container( 560 - margin: const EdgeInsets.symmetric(vertical: 16), 561 - height: 1, 562 - color: AppColors.border, 563 - ), 721 + // Comments header with sort dropdown 722 + CommentsHeader( 723 + key: _commentsHeaderKey, 724 + commentCount: comments.length, 725 + currentSort: commentsProvider.sort, 726 + onSortChanged: _onSortChanged, 727 + ), 728 + ], 729 + ); 730 + } 564 731 565 - // Comments header with sort dropdown 566 - CommentsHeader( 567 - key: _commentsHeaderKey, 568 - commentCount: comments.length, 569 - currentSort: _currentSort, 570 - onSortChanged: _onSortChanged, 571 - ), 572 - ], 573 - ); 574 - } 732 + // Loading indicator or error at the end 733 + if (index == comments.length + 1) { 734 + if (isLoadingMore) { 735 + return const InlineLoading(); 736 + } 737 + if (error != null) { 738 + return InlineError( 739 + message: ErrorMessages.getUserFriendly(error), 740 + onRetry: () { 741 + commentsProvider 742 + ..clearError() 743 + ..loadMoreComments(); 744 + }, 745 + ); 746 + } 747 + } 575 748 576 - // Loading indicator or error at the end 577 - if (index == comments.length + 1) { 578 - if (isLoadingMore) { 579 - return const InlineLoading(); 580 - } 581 - if (error != null) { 582 - return InlineError( 583 - message: ErrorMessages.getUserFriendly(error), 584 - onRetry: () { 585 - commentsProvider 586 - ..clearError() 587 - ..loadMoreComments(); 588 - }, 749 + // Comment item - use existing CommentThread widget 750 + final comment = comments[index - 1]; 751 + return _CommentItem( 752 + comment: comment, 753 + currentTimeNotifier: 754 + commentsProvider.currentTimeNotifier, 755 + onCommentTap: _openReplyToComment, 756 + collapsedComments: 757 + commentsProvider.collapsedComments, 758 + onCollapseToggle: commentsProvider.toggleCollapsed, 759 + onContinueThread: _onContinueThread, 589 760 ); 590 - } 591 - } 592 - 593 - // Comment item - use existing CommentThread widget 594 - final comment = comments[index - 1]; 595 - return _CommentItem( 596 - comment: comment, 597 - currentTimeNotifier: 598 - commentsProvider.currentTimeNotifier, 599 - onCommentTap: _openReplyToComment, 600 - collapsedComments: commentsProvider.collapsedComments, 601 - onCollapseToggle: commentsProvider.toggleCollapsed, 602 - onContinueThread: _onContinueThread, 603 - ); 604 - }, 605 - childCount: 606 - 1 + 607 - comments.length + 608 - (isLoadingMore || error != null ? 1 : 0), 761 + }, 762 + childCount: 763 + 1 + 764 + comments.length + 765 + (isLoadingMore || error != null ? 1 : 0), 766 + ), 767 + ), 609 768 ), 610 - ), 769 + ], 611 770 ), 612 - ], 613 - ), 614 - ), 771 + ), 615 772 // Prevents content showing through transparent status bar 616 773 const StatusBarOverlay(), 617 774 ], ··· 677 834 final Set<String> collapsedComments; 678 835 final void Function(String uri)? onCollapseToggle; 679 836 final void Function(ThreadViewComment, List<ThreadViewComment>)? 680 - onContinueThread; 837 + onContinueThread; 681 838 682 839 @override 683 840 Widget build(BuildContext context) {
+217
lib/services/comments_provider_cache.dart
··· 1 + import 'dart:collection'; 2 + 3 + import 'package:flutter/foundation.dart'; 4 + import '../providers/auth_provider.dart'; 5 + import '../providers/comments_provider.dart'; 6 + import '../providers/vote_provider.dart'; 7 + import 'comment_service.dart'; 8 + 9 + /// Comments Provider Cache 10 + /// 11 + /// Manages cached CommentsProvider instances per post URI using LRU eviction. 12 + /// Inspired by Thunder app's architecture for instant back navigation. 13 + /// 14 + /// Key features: 15 + /// - One CommentsProvider per post URI 16 + /// - LRU eviction (default: 15 most recent posts) 17 + /// - Sign-out cleanup via AuthProvider listener 18 + /// 19 + /// Usage: 20 + /// ```dart 21 + /// final cache = context.read<CommentsProviderCache>(); 22 + /// final provider = cache.getProvider( 23 + /// postUri: post.uri, 24 + /// postCid: post.cid, 25 + /// ); 26 + /// ``` 27 + class CommentsProviderCache { 28 + CommentsProviderCache({ 29 + required AuthProvider authProvider, 30 + required VoteProvider voteProvider, 31 + required CommentService commentService, 32 + this.maxSize = 15, 33 + }) : _authProvider = authProvider, 34 + _voteProvider = voteProvider, 35 + _commentService = commentService { 36 + _wasAuthenticated = _authProvider.isAuthenticated; 37 + _authProvider.addListener(_onAuthChanged); 38 + } 39 + 40 + final AuthProvider _authProvider; 41 + final VoteProvider _voteProvider; 42 + final CommentService _commentService; 43 + 44 + /// Maximum number of providers to cache 45 + final int maxSize; 46 + 47 + /// LRU cache - LinkedHashMap maintains insertion order 48 + /// Most recently accessed items are at the end 49 + final LinkedHashMap<String, CommentsProvider> _cache = LinkedHashMap(); 50 + 51 + /// Reference counts for "in-use" providers. 52 + /// 53 + /// Screens that hold onto a provider instance should call [acquireProvider] 54 + /// and later [releaseProvider] to prevent LRU eviction from disposing a 55 + /// provider that is still mounted in the navigation stack. 56 + final Map<String, int> _refCounts = {}; 57 + 58 + /// Track auth state for sign-out detection 59 + bool _wasAuthenticated = false; 60 + 61 + /// Acquire (get or create) a CommentsProvider for a post. 62 + /// 63 + /// This "pins" the provider to avoid LRU eviction while in use. 64 + /// Call [releaseProvider] when the consumer unmounts. 65 + /// 66 + /// If provider exists in cache, moves it to end (LRU touch). 67 + /// If cache is full, evicts the oldest *unreferenced* provider before 68 + /// creating a new one. If all providers are currently referenced, the cache 69 + /// may temporarily exceed [maxSize] to avoid disposing active providers. 70 + CommentsProvider acquireProvider({ 71 + required String postUri, 72 + required String postCid, 73 + }) { 74 + final provider = _getOrCreateProvider(postUri: postUri, postCid: postCid); 75 + _refCounts[postUri] = (_refCounts[postUri] ?? 0) + 1; 76 + return provider; 77 + } 78 + 79 + /// Release a previously acquired provider for a post. 80 + /// 81 + /// Once released, the provider becomes eligible for LRU eviction. 82 + void releaseProvider(String postUri) { 83 + final current = _refCounts[postUri]; 84 + if (current == null) { 85 + return; 86 + } 87 + 88 + if (current <= 1) { 89 + _refCounts.remove(postUri); 90 + } else { 91 + _refCounts[postUri] = current - 1; 92 + } 93 + 94 + _evictIfNeeded(); 95 + } 96 + 97 + /// Legacy name kept for compatibility: prefer [acquireProvider]. 98 + CommentsProvider getProvider({ 99 + required String postUri, 100 + required String postCid, 101 + }) => acquireProvider(postUri: postUri, postCid: postCid); 102 + 103 + CommentsProvider _getOrCreateProvider({ 104 + required String postUri, 105 + required String postCid, 106 + }) { 107 + // Check if already cached 108 + if (_cache.containsKey(postUri)) { 109 + // Move to end (most recently used) 110 + final provider = _cache.remove(postUri)!; 111 + _cache[postUri] = provider; 112 + 113 + if (kDebugMode) { 114 + debugPrint('📦 Cache hit: $postUri (${_cache.length}/$maxSize)'); 115 + } 116 + 117 + return provider; 118 + } 119 + 120 + // Evict unreferenced providers if at capacity. 121 + if (_cache.length >= maxSize) { 122 + _evictIfNeeded(includingOne: true); 123 + } 124 + 125 + // Create new provider 126 + final provider = CommentsProvider( 127 + _authProvider, 128 + voteProvider: _voteProvider, 129 + commentService: _commentService, 130 + postUri: postUri, 131 + postCid: postCid, 132 + ); 133 + 134 + _cache[postUri] = provider; 135 + 136 + if (kDebugMode) { 137 + debugPrint('📦 Cache miss: $postUri (${_cache.length}/$maxSize)'); 138 + if (_cache.length > maxSize) { 139 + debugPrint( 140 + '📌 Cache exceeded maxSize because active providers are pinned', 141 + ); 142 + } 143 + } 144 + 145 + return provider; 146 + } 147 + 148 + void _evictIfNeeded({bool includingOne = false}) { 149 + final targetSize = includingOne ? maxSize - 1 : maxSize; 150 + while (_cache.length > targetSize) { 151 + String? oldestUnreferencedKey; 152 + for (final key in _cache.keys) { 153 + if ((_refCounts[key] ?? 0) == 0) { 154 + oldestUnreferencedKey = key; 155 + break; 156 + } 157 + } 158 + 159 + if (oldestUnreferencedKey == null) { 160 + break; 161 + } 162 + 163 + final evicted = _cache.remove(oldestUnreferencedKey); 164 + evicted?.dispose(); 165 + 166 + if (kDebugMode) { 167 + debugPrint('🗑️ Cache evict: $oldestUnreferencedKey'); 168 + } 169 + } 170 + } 171 + 172 + /// Check if provider exists without creating 173 + bool hasProvider(String postUri) => _cache.containsKey(postUri); 174 + 175 + /// Get existing provider without creating (for checking state) 176 + CommentsProvider? peekProvider(String postUri) => _cache[postUri]; 177 + 178 + /// Remove specific provider (e.g., after post deletion) 179 + void removeProvider(String postUri) { 180 + final provider = _cache.remove(postUri); 181 + _refCounts.remove(postUri); 182 + provider?.dispose(); 183 + } 184 + 185 + /// Handle auth state changes - clear all on sign-out 186 + void _onAuthChanged() { 187 + final isAuthenticated = _authProvider.isAuthenticated; 188 + 189 + // Clear all cached providers on sign-out 190 + if (_wasAuthenticated && !isAuthenticated) { 191 + if (kDebugMode) { 192 + debugPrint('🔒 User signed out - clearing ${_cache.length} cached comment providers'); 193 + } 194 + clearAll(); 195 + } 196 + 197 + _wasAuthenticated = isAuthenticated; 198 + } 199 + 200 + /// Clear all cached providers 201 + void clearAll() { 202 + for (final provider in _cache.values) { 203 + provider.dispose(); 204 + } 205 + _cache.clear(); 206 + _refCounts.clear(); 207 + } 208 + 209 + /// Current cache size 210 + int get size => _cache.length; 211 + 212 + /// Dispose and cleanup 213 + void dispose() { 214 + _authProvider.removeListener(_onAuthChanged); 215 + clearAll(); 216 + } 217 + }
+65 -396
test/providers/comments_provider_test.dart
··· 39 39 40 40 commentsProvider = CommentsProvider( 41 41 mockAuthProvider, 42 + postUri: testPostUri, 43 + postCid: testPostCid, 42 44 apiService: mockApiService, 43 45 voteProvider: mockVoteProvider, 44 46 ); ··· 72 74 ), 73 75 ).thenAnswer((_) async => mockResponse); 74 76 75 - await commentsProvider.loadComments( 76 - postUri: testPostUri, 77 - postCid: testPostCid, 78 - refresh: true, 79 - ); 77 + await commentsProvider.loadComments(refresh: true); 80 78 81 79 expect(commentsProvider.comments.length, 2); 82 80 expect(commentsProvider.hasMore, true); ··· 98 96 ), 99 97 ).thenAnswer((_) async => mockResponse); 100 98 101 - await commentsProvider.loadComments( 102 - postUri: testPostUri, 103 - postCid: testPostCid, 104 - refresh: true, 105 - ); 99 + await commentsProvider.loadComments(refresh: true); 106 100 107 101 expect(commentsProvider.comments.isEmpty, true); 108 102 expect(commentsProvider.hasMore, false); ··· 121 115 ), 122 116 ).thenThrow(Exception('Network error')); 123 117 124 - await commentsProvider.loadComments( 125 - postUri: testPostUri, 126 - postCid: testPostCid, 127 - refresh: true, 128 - ); 118 + await commentsProvider.loadComments(refresh: true); 129 119 130 120 expect(commentsProvider.error, isNotNull); 131 121 expect(commentsProvider.error, contains('Network error')); ··· 145 135 ), 146 136 ).thenThrow(Exception('TimeoutException: Request timed out')); 147 137 148 - await commentsProvider.loadComments( 149 - postUri: testPostUri, 150 - postCid: testPostCid, 151 - refresh: true, 152 - ); 138 + await commentsProvider.loadComments(refresh: true); 153 139 154 140 expect(commentsProvider.error, isNotNull); 155 141 expect(commentsProvider.isLoading, false); ··· 174 160 ), 175 161 ).thenAnswer((_) async => firstResponse); 176 162 177 - await commentsProvider.loadComments( 178 - postUri: testPostUri, 179 - postCid: testPostCid, 180 - refresh: true, 181 - ); 163 + await commentsProvider.loadComments(refresh: true); 182 164 183 165 expect(commentsProvider.comments.length, 1); 184 166 ··· 200 182 ), 201 183 ).thenAnswer((_) async => secondResponse); 202 184 203 - await commentsProvider.loadComments( 204 - postUri: testPostUri, 205 - postCid: testPostCid, 206 - ); 185 + await commentsProvider.loadComments(); 207 186 208 187 expect(commentsProvider.comments.length, 2); 209 188 expect(commentsProvider.comments[0].comment.uri, 'comment1'); ··· 229 208 ), 230 209 ).thenAnswer((_) async => firstResponse); 231 210 232 - await commentsProvider.loadComments( 233 - postUri: testPostUri, 234 - postCid: testPostCid, 235 - refresh: true, 236 - ); 211 + await commentsProvider.loadComments(refresh: true); 237 212 238 213 expect(commentsProvider.comments.length, 1); 239 214 ··· 257 232 ), 258 233 ).thenAnswer((_) async => refreshResponse); 259 234 260 - await commentsProvider.loadComments( 261 - postUri: testPostUri, 262 - postCid: testPostCid, 263 - refresh: true, 264 - ); 235 + await commentsProvider.loadComments(refresh: true); 265 236 266 237 expect(commentsProvider.comments.length, 2); 267 238 expect(commentsProvider.comments[0].comment.uri, 'comment2'); ··· 285 256 ), 286 257 ).thenAnswer((_) async => response); 287 258 288 - await commentsProvider.loadComments( 289 - postUri: testPostUri, 290 - postCid: testPostCid, 291 - refresh: true, 292 - ); 259 + await commentsProvider.loadComments(refresh: true); 293 260 294 261 expect(commentsProvider.hasMore, false); 295 262 }); 296 263 297 - test('should reset state when loading different post', () async { 298 - // Load first post 299 - final firstResponse = CommentsResponse( 300 - post: {}, 301 - comments: [_createMockThreadComment('comment1')], 302 - cursor: 'cursor-1', 303 - ); 304 - 305 - when( 306 - mockApiService.getComments( 307 - postUri: anyNamed('postUri'), 308 - sort: anyNamed('sort'), 309 - timeframe: anyNamed('timeframe'), 310 - depth: anyNamed('depth'), 311 - limit: anyNamed('limit'), 312 - cursor: anyNamed('cursor'), 313 - ), 314 - ).thenAnswer((_) async => firstResponse); 315 - 316 - await commentsProvider.loadComments( 317 - postUri: testPostUri, 318 - postCid: testPostCid, 319 - refresh: true, 320 - ); 321 - 322 - expect(commentsProvider.comments.length, 1); 323 - 324 - // Load different post 325 - const differentPostUri = 326 - 'at://did:plc:test/social.coves.post.record/456'; 327 - const differentPostCid = 'different-post-cid'; 328 - final secondResponse = CommentsResponse( 329 - post: {}, 330 - comments: [_createMockThreadComment('comment2')], 331 - ); 332 - 333 - when( 334 - mockApiService.getComments( 335 - postUri: differentPostUri, 336 - sort: anyNamed('sort'), 337 - timeframe: anyNamed('timeframe'), 338 - depth: anyNamed('depth'), 339 - limit: anyNamed('limit'), 340 - cursor: anyNamed('cursor'), 341 - ), 342 - ).thenAnswer((_) async => secondResponse); 343 - 344 - await commentsProvider.loadComments( 345 - postUri: differentPostUri, 346 - postCid: differentPostCid, 347 - refresh: true, 348 - ); 349 - 350 - // Should have reset and loaded new comments 351 - expect(commentsProvider.comments.length, 1); 352 - expect(commentsProvider.comments[0].comment.uri, 'comment2'); 353 - }); 264 + // Note: "reset state when loading different post" test removed 265 + // Providers are now immutable per post - use CommentsProviderCache 266 + // to get separate providers for different posts 354 267 355 268 test('should not load when already loading', () async { 356 269 final response = CommentsResponse( ··· 374 287 }); 375 288 376 289 // Start first load 377 - final firstFuture = commentsProvider.loadComments( 378 - postUri: testPostUri, 379 - postCid: testPostCid, 380 - refresh: true, 381 - ); 290 + final firstFuture = commentsProvider.loadComments(refresh: true); 382 291 383 292 // Try to load again while still loading - should schedule a refresh 384 - await commentsProvider.loadComments( 385 - postUri: testPostUri, 386 - postCid: testPostCid, 387 - refresh: true, 388 - ); 293 + await commentsProvider.loadComments(refresh: true); 389 294 390 295 await firstFuture; 391 296 // Wait a bit for the pending refresh to execute ··· 425 330 ), 426 331 ).thenAnswer((_) async => mockResponse); 427 332 428 - await commentsProvider.loadComments( 429 - postUri: testPostUri, 430 - postCid: testPostCid, 431 - refresh: true, 432 - ); 333 + await commentsProvider.loadComments(refresh: true); 433 334 434 335 expect(commentsProvider.comments.length, 1); 435 336 expect(commentsProvider.error, null); ··· 455 356 ), 456 357 ).thenAnswer((_) async => mockResponse); 457 358 458 - await commentsProvider.loadComments( 459 - postUri: testPostUri, 460 - postCid: testPostCid, 461 - refresh: true, 462 - ); 359 + await commentsProvider.loadComments(refresh: true); 463 360 464 361 expect(commentsProvider.comments.length, 1); 465 362 expect(commentsProvider.error, null); ··· 486 383 ), 487 384 ).thenAnswer((_) async => initialResponse); 488 385 489 - await commentsProvider.loadComments( 490 - postUri: testPostUri, 491 - postCid: testPostCid, 492 - refresh: true, 493 - ); 386 + await commentsProvider.loadComments(refresh: true); 494 387 495 388 expect(commentsProvider.sort, 'hot'); 496 389 ··· 544 437 ), 545 438 ).thenAnswer((_) async => response); 546 439 547 - await commentsProvider.loadComments( 548 - postUri: testPostUri, 549 - postCid: testPostCid, 550 - refresh: true, 551 - ); 440 + await commentsProvider.loadComments(refresh: true); 552 441 553 442 // Try to set same sort option 554 443 await commentsProvider.setSortOption('hot'); ··· 587 476 ), 588 477 ).thenAnswer((_) async => initialResponse); 589 478 590 - await commentsProvider.loadComments( 591 - postUri: testPostUri, 592 - postCid: testPostCid, 593 - refresh: true, 594 - ); 479 + await commentsProvider.loadComments(refresh: true); 595 480 596 481 expect(commentsProvider.comments.length, 1); 597 482 ··· 619 504 expect(commentsProvider.comments.length, 2); 620 505 }); 621 506 622 - test('should not refresh if no post loaded', () async { 623 - await commentsProvider.refreshComments(); 624 - 625 - verifyNever( 626 - mockApiService.getComments( 627 - postUri: anyNamed('postUri'), 628 - sort: anyNamed('sort'), 629 - timeframe: anyNamed('timeframe'), 630 - depth: anyNamed('depth'), 631 - limit: anyNamed('limit'), 632 - cursor: anyNamed('cursor'), 633 - ), 634 - ); 635 - }); 507 + // Note: "should not refresh if no post loaded" test removed 508 + // Providers now always have a post URI at construction time 636 509 }); 637 510 638 511 group('loadMoreComments', () { ··· 657 530 ), 658 531 ).thenAnswer((_) async => initialResponse); 659 532 660 - await commentsProvider.loadComments( 661 - postUri: testPostUri, 662 - postCid: testPostCid, 663 - refresh: true, 664 - ); 533 + await commentsProvider.loadComments(refresh: true); 665 534 666 535 expect(commentsProvider.hasMore, true); 667 536 ··· 705 574 ), 706 575 ).thenAnswer((_) async => response); 707 576 708 - await commentsProvider.loadComments( 709 - postUri: testPostUri, 710 - postCid: testPostCid, 711 - refresh: true, 712 - ); 577 + await commentsProvider.loadComments(refresh: true); 713 578 714 579 expect(commentsProvider.hasMore, false); 715 580 ··· 729 594 ).called(1); 730 595 }); 731 596 732 - test('should not load more if no post loaded', () async { 733 - await commentsProvider.loadMoreComments(); 734 - 735 - verifyNever( 736 - mockApiService.getComments( 737 - postUri: anyNamed('postUri'), 738 - sort: anyNamed('sort'), 739 - timeframe: anyNamed('timeframe'), 740 - depth: anyNamed('depth'), 741 - limit: anyNamed('limit'), 742 - cursor: anyNamed('cursor'), 743 - ), 744 - ); 745 - }); 597 + // Note: "should not load more if no post loaded" test removed 598 + // Providers now always have a post URI at construction time 746 599 }); 747 600 748 601 group('retry', () { ··· 761 614 ), 762 615 ).thenThrow(Exception('Network error')); 763 616 764 - await commentsProvider.loadComments( 765 - postUri: testPostUri, 766 - postCid: testPostCid, 767 - refresh: true, 768 - ); 617 + await commentsProvider.loadComments(refresh: true); 769 618 770 619 expect(commentsProvider.error, isNotNull); 771 620 ··· 793 642 }); 794 643 }); 795 644 796 - group('Auth state changes', () { 797 - const testPostUri = 'at://did:plc:test/social.coves.post.record/123'; 798 - 799 - test('should clear comments on sign-out', () async { 800 - final response = CommentsResponse( 801 - post: {}, 802 - comments: [_createMockThreadComment('comment1')], 803 - ); 804 - 805 - when( 806 - mockApiService.getComments( 807 - postUri: anyNamed('postUri'), 808 - sort: anyNamed('sort'), 809 - timeframe: anyNamed('timeframe'), 810 - depth: anyNamed('depth'), 811 - limit: anyNamed('limit'), 812 - cursor: anyNamed('cursor'), 813 - ), 814 - ).thenAnswer((_) async => response); 815 - 816 - await commentsProvider.loadComments( 817 - postUri: testPostUri, 818 - postCid: testPostCid, 819 - refresh: true, 820 - ); 821 - 822 - expect(commentsProvider.comments.length, 1); 823 - 824 - // Simulate sign-out 825 - when(mockAuthProvider.isAuthenticated).thenReturn(false); 826 - // Trigger listener manually since we're using a mock 827 - commentsProvider.reset(); 828 - 829 - expect(commentsProvider.comments.isEmpty, true); 830 - }); 831 - }); 645 + // Note: "Auth state changes" group removed 646 + // Sign-out cleanup is now handled by CommentsProviderCache which disposes 647 + // all cached providers when the user signs out. Individual providers no 648 + // longer have a reset() method. 832 649 833 650 group('Time updates', () { 834 651 test('should start time updates when comments are loaded', () async { ··· 850 667 851 668 expect(commentsProvider.currentTimeNotifier.value, null); 852 669 853 - await commentsProvider.loadComments( 854 - postUri: testPostUri, 855 - postCid: testPostCid, 856 - refresh: true, 857 - ); 670 + await commentsProvider.loadComments(refresh: true); 858 671 859 672 expect(commentsProvider.currentTimeNotifier.value, isNotNull); 860 673 }); ··· 876 689 ), 877 690 ).thenAnswer((_) async => response); 878 691 879 - await commentsProvider.loadComments( 880 - postUri: testPostUri, 881 - postCid: testPostCid, 882 - refresh: true, 883 - ); 692 + await commentsProvider.loadComments(refresh: true); 884 693 885 694 expect(commentsProvider.currentTimeNotifier.value, isNotNull); 886 695 ··· 915 724 ), 916 725 ).thenAnswer((_) async => response); 917 726 918 - await commentsProvider.loadComments( 919 - postUri: testPostUri, 920 - postCid: testPostCid, 921 - refresh: true, 922 - ); 727 + await commentsProvider.loadComments(refresh: true); 923 728 924 729 expect(notificationCount, greaterThan(0)); 925 730 }); ··· 944 749 return response; 945 750 }); 946 751 947 - final loadFuture = commentsProvider.loadComments( 948 - postUri: testPostUri, 949 - postCid: testPostCid, 950 - refresh: true, 951 - ); 752 + final loadFuture = commentsProvider.loadComments(refresh: true); 952 753 953 754 // Should be loading 954 755 expect(commentsProvider.isLoading, true); ··· 986 787 ), 987 788 ).thenAnswer((_) async => response); 988 789 989 - await commentsProvider.loadComments( 990 - postUri: testPostUri, 991 - postCid: testPostCid, 992 - refresh: true, 993 - ); 790 + await commentsProvider.loadComments(refresh: true); 994 791 995 792 verify( 996 793 mockVoteProvider.setInitialVoteState( ··· 1024 821 ), 1025 822 ).thenAnswer((_) async => response); 1026 823 1027 - await commentsProvider.loadComments( 1028 - postUri: testPostUri, 1029 - postCid: testPostCid, 1030 - refresh: true, 1031 - ); 824 + await commentsProvider.loadComments(refresh: true); 1032 825 1033 826 verify( 1034 827 mockVoteProvider.setInitialVoteState( ··· 1064 857 ), 1065 858 ).thenAnswer((_) async => response); 1066 859 1067 - await commentsProvider.loadComments( 1068 - postUri: testPostUri, 1069 - postCid: testPostCid, 1070 - refresh: true, 1071 - ); 860 + await commentsProvider.loadComments(refresh: true); 1072 861 1073 862 // Should call setInitialVoteState with null to clear stale state 1074 863 verify( ··· 1114 903 ), 1115 904 ).thenAnswer((_) async => response); 1116 905 1117 - await commentsProvider.loadComments( 1118 - postUri: testPostUri, 1119 - postCid: testPostCid, 1120 - refresh: true, 1121 - ); 906 + await commentsProvider.loadComments(refresh: true); 1122 907 1123 908 // Should initialize vote state for both parent and reply 1124 909 verify( ··· 1177 962 ), 1178 963 ).thenAnswer((_) async => response); 1179 964 1180 - await commentsProvider.loadComments( 1181 - postUri: testPostUri, 1182 - postCid: testPostCid, 1183 - refresh: true, 1184 - ); 965 + await commentsProvider.loadComments(refresh: true); 1185 966 1186 967 // Should initialize vote state for all 3 levels 1187 968 verify( ··· 1246 1027 ).thenAnswer((_) async => page2Response); 1247 1028 1248 1029 // Load first page (refresh) 1249 - await commentsProvider.loadComments( 1250 - postUri: testPostUri, 1251 - postCid: testPostCid, 1252 - refresh: true, 1253 - ); 1030 + await commentsProvider.loadComments(refresh: true); 1254 1031 1255 1032 // Verify comment1 vote initialized 1256 1033 verify( ··· 1338 1115 expect(notificationCount, 2); 1339 1116 }); 1340 1117 1341 - test('should clear collapsed state on reset', () async { 1342 - // Collapse some comments 1343 - commentsProvider 1344 - ..toggleCollapsed('at://did:plc:test/comment/1') 1345 - ..toggleCollapsed('at://did:plc:test/comment/2'); 1346 - 1347 - expect(commentsProvider.collapsedComments.length, 2); 1348 - 1349 - // Reset should clear collapsed state 1350 - commentsProvider.reset(); 1351 - 1352 - expect(commentsProvider.collapsedComments.isEmpty, true); 1353 - expect( 1354 - commentsProvider.isCollapsed('at://did:plc:test/comment/1'), 1355 - false, 1356 - ); 1357 - expect( 1358 - commentsProvider.isCollapsed('at://did:plc:test/comment/2'), 1359 - false, 1360 - ); 1361 - }); 1118 + // Note: "clear collapsed state on reset" test removed 1119 + // Providers no longer have a reset() method - they are disposed entirely 1120 + // when evicted from cache or on sign-out 1362 1121 1363 1122 test('collapsedComments getter returns unmodifiable set', () { 1364 1123 commentsProvider.toggleCollapsed('at://did:plc:test/comment/1'); ··· 1372 1131 ); 1373 1132 }); 1374 1133 1375 - test('should clear collapsed state on post change', () async { 1376 - // Setup mock response 1377 - final response = CommentsResponse( 1378 - post: {}, 1379 - comments: [_createMockThreadComment('comment1')], 1380 - ); 1381 - 1382 - when( 1383 - mockApiService.getComments( 1384 - postUri: anyNamed('postUri'), 1385 - sort: anyNamed('sort'), 1386 - timeframe: anyNamed('timeframe'), 1387 - depth: anyNamed('depth'), 1388 - limit: anyNamed('limit'), 1389 - cursor: anyNamed('cursor'), 1390 - ), 1391 - ).thenAnswer((_) async => response); 1392 - 1393 - // Load first post 1394 - await commentsProvider.loadComments( 1395 - postUri: testPostUri, 1396 - postCid: testPostCid, 1397 - refresh: true, 1398 - ); 1399 - 1400 - // Collapse a comment 1401 - commentsProvider.toggleCollapsed('at://did:plc:test/comment/1'); 1402 - expect(commentsProvider.collapsedComments.length, 1); 1403 - 1404 - // Load different post 1405 - await commentsProvider.loadComments( 1406 - postUri: 'at://did:plc:test/social.coves.post.record/456', 1407 - postCid: 'different-cid', 1408 - refresh: true, 1409 - ); 1410 - 1411 - // Collapsed state should be cleared 1412 - expect(commentsProvider.collapsedComments.isEmpty, true); 1413 - }); 1134 + // Note: "clear collapsed state on post change" test removed 1135 + // Providers are now immutable per post - each post gets its own provider 1136 + // with its own collapsed state. Use CommentsProviderCache to get different 1137 + // providers for different posts. 1414 1138 }); 1415 1139 1416 1140 group('createComment', () { ··· 1438 1162 1439 1163 providerWithCommentService = CommentsProvider( 1440 1164 mockAuthProvider, 1165 + postUri: testPostUri, 1166 + postCid: testPostCid, 1441 1167 apiService: mockApiService, 1442 1168 voteProvider: mockVoteProvider, 1443 1169 commentService: mockCommentService, ··· 1450 1176 1451 1177 test('should throw ValidationException for empty content', () async { 1452 1178 // First load comments to set up post context 1453 - await providerWithCommentService.loadComments( 1454 - postUri: testPostUri, 1455 - postCid: testPostCid, 1456 - refresh: true, 1457 - ); 1179 + await providerWithCommentService.loadComments(refresh: true); 1458 1180 1459 1181 expect( 1460 1182 () => providerWithCommentService.createComment(content: ''), ··· 1471 1193 test( 1472 1194 'should throw ValidationException for whitespace-only content', 1473 1195 () async { 1474 - await providerWithCommentService.loadComments( 1475 - postUri: testPostUri, 1476 - postCid: testPostCid, 1477 - refresh: true, 1478 - ); 1196 + await providerWithCommentService.loadComments(refresh: true); 1479 1197 1480 1198 expect( 1481 1199 () => ··· 1488 1206 test( 1489 1207 'should throw ValidationException for content exceeding limit', 1490 1208 () async { 1491 - await providerWithCommentService.loadComments( 1492 - postUri: testPostUri, 1493 - postCid: testPostCid, 1494 - refresh: true, 1495 - ); 1209 + await providerWithCommentService.loadComments(refresh: true); 1496 1210 1497 1211 // Create a string longer than 10000 characters 1498 1212 final longContent = 'a' * 10001; ··· 1512 1226 ); 1513 1227 1514 1228 test('should count emoji correctly in character limit', () async { 1515 - await providerWithCommentService.loadComments( 1516 - postUri: testPostUri, 1517 - postCid: testPostCid, 1518 - refresh: true, 1519 - ); 1229 + await providerWithCommentService.loadComments(refresh: true); 1520 1230 1521 1231 // Each emoji should count as 1 character, not 2-4 bytes 1522 1232 // 9999 'a' chars + 1 emoji = 10000 chars (should pass) ··· 1551 1261 ).called(1); 1552 1262 }); 1553 1263 1554 - test('should throw ApiException when no post loaded', () async { 1555 - // Don't call loadComments first - no post context 1556 - 1557 - expect( 1558 - () => 1559 - providerWithCommentService.createComment(content: 'Test comment'), 1560 - throwsA( 1561 - isA<ApiException>().having( 1562 - (e) => e.message, 1563 - 'message', 1564 - contains('No post loaded'), 1565 - ), 1566 - ), 1567 - ); 1568 - }); 1264 + // Note: "should throw ApiException when no post loaded" test removed 1265 + // Post context is now always provided via constructor - this case can't occur 1569 1266 1570 1267 test('should throw ApiException when no CommentService', () async { 1571 1268 // Create provider without CommentService 1572 1269 final providerWithoutService = CommentsProvider( 1573 1270 mockAuthProvider, 1271 + postUri: testPostUri, 1272 + postCid: testPostCid, 1574 1273 apiService: mockApiService, 1575 1274 voteProvider: mockVoteProvider, 1576 1275 ); 1577 1276 1578 - await providerWithoutService.loadComments( 1579 - postUri: testPostUri, 1580 - postCid: testPostCid, 1581 - refresh: true, 1582 - ); 1583 - 1584 1277 expect( 1585 1278 () => providerWithoutService.createComment(content: 'Test comment'), 1586 1279 throwsA( ··· 1596 1289 }); 1597 1290 1598 1291 test('should create top-level comment (reply to post)', () async { 1599 - await providerWithCommentService.loadComments( 1600 - postUri: testPostUri, 1601 - postCid: testPostCid, 1602 - refresh: true, 1603 - ); 1292 + await providerWithCommentService.loadComments(refresh: true); 1604 1293 1605 1294 when( 1606 1295 mockCommentService.createComment( ··· 1635 1324 }); 1636 1325 1637 1326 test('should create nested comment (reply to comment)', () async { 1638 - await providerWithCommentService.loadComments( 1639 - postUri: testPostUri, 1640 - postCid: testPostCid, 1641 - refresh: true, 1642 - ); 1327 + await providerWithCommentService.loadComments(refresh: true); 1643 1328 1644 1329 when( 1645 1330 mockCommentService.createComment( ··· 1677 1362 }); 1678 1363 1679 1364 test('should trim content before sending', () async { 1680 - await providerWithCommentService.loadComments( 1681 - postUri: testPostUri, 1682 - postCid: testPostCid, 1683 - refresh: true, 1684 - ); 1365 + await providerWithCommentService.loadComments(refresh: true); 1685 1366 1686 1367 when( 1687 1368 mockCommentService.createComment( ··· 1715 1396 }); 1716 1397 1717 1398 test('should refresh comments after successful creation', () async { 1718 - await providerWithCommentService.loadComments( 1719 - postUri: testPostUri, 1720 - postCid: testPostCid, 1721 - refresh: true, 1722 - ); 1399 + await providerWithCommentService.loadComments(refresh: true); 1723 1400 1724 1401 when( 1725 1402 mockCommentService.createComment( ··· 1753 1430 }); 1754 1431 1755 1432 test('should rethrow exception from CommentService', () async { 1756 - await providerWithCommentService.loadComments( 1757 - postUri: testPostUri, 1758 - postCid: testPostCid, 1759 - refresh: true, 1760 - ); 1433 + await providerWithCommentService.loadComments(refresh: true); 1761 1434 1762 1435 when( 1763 1436 mockCommentService.createComment( ··· 1783 1456 }); 1784 1457 1785 1458 test('should accept content at exactly max length', () async { 1786 - await providerWithCommentService.loadComments( 1787 - postUri: testPostUri, 1788 - postCid: testPostCid, 1789 - refresh: true, 1790 - ); 1459 + await providerWithCommentService.loadComments(refresh: true); 1791 1460 1792 1461 final contentAtLimit = 'a' * CommentsProvider.maxCommentLength; 1793 1462
+30 -5
test/providers/feed_provider_test.dart
··· 38 38 }); 39 39 40 40 group('loadFeed', () { 41 - test('should load timeline when authenticated', () async { 41 + test('should load discover feed when authenticated by default', () async { 42 42 when(mockAuthProvider.isAuthenticated).thenReturn(true); 43 43 44 44 final mockResponse = TimelineResponse( ··· 47 47 ); 48 48 49 49 when( 50 - mockApiService.getTimeline( 50 + mockApiService.getDiscover( 51 51 sort: anyNamed('sort'), 52 52 timeframe: anyNamed('timeframe'), 53 53 limit: anyNamed('limit'), ··· 56 56 ).thenAnswer((_) async => mockResponse); 57 57 58 58 await feedProvider.loadFeed(refresh: true); 59 + 60 + expect(feedProvider.posts.length, 1); 61 + expect(feedProvider.error, null); 62 + expect(feedProvider.isLoading, false); 63 + }); 64 + 65 + test('should load timeline when feed type is For You', () async { 66 + when(mockAuthProvider.isAuthenticated).thenReturn(true); 67 + 68 + final mockResponse = TimelineResponse( 69 + feed: [_createMockPost()], 70 + cursor: 'next-cursor', 71 + ); 72 + 73 + when( 74 + mockApiService.getTimeline( 75 + sort: anyNamed('sort'), 76 + timeframe: anyNamed('timeframe'), 77 + limit: anyNamed('limit'), 78 + cursor: anyNamed('cursor'), 79 + ), 80 + ).thenAnswer((_) async => mockResponse); 81 + 82 + await feedProvider.setFeedType(FeedType.forYou); 59 83 60 84 expect(feedProvider.posts.length, 1); 61 85 expect(feedProvider.error, null); ··· 274 298 sort: anyNamed('sort'), 275 299 timeframe: anyNamed('timeframe'), 276 300 limit: anyNamed('limit'), 301 + cursor: anyNamed('cursor'), 277 302 ), 278 303 ).thenAnswer((_) async => firstResponse); 279 304 280 - await feedProvider.loadFeed(refresh: true); 305 + await feedProvider.setFeedType(FeedType.forYou); 281 306 282 307 // Load more 283 308 final secondResponse = TimelineResponse( ··· 316 341 ), 317 342 ).thenAnswer((_) async => response); 318 343 319 - await feedProvider.fetchTimeline(refresh: true); 344 + await feedProvider.setFeedType(FeedType.forYou); 320 345 await feedProvider.loadMore(); 321 346 322 347 // Should not make additional calls while loading ··· 356 381 ), 357 382 ).thenThrow(Exception('Network error')); 358 383 359 - await feedProvider.loadFeed(refresh: true); 384 + await feedProvider.setFeedType(FeedType.forYou); 360 385 expect(feedProvider.error, isNotNull); 361 386 362 387 // Retry
+19
test/test_helpers/mock_providers.dart
··· 2 2 import 'package:coves_flutter/providers/vote_provider.dart'; 3 3 import 'package:flutter/foundation.dart'; 4 4 5 + /// Mock CommentsProvider for testing 6 + class MockCommentsProvider extends ChangeNotifier { 7 + final String postUri; 8 + final String postCid; 9 + 10 + MockCommentsProvider({ 11 + required this.postUri, 12 + required this.postCid, 13 + }); 14 + 15 + final ValueNotifier<DateTime?> currentTimeNotifier = ValueNotifier(null); 16 + 17 + @override 18 + void dispose() { 19 + currentTimeNotifier.dispose(); 20 + super.dispose(); 21 + } 22 + } 23 + 5 24 /// Mock AuthProvider for testing 6 25 class MockAuthProvider extends ChangeNotifier { 7 26 bool _isAuthenticated = false;
+6 -4
test/widgets/feed_screen_test.dart
··· 215 215 expect(find.text('Test Post 2'), findsOneWidget); 216 216 }); 217 217 218 - testWidgets('should display "Feed" title when authenticated', ( 218 + testWidgets('should display feed type tabs when authenticated', ( 219 219 tester, 220 220 ) async { 221 221 fakeAuthProvider.setAuthenticated(value: true); 222 222 223 223 await tester.pumpWidget(createTestWidget()); 224 224 225 - expect(find.text('Feed'), findsOneWidget); 225 + expect(find.text('Discover'), findsOneWidget); 226 + expect(find.text('For You'), findsOneWidget); 226 227 }); 227 228 228 - testWidgets('should display "Explore" title when not authenticated', ( 229 + testWidgets('should display only Discover tab when not authenticated', ( 229 230 tester, 230 231 ) async { 231 232 fakeAuthProvider.setAuthenticated(value: false); 232 233 233 234 await tester.pumpWidget(createTestWidget()); 234 235 235 - expect(find.text('Explore'), findsOneWidget); 236 + expect(find.text('Discover'), findsOneWidget); 237 + expect(find.text('For You'), findsNothing); 236 238 }); 237 239 238 240 testWidgets('should handle pull-to-refresh', (tester) async {
+12
test/widgets/focused_thread_screen_test.dart
··· 1 1 import 'package:coves_flutter/models/comment.dart'; 2 2 import 'package:coves_flutter/models/post.dart'; 3 + import 'package:coves_flutter/providers/comments_provider.dart'; 3 4 import 'package:coves_flutter/screens/home/focused_thread_screen.dart'; 4 5 import 'package:flutter/material.dart'; 5 6 import 'package:flutter_test/flutter_test.dart'; ··· 10 11 void main() { 11 12 late MockAuthProvider mockAuthProvider; 12 13 late MockVoteProvider mockVoteProvider; 14 + late MockCommentsProvider mockCommentsProvider; 13 15 14 16 setUp(() { 15 17 mockAuthProvider = MockAuthProvider(); 16 18 mockVoteProvider = MockVoteProvider(); 19 + mockCommentsProvider = MockCommentsProvider( 20 + postUri: 'at://did:plc:test/post/123', 21 + postCid: 'post-cid', 22 + ); 23 + }); 24 + 25 + tearDown(() { 26 + mockCommentsProvider.dispose(); 17 27 }); 18 28 19 29 /// Helper to create a test comment ··· 61 71 thread: thread, 62 72 ancestors: ancestors, 63 73 onReply: onReply ?? (content, parent) async {}, 74 + // Note: Using mock cast - tests are skipped so this won't actually run 75 + commentsProvider: mockCommentsProvider as CommentsProvider, 64 76 ), 65 77 ), 66 78 );