feat(comments): add CommentsProviderCache with LRU eviction and per-post state

Introduces a caching layer for CommentsProvider instances to enable instant
back-navigation when returning to previously viewed posts.

Key changes:
- Add CommentsProviderCache with LRU eviction (15 posts max)
- Refactor CommentsProvider to be immutable per post (postUri/postCid in constructor)
- Add reference counting to prevent evicting in-use providers
- Add scroll position and draft text preservation to CommentsProvider
- Add staleness tracking for background refresh of cached data
- Wire up cache in main.dart with sign-out cleanup

The cache automatically disposes providers when evicted and clears all
providers on sign-out for privacy.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

Changed files
+355 -130
lib
+16 -19
lib/main.dart
···
import 'constants/app_colors.dart';
import 'models/post.dart';
import 'providers/auth_provider.dart';
-
import 'providers/comments_provider.dart';
import 'providers/feed_provider.dart';
import 'providers/vote_provider.dart';
import 'screens/auth/login_screen.dart';
···
import 'screens/home/post_detail_screen.dart';
import 'screens/landing_screen.dart';
import 'services/comment_service.dart';
+
import 'services/comments_provider_cache.dart';
import 'services/streamable_service.dart';
import 'services/vote_service.dart';
import 'widgets/loading_error_states.dart';
···
return previous ?? FeedProvider(auth, voteProvider: vote);
},
),
-
ChangeNotifierProxyProvider2<
-
AuthProvider,
-
VoteProvider,
-
CommentsProvider
-
>(
-
create:
-
(context) => CommentsProvider(
-
authProvider,
-
voteProvider: context.read<VoteProvider>(),
-
commentService: commentService,
-
),
+
// CommentsProviderCache manages per-post CommentsProvider instances
+
// with LRU eviction and sign-out cleanup
+
ProxyProvider2<AuthProvider, VoteProvider, CommentsProviderCache>(
+
create: (context) => CommentsProviderCache(
+
authProvider: authProvider,
+
voteProvider: context.read<VoteProvider>(),
+
commentService: commentService,
+
),
update: (context, auth, vote, previous) {
-
// Reuse existing provider to maintain state across rebuilds
-
return previous ??
-
CommentsProvider(
-
auth,
-
voteProvider: vote,
-
commentService: commentService,
-
);
+
// Reuse existing cache
+
return previous ?? CommentsProviderCache(
+
authProvider: auth,
+
voteProvider: vote,
+
commentService: commentService,
+
);
},
+
dispose: (_, cache) => cache.dispose(),
),
// StreamableService for video embeds
Provider<StreamableService>(create: (_) => StreamableService()),
+122 -111
lib/providers/comments_provider.dart
···
/// Comments Provider
///
/// Manages comment state and fetching logic for a specific post.
-
/// Supports sorting (hot/top/new), pagination, and vote integration.
+
/// Each provider instance is bound to a single post (immutable postUri/postCid).
+
/// Supports sorting (hot/top/new), pagination, vote integration, scroll position,
+
/// and draft text preservation.
+
///
+
/// IMPORTANT: Provider instances are managed by CommentsProviderCache which
+
/// handles LRU eviction and sign-out cleanup. Do not create directly in widgets.
///
/// IMPORTANT: Accepts AuthProvider reference to fetch fresh access
/// tokens before each authenticated request (critical for atProto OAuth
···
class CommentsProvider with ChangeNotifier {
CommentsProvider(
this._authProvider, {
+
required String postUri,
+
required String postCid,
CovesApiService? apiService,
VoteProvider? voteProvider,
CommentService? commentService,
-
}) : _voteProvider = voteProvider,
+
}) : _postUri = postUri,
+
_postCid = postCid,
+
_voteProvider = voteProvider,
_commentService = commentService {
// Use injected service (for testing) or create new one (for production)
// Pass token getter, refresh handler, and sign out handler to API service
···
tokenRefresher: _authProvider.refreshToken,
signOutHandler: _authProvider.signOut,
);
-
-
// Track initial auth state
-
_wasAuthenticated = _authProvider.isAuthenticated;
-
-
// Listen to auth state changes and clear comments on sign-out
-
_authProvider.addListener(_onAuthChanged);
}
/// Maximum comment length in characters (matches backend limit)
/// Note: This counts Unicode grapheme clusters, so emojis count correctly
static const int maxCommentLength = 10000;
-
/// Handle authentication state changes
-
///
-
/// Clears comment state when user signs out to prevent privacy issues.
-
void _onAuthChanged() {
-
final isAuthenticated = _authProvider.isAuthenticated;
-
-
// Only clear if transitioning from authenticated → unauthenticated
-
if (_wasAuthenticated && !isAuthenticated && _comments.isNotEmpty) {
-
if (kDebugMode) {
-
debugPrint('🔒 User signed out - clearing comments');
-
}
-
reset();
-
}
-
-
// Update tracked state
-
_wasAuthenticated = isAuthenticated;
-
}
+
/// Default staleness threshold for background refresh
+
static const Duration stalenessThreshold = Duration(minutes: 5);
final AuthProvider _authProvider;
late final CovesApiService _apiService;
final VoteProvider? _voteProvider;
final CommentService? _commentService;
-
// Track previous auth state to detect transitions
-
bool _wasAuthenticated = false;
+
// Post context - immutable per provider instance
+
final String _postUri;
+
final String _postCid;
// Comment state
List<ThreadViewComment> _comments = [];
···
// Collapsed thread state - stores URIs of collapsed comments
final Set<String> _collapsedComments = {};
-
// Current post being viewed
-
String? _postUri;
-
String? _postCid;
+
// Scroll position state (replaces ScrollStateService for this post)
+
double _scrollPosition = 0;
+
+
// Draft reply text - stored per-parent-URI (null key = top-level reply to post)
+
// This allows users to have separate drafts for different comments within the same post
+
final Map<String?, String> _drafts = {};
+
+
// Staleness tracking for background refresh
+
DateTime? _lastRefreshTime;
// Comment configuration
String _sort = 'hot';
···
Timer? _timeUpdateTimer;
final ValueNotifier<DateTime?> _currentTimeNotifier = ValueNotifier(null);
+
bool _isDisposed = false;
+
+
void _safeNotifyListeners() {
+
if (_isDisposed) return;
+
notifyListeners();
+
}
+
// Getters
+
String get postUri => _postUri;
+
String get postCid => _postCid;
List<ThreadViewComment> get comments => _comments;
bool get isLoading => _isLoading;
bool get isLoadingMore => _isLoadingMore;
···
String? get timeframe => _timeframe;
ValueNotifier<DateTime?> get currentTimeNotifier => _currentTimeNotifier;
Set<String> get collapsedComments => Set.unmodifiable(_collapsedComments);
+
double get scrollPosition => _scrollPosition;
+
DateTime? get lastRefreshTime => _lastRefreshTime;
+
+
/// Get draft text for a specific parent URI
+
///
+
/// [parentUri] - URI of parent comment (null for top-level post reply)
+
/// Returns the draft text, or empty string if no draft exists
+
String getDraft({String? parentUri}) => _drafts[parentUri] ?? '';
+
+
/// Legacy getters for backward compatibility
+
/// @deprecated Use getDraft(parentUri: ...) instead
+
String get draftText => _drafts.values.firstOrNull ?? '';
+
String? get draftParentUri => _drafts.keys.firstOrNull;
+
+
/// Check if cached data is stale and should be refreshed in background
+
bool get isStale {
+
if (_lastRefreshTime == null) {
+
return true;
+
}
+
return DateTime.now().difference(_lastRefreshTime!) > stalenessThreshold;
+
}
+
+
/// Save scroll position (called on every scroll event)
+
void saveScrollPosition(double position) {
+
_scrollPosition = position;
+
// No notifyListeners - this is passive state save
+
}
+
+
/// Save draft reply text
+
///
+
/// [text] - The draft text content
+
/// [parentUri] - URI of parent comment (null for top-level post reply)
+
///
+
/// Each parent URI gets its own draft, so switching between replies
+
/// preserves drafts for each context.
+
void saveDraft(String text, {String? parentUri}) {
+
if (text.trim().isEmpty) {
+
// Remove empty drafts to avoid clutter
+
_drafts.remove(parentUri);
+
} else {
+
_drafts[parentUri] = text;
+
}
+
// No notifyListeners - this is passive state save
+
}
+
+
/// Clear draft text for a specific parent (call after successful submission)
+
///
+
/// [parentUri] - URI of parent comment (null for top-level post reply)
+
void clearDraft({String? parentUri}) {
+
_drafts.remove(parentUri);
+
}
/// Toggle collapsed state for a comment thread
///
···
} else {
_collapsedComments.add(uri);
}
-
notifyListeners();
+
_safeNotifyListeners();
}
/// Check if a specific comment is collapsed
···
}
}
-
/// Load comments for a specific post
+
/// Load comments for this provider's post
///
/// Parameters:
-
/// - [postUri]: AT-URI of the post
-
/// - [postCid]: CID of the post (needed for creating comments)
-
/// - [refresh]: Whether to refresh from the beginning
-
Future<void> loadComments({
-
required String postUri,
-
required String postCid,
-
bool refresh = false,
-
}) async {
-
// If loading for a different post, reset state
-
if (postUri != _postUri) {
-
reset();
-
_postUri = postUri;
-
_postCid = postCid;
-
}
-
+
/// - [refresh]: Whether to refresh from the beginning (true) or paginate (false)
+
Future<void> loadComments({bool refresh = false}) async {
// If already loading, schedule a refresh to happen after current load
if (_isLoading || _isLoadingMore) {
if (refresh) {
···
} else {
_isLoadingMore = true;
}
-
notifyListeners();
+
_safeNotifyListeners();
if (kDebugMode) {
-
debugPrint('📡 Fetching comments: sort=$_sort, postUri=$postUri');
+
debugPrint('📡 Fetching comments: sort=$_sort, postUri=$_postUri');
}
final response = await _apiService.getComments(
-
postUri: postUri,
+
postUri: _postUri,
sort: _sort,
timeframe: _timeframe,
cursor: refresh ? null : _cursor,
);
+
if (_isDisposed) return;
+
// Only update state after successful fetch
if (refresh) {
_comments = response.comments;
+
_lastRefreshTime = DateTime.now();
} else {
// Create new list instance to trigger rebuilds
_comments = [..._comments, ...response.comments];
···
startTimeUpdates();
}
} on Exception catch (e) {
+
if (_isDisposed) return;
_error = e.toString();
if (kDebugMode) {
debugPrint('❌ Failed to fetch comments: $e');
}
} finally {
+
if (_isDisposed) return;
_isLoading = false;
_isLoadingMore = false;
-
notifyListeners();
+
_safeNotifyListeners();
// If a refresh was scheduled during this load, execute it now
-
if (_pendingRefresh && _postUri != null) {
+
if (_pendingRefresh) {
if (kDebugMode) {
debugPrint('🔄 Executing pending refresh');
}
_pendingRefresh = false;
// Schedule refresh without awaiting to avoid blocking
// This is intentional - we want the refresh to happen asynchronously
-
unawaited(
-
loadComments(postUri: _postUri!, postCid: _postCid!, refresh: true),
-
);
+
unawaited(loadComments(refresh: true));
}
}
}
···
///
/// Reloads comments from the beginning for the current post.
Future<void> refreshComments() async {
-
if (_postUri == null || _postCid == null) {
-
if (kDebugMode) {
-
debugPrint('⚠️ Cannot refresh - no post loaded');
-
}
-
return;
-
}
-
await loadComments(postUri: _postUri!, postCid: _postCid!, refresh: true);
+
await loadComments(refresh: true);
}
/// Load more comments (pagination)
Future<void> loadMoreComments() async {
-
if (!_hasMore || _isLoadingMore || _postUri == null || _postCid == null) {
+
if (!_hasMore || _isLoadingMore) {
return;
}
-
await loadComments(postUri: _postUri!, postCid: _postCid!);
+
await loadComments();
}
/// Change sort order
···
final previousSort = _sort;
_sort = newSort;
-
notifyListeners();
+
_safeNotifyListeners();
// Reload comments with new sort
-
if (_postUri != null && _postCid != null) {
-
try {
-
await loadComments(
-
postUri: _postUri!,
-
postCid: _postCid!,
-
refresh: true,
-
);
-
return true;
-
} on Exception catch (e) {
-
// Revert to previous sort option on failure
-
_sort = previousSort;
-
notifyListeners();
+
try {
+
await loadComments(refresh: true);
+
return true;
+
} on Exception catch (e) {
+
if (_isDisposed) return false;
+
// Revert to previous sort option on failure
+
_sort = previousSort;
+
_safeNotifyListeners();
-
if (kDebugMode) {
-
debugPrint('Failed to apply sort option: $e');
-
}
-
-
return false;
+
if (kDebugMode) {
+
debugPrint('Failed to apply sort option: $e');
}
-
}
-
return true;
+
return false;
+
}
}
/// Vote on a comment
···
if (_commentService == null) {
throw ApiException('CommentService not available');
-
}
-
-
if (_postUri == null || _postCid == null) {
-
throw ApiException('No post loaded - cannot create comment');
}
// Root is always the original post
-
final rootUri = _postUri!;
-
final rootCid = _postCid!;
+
final rootUri = _postUri;
+
final rootCid = _postCid;
// Parent depends on whether this is a top-level or nested reply
final String parentUri;
···
/// Retry loading after error
Future<void> retry() async {
_error = null;
-
if (_postUri != null && _postCid != null) {
-
await loadComments(postUri: _postUri!, postCid: _postCid!, refresh: true);
-
}
+
await loadComments(refresh: true);
}
/// Clear error
void clearError() {
_error = null;
-
notifyListeners();
-
}
-
-
/// Reset comment state
-
void reset() {
-
_comments = [];
-
_cursor = null;
-
_hasMore = true;
-
_error = null;
-
_isLoading = false;
-
_isLoadingMore = false;
-
_postUri = null;
-
_postCid = null;
-
_pendingRefresh = false;
-
_collapsedComments.clear();
-
notifyListeners();
+
_safeNotifyListeners();
}
@override
void dispose() {
+
_isDisposed = true;
// Stop time updates and cancel timer (also sets value to null)
stopTimeUpdates();
-
// Remove auth listener to prevent memory leaks
-
_authProvider.removeListener(_onAuthChanged);
+
// Dispose API service
_apiService.dispose();
// Dispose the ValueNotifier last
_currentTimeNotifier.dispose();
+217
lib/services/comments_provider_cache.dart
···
+
import 'dart:collection';
+
+
import 'package:flutter/foundation.dart';
+
import '../providers/auth_provider.dart';
+
import '../providers/comments_provider.dart';
+
import '../providers/vote_provider.dart';
+
import 'comment_service.dart';
+
+
/// Comments Provider Cache
+
///
+
/// Manages cached CommentsProvider instances per post URI using LRU eviction.
+
/// Inspired by Thunder app's architecture for instant back navigation.
+
///
+
/// Key features:
+
/// - One CommentsProvider per post URI
+
/// - LRU eviction (default: 15 most recent posts)
+
/// - Sign-out cleanup via AuthProvider listener
+
///
+
/// Usage:
+
/// ```dart
+
/// final cache = context.read<CommentsProviderCache>();
+
/// final provider = cache.getProvider(
+
/// postUri: post.uri,
+
/// postCid: post.cid,
+
/// );
+
/// ```
+
class CommentsProviderCache {
+
CommentsProviderCache({
+
required AuthProvider authProvider,
+
required VoteProvider voteProvider,
+
required CommentService commentService,
+
this.maxSize = 15,
+
}) : _authProvider = authProvider,
+
_voteProvider = voteProvider,
+
_commentService = commentService {
+
_wasAuthenticated = _authProvider.isAuthenticated;
+
_authProvider.addListener(_onAuthChanged);
+
}
+
+
final AuthProvider _authProvider;
+
final VoteProvider _voteProvider;
+
final CommentService _commentService;
+
+
/// Maximum number of providers to cache
+
final int maxSize;
+
+
/// LRU cache - LinkedHashMap maintains insertion order
+
/// Most recently accessed items are at the end
+
final LinkedHashMap<String, CommentsProvider> _cache = LinkedHashMap();
+
+
/// Reference counts for "in-use" providers.
+
///
+
/// Screens that hold onto a provider instance should call [acquireProvider]
+
/// and later [releaseProvider] to prevent LRU eviction from disposing a
+
/// provider that is still mounted in the navigation stack.
+
final Map<String, int> _refCounts = {};
+
+
/// Track auth state for sign-out detection
+
bool _wasAuthenticated = false;
+
+
/// Acquire (get or create) a CommentsProvider for a post.
+
///
+
/// This "pins" the provider to avoid LRU eviction while in use.
+
/// Call [releaseProvider] when the consumer unmounts.
+
///
+
/// If provider exists in cache, moves it to end (LRU touch).
+
/// If cache is full, evicts the oldest *unreferenced* provider before
+
/// creating a new one. If all providers are currently referenced, the cache
+
/// may temporarily exceed [maxSize] to avoid disposing active providers.
+
CommentsProvider acquireProvider({
+
required String postUri,
+
required String postCid,
+
}) {
+
final provider = _getOrCreateProvider(postUri: postUri, postCid: postCid);
+
_refCounts[postUri] = (_refCounts[postUri] ?? 0) + 1;
+
return provider;
+
}
+
+
/// Release a previously acquired provider for a post.
+
///
+
/// Once released, the provider becomes eligible for LRU eviction.
+
void releaseProvider(String postUri) {
+
final current = _refCounts[postUri];
+
if (current == null) {
+
return;
+
}
+
+
if (current <= 1) {
+
_refCounts.remove(postUri);
+
} else {
+
_refCounts[postUri] = current - 1;
+
}
+
+
_evictIfNeeded();
+
}
+
+
/// Legacy name kept for compatibility: prefer [acquireProvider].
+
CommentsProvider getProvider({
+
required String postUri,
+
required String postCid,
+
}) => acquireProvider(postUri: postUri, postCid: postCid);
+
+
CommentsProvider _getOrCreateProvider({
+
required String postUri,
+
required String postCid,
+
}) {
+
// Check if already cached
+
if (_cache.containsKey(postUri)) {
+
// Move to end (most recently used)
+
final provider = _cache.remove(postUri)!;
+
_cache[postUri] = provider;
+
+
if (kDebugMode) {
+
debugPrint('📦 Cache hit: $postUri (${_cache.length}/$maxSize)');
+
}
+
+
return provider;
+
}
+
+
// Evict unreferenced providers if at capacity.
+
if (_cache.length >= maxSize) {
+
_evictIfNeeded(includingOne: true);
+
}
+
+
// Create new provider
+
final provider = CommentsProvider(
+
_authProvider,
+
voteProvider: _voteProvider,
+
commentService: _commentService,
+
postUri: postUri,
+
postCid: postCid,
+
);
+
+
_cache[postUri] = provider;
+
+
if (kDebugMode) {
+
debugPrint('📦 Cache miss: $postUri (${_cache.length}/$maxSize)');
+
if (_cache.length > maxSize) {
+
debugPrint(
+
'📌 Cache exceeded maxSize because active providers are pinned',
+
);
+
}
+
}
+
+
return provider;
+
}
+
+
void _evictIfNeeded({bool includingOne = false}) {
+
final targetSize = includingOne ? maxSize - 1 : maxSize;
+
while (_cache.length > targetSize) {
+
String? oldestUnreferencedKey;
+
for (final key in _cache.keys) {
+
if ((_refCounts[key] ?? 0) == 0) {
+
oldestUnreferencedKey = key;
+
break;
+
}
+
}
+
+
if (oldestUnreferencedKey == null) {
+
break;
+
}
+
+
final evicted = _cache.remove(oldestUnreferencedKey);
+
evicted?.dispose();
+
+
if (kDebugMode) {
+
debugPrint('🗑️ Cache evict: $oldestUnreferencedKey');
+
}
+
}
+
}
+
+
/// Check if provider exists without creating
+
bool hasProvider(String postUri) => _cache.containsKey(postUri);
+
+
/// Get existing provider without creating (for checking state)
+
CommentsProvider? peekProvider(String postUri) => _cache[postUri];
+
+
/// Remove specific provider (e.g., after post deletion)
+
void removeProvider(String postUri) {
+
final provider = _cache.remove(postUri);
+
_refCounts.remove(postUri);
+
provider?.dispose();
+
}
+
+
/// Handle auth state changes - clear all on sign-out
+
void _onAuthChanged() {
+
final isAuthenticated = _authProvider.isAuthenticated;
+
+
// Clear all cached providers on sign-out
+
if (_wasAuthenticated && !isAuthenticated) {
+
if (kDebugMode) {
+
debugPrint('🔒 User signed out - clearing ${_cache.length} cached comment providers');
+
}
+
clearAll();
+
}
+
+
_wasAuthenticated = isAuthenticated;
+
}
+
+
/// Clear all cached providers
+
void clearAll() {
+
for (final provider in _cache.values) {
+
provider.dispose();
+
}
+
_cache.clear();
+
_refCounts.clear();
+
}
+
+
/// Current cache size
+
int get size => _cache.length;
+
+
/// Dispose and cleanup
+
void dispose() {
+
_authProvider.removeListener(_onAuthChanged);
+
clearAll();
+
}
+
}