feat(comments): integrate provider cache in screens with scroll and draft preservation

Updates PostDetailScreen, ReplyScreen, and FocusedThreadScreen to use
the new CommentsProviderCache for instant back-navigation and state preservation.

PostDetailScreen:
- Acquires provider from cache with reference counting
- Restores scroll position when returning to cached comments
- Background refresh if data is stale (>5 min)
- Handles sign-out by navigating back to feed

ReplyScreen:
- Requires CommentsProvider parameter for draft access
- Saves draft text on cancel, restores on reopen
- Per-parent-URI drafts (separate drafts for different reply contexts)
- Auto-closes on sign-out

FocusedThreadScreen:
- Passes CommentsProvider to children for consistent draft/vote state

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

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

Changed files
+410 -129
lib
+119 -10
lib/screens/compose/reply_screen.dart
···
import 'dart:async';
import 'dart:math' as math;
+
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
···
import '../../constants/app_colors.dart';
import '../../models/comment.dart';
import '../../models/post.dart';
+
import '../../providers/auth_provider.dart';
import '../../providers/comments_provider.dart';
import '../../widgets/comment_thread.dart';
import '../../widgets/post_card.dart';
···
this.post,
this.comment,
required this.onSubmit,
+
required this.commentsProvider,
super.key,
}) : assert(
(post != null) != (comment != null),
···
/// Callback when user submits reply
final Future<void> Function(String content) onSubmit;
+
/// CommentsProvider for draft save/restore and time updates
+
final CommentsProvider commentsProvider;
+
@override
State<ReplyScreen> createState() => _ReplyScreenState();
}
···
bool _hasText = false;
bool _isKeyboardOpening = false;
bool _isSubmitting = false;
+
bool _authInvalidated = false;
double _lastKeyboardHeight = 0;
Timer? _bannerDismissTimer;
···
_textController.addListener(_onTextChanged);
_focusNode.addListener(_onFocusChanged);
-
// Autofocus with delay (Thunder approach - let screen render first)
-
Future.delayed(const Duration(milliseconds: 300), () {
+
// Restore draft and autofocus after frame is built
+
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
-
_isKeyboardOpening = true;
-
_focusNode.requestFocus();
+
_setupAuthListener();
+
_restoreDraft();
+
+
// Autofocus with delay (Thunder approach - let screen render first)
+
Future.delayed(const Duration(milliseconds: 300), () {
+
if (mounted) {
+
_isKeyboardOpening = true;
+
_focusNode.requestFocus();
+
}
+
});
}
});
}
+
void _setupAuthListener() {
+
try {
+
context.read<AuthProvider>().addListener(_onAuthChanged);
+
} on Exception {
+
// AuthProvider may not be available (e.g., tests)
+
}
+
}
+
+
void _onAuthChanged() {
+
if (!mounted || _authInvalidated) return;
+
+
try {
+
final authProvider = context.read<AuthProvider>();
+
if (!authProvider.isAuthenticated) {
+
_authInvalidated = true;
+
if (mounted) {
+
Navigator.of(context).pop();
+
}
+
}
+
} on Exception {
+
// AuthProvider may not be available
+
}
+
}
+
+
/// Restore draft text if available for this reply context
+
void _restoreDraft() {
+
try {
+
final commentsProvider = context.read<CommentsProvider>();
+
final ourParentUri = widget.comment?.comment.uri;
+
+
// Get draft for this specific parent URI
+
final draft = commentsProvider.getDraft(parentUri: ourParentUri);
+
+
if (draft.isNotEmpty) {
+
_textController.text = draft;
+
setState(() {
+
_hasText = true;
+
});
+
}
+
} on Exception catch (e) {
+
// CommentsProvider might not be available (e.g., during testing)
+
if (kDebugMode) {
+
debugPrint('📝 Draft not restored: $e');
+
}
+
}
+
}
+
void _onFocusChanged() {
// When text field gains focus, scroll to bottom as keyboard opens
if (_focusNode.hasFocus) {
···
@override
void didChangeMetrics() {
super.didChangeMetrics();
+
// Guard against being called after widget is deactivated
+
// (can happen during keyboard animation while navigating away)
+
if (!mounted) return;
+
final keyboardHeight = View.of(context).viewInsets.bottom;
// Detect keyboard closing and unfocus text field
···
@override
void dispose() {
_bannerDismissTimer?.cancel();
+
try {
+
context.read<AuthProvider>().removeListener(_onAuthChanged);
+
} on Exception {
+
// AuthProvider may not be available
+
}
WidgetsBinding.instance.removeObserver(this);
_textController.dispose();
_focusNode.dispose();
···
}
Future<void> _handleSubmit() async {
+
if (_authInvalidated) {
+
return;
+
}
+
final content = _textController.text.trim();
if (content.isEmpty) {
return;
···
try {
await widget.onSubmit(content);
+
// Clear draft on success
+
try {
+
if (mounted) {
+
final parentUri = widget.comment?.comment.uri;
+
context.read<CommentsProvider>().clearDraft(parentUri: parentUri);
+
}
+
} on Exception catch (e) {
+
// CommentsProvider might not be available
+
if (kDebugMode) {
+
debugPrint('📝 Draft not cleared: $e');
+
}
+
}
// Pop screen after successful submission
if (mounted) {
Navigator.of(context).pop();
···
}
void _handleCancel() {
+
// Save draft before closing (if text is not empty)
+
_saveDraft();
Navigator.of(context).pop();
}
+
/// Save current text as draft
+
void _saveDraft() {
+
try {
+
final commentsProvider = context.read<CommentsProvider>();
+
commentsProvider.saveDraft(
+
_textController.text,
+
parentUri: widget.comment?.comment.uri,
+
);
+
} on Exception catch (e) {
+
// CommentsProvider might not be available
+
if (kDebugMode) {
+
debugPrint('📝 Draft not saved: $e');
+
}
+
}
+
}
+
@override
Widget build(BuildContext context) {
-
return GestureDetector(
-
onTap: () {
-
// Dismiss keyboard when tapping outside
-
FocusManager.instance.primaryFocus?.unfocus();
-
},
-
child: Scaffold(
+
// Provide CommentsProvider to descendant widgets (Consumer in _ContextPreview)
+
return ChangeNotifierProvider.value(
+
value: widget.commentsProvider,
+
child: GestureDetector(
+
onTap: () {
+
// Dismiss keyboard when tapping outside
+
FocusManager.instance.primaryFocus?.unfocus();
+
},
+
child: Scaffold(
backgroundColor: AppColors.background,
resizeToAvoidBottomInset: false, // Thunder approach
appBar: AppBar(
···
),
],
),
+
),
),
);
}
+23 -8
lib/screens/home/focused_thread_screen.dart
···
import '../../constants/app_colors.dart';
import '../../models/comment.dart';
import '../../providers/auth_provider.dart';
+
import '../../providers/comments_provider.dart';
import '../../widgets/comment_card.dart';
import '../../widgets/comment_thread.dart';
import '../../widgets/status_bar_overlay.dart';
···
/// any collapsed state is reset. This is by design - it allows users to
/// explore deep threads without their collapse choices persisting across
/// navigation, keeping the focused view clean and predictable.
+
///
+
/// ## Provider Sharing
+
/// Receives the parent's CommentsProvider for draft text preservation and
+
/// consistent vote state display.
class FocusedThreadScreen extends StatelessWidget {
const FocusedThreadScreen({
required this.thread,
required this.ancestors,
required this.onReply,
+
required this.commentsProvider,
super.key,
});
···
/// Callback when user replies to a comment
final Future<void> Function(String content, ThreadViewComment parent) onReply;
+
/// Parent's CommentsProvider for draft preservation and vote state
+
final CommentsProvider commentsProvider;
+
@override
Widget build(BuildContext context) {
-
return Scaffold(
-
backgroundColor: AppColors.background,
-
body: _FocusedThreadBody(
-
thread: thread,
-
ancestors: ancestors,
-
onReply: onReply,
+
// Expose parent's CommentsProvider for ReplyScreen draft access
+
return ChangeNotifierProvider.value(
+
value: commentsProvider,
+
child: Scaffold(
+
backgroundColor: AppColors.background,
+
body: _FocusedThreadBody(
+
thread: thread,
+
ancestors: ancestors,
+
onReply: onReply,
+
),
),
);
}
···
Navigator.of(context).push(
MaterialPageRoute<void>(
-
builder: (context) => ReplyScreen(
+
builder: (navigatorContext) => ReplyScreen(
comment: comment,
onSubmit: (content) => widget.onReply(content, comment),
+
commentsProvider: context.read<CommentsProvider>(),
),
),
);
···
) {
Navigator.of(context).push(
MaterialPageRoute<void>(
-
builder: (context) => FocusedThreadScreen(
+
builder: (navigatorContext) => FocusedThreadScreen(
thread: thread,
ancestors: ancestors,
onReply: widget.onReply,
+
commentsProvider: context.read<CommentsProvider>(),
),
),
);
+268 -111
lib/screens/home/post_detail_screen.dart
···
import 'package:cached_network_image/cached_network_image.dart';
+
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
···
import '../../providers/auth_provider.dart';
import '../../providers/comments_provider.dart';
import '../../providers/vote_provider.dart';
+
import '../../services/comments_provider_cache.dart';
import '../../utils/community_handle_utils.dart';
import '../../utils/error_messages.dart';
import '../../widgets/comment_thread.dart';
···
final ScrollController _scrollController = ScrollController();
final GlobalKey _commentsHeaderKey = GlobalKey();
-
// Current sort option
-
String _currentSort = 'hot';
+
// Cached provider from CommentsProviderCache
+
late CommentsProvider _commentsProvider;
+
CommentsProviderCache? _commentsCache;
+
+
// Track initialization state
+
bool _isInitialized = false;
+
+
// Track if provider has been invalidated (e.g., by sign-out)
+
bool _providerInvalidated = false;
@override
void initState() {
super.initState();
-
-
// Initialize scroll controller for pagination
_scrollController.addListener(_onScroll);
-
// Load comments after frame is built using provider from tree
+
// Initialize provider after frame is built
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
-
_loadComments();
+
_initializeProvider();
+
_setupAuthListener();
+
}
+
});
+
}
+
+
/// Listen for auth state changes to handle sign-out
+
void _setupAuthListener() {
+
final authProvider = context.read<AuthProvider>();
+
authProvider.addListener(_onAuthChanged);
+
}
+
+
/// Handle auth state changes (specifically sign-out)
+
void _onAuthChanged() {
+
if (!mounted) return;
+
+
final authProvider = context.read<AuthProvider>();
+
+
// If user signed out while viewing this screen, navigate back
+
// The CommentsProviderCache has already disposed our provider
+
if (!authProvider.isAuthenticated && _isInitialized && !_providerInvalidated) {
+
_providerInvalidated = true;
+
+
if (kDebugMode) {
+
debugPrint('🚪 User signed out - cleaning up PostDetailScreen');
+
}
+
+
// Remove listener from provider (it's disposed but this is safe)
+
try {
+
_commentsProvider.removeListener(_onProviderChanged);
+
} on Exception {
+
// Provider already disposed - expected
+
}
+
+
// Navigate back to feed
+
if (mounted) {
+
Navigator.of(context).popUntil((route) => route.isFirst);
+
}
+
}
+
}
+
+
/// Initialize provider from cache and restore state
+
void _initializeProvider() {
+
// Get or create provider from cache
+
final cache = context.read<CommentsProviderCache>();
+
_commentsCache = cache;
+
_commentsProvider = cache.acquireProvider(
+
postUri: widget.post.post.uri,
+
postCid: widget.post.post.cid,
+
);
+
+
// Listen for changes to trigger rebuilds
+
_commentsProvider.addListener(_onProviderChanged);
+
+
// Check if we already have cached data
+
if (_commentsProvider.comments.isNotEmpty) {
+
// Already have data - restore scroll position immediately
+
if (kDebugMode) {
+
debugPrint(
+
'📦 Using cached comments (${_commentsProvider.comments.length})',
+
);
+
}
+
_restoreScrollPosition();
+
+
// Background refresh if data is stale
+
if (_commentsProvider.isStale) {
+
if (kDebugMode) {
+
debugPrint('🔄 Data stale, refreshing in background');
+
}
+
_commentsProvider.loadComments(refresh: true);
}
+
} else {
+
// No cached data - load fresh
+
_commentsProvider.loadComments(refresh: true);
+
}
+
+
setState(() {
+
_isInitialized = true;
});
}
@override
void dispose() {
+
// Remove auth listener
+
try {
+
context.read<AuthProvider>().removeListener(_onAuthChanged);
+
} on Exception {
+
// Context may not be valid during dispose
+
}
+
+
// Release provider pin in cache (prevents LRU eviction disposing an active
+
// provider while this screen is in the navigation stack).
+
if (_isInitialized) {
+
try {
+
_commentsCache?.releaseProvider(widget.post.post.uri);
+
} on Exception {
+
// Cache may already be disposed
+
}
+
}
+
+
// Remove provider listener if not already invalidated
+
if (_isInitialized && !_providerInvalidated) {
+
try {
+
_commentsProvider.removeListener(_onProviderChanged);
+
} on Exception {
+
// Provider may already be disposed
+
}
+
}
_scrollController.dispose();
super.dispose();
}
-
/// Load comments for the current post
-
void _loadComments() {
-
context.read<CommentsProvider>().loadComments(
-
postUri: widget.post.post.uri,
-
postCid: widget.post.post.cid,
-
refresh: true,
-
);
+
/// Handle provider changes
+
void _onProviderChanged() {
+
if (mounted) {
+
setState(() {});
+
}
}
-
/// Handle sort changes from dropdown
-
Future<void> _onSortChanged(String newSort) async {
-
final previousSort = _currentSort;
+
/// Restore scroll position from provider
+
void _restoreScrollPosition() {
+
final savedPosition = _commentsProvider.scrollPosition;
+
if (savedPosition <= 0) {
+
return;
+
}
+
+
WidgetsBinding.instance.addPostFrameCallback((_) {
+
if (!mounted || !_scrollController.hasClients) {
+
return;
+
}
-
setState(() {
-
_currentSort = newSort;
+
final maxExtent = _scrollController.position.maxScrollExtent;
+
final targetPosition = savedPosition.clamp(0.0, maxExtent);
+
+
if (targetPosition > 0) {
+
_scrollController.jumpTo(targetPosition);
+
if (kDebugMode) {
+
debugPrint('📍 Restored scroll to $targetPosition (max: $maxExtent)');
+
}
+
}
});
+
}
-
final commentsProvider = context.read<CommentsProvider>();
-
final success = await commentsProvider.setSortOption(newSort);
+
/// Handle sort changes from dropdown
+
Future<void> _onSortChanged(String newSort) async {
+
final success = await _commentsProvider.setSortOption(newSort);
-
// Show error snackbar and revert UI if sort change failed
+
// Show error snackbar if sort change failed
if (!success && mounted) {
-
setState(() {
-
_currentSort = previousSort;
-
});
-
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: const Text('Failed to change sort order. Please try again.'),
···
/// Handle scroll for pagination
void _onScroll() {
+
// Don't interact with disposed provider
+
if (_providerInvalidated) return;
+
+
// Save scroll position to provider on every scroll event
+
if (_scrollController.hasClients) {
+
_commentsProvider.saveScrollPosition(_scrollController.position.pixels);
+
}
+
+
// Load more comments when near bottom
if (_scrollController.position.pixels >=
_scrollController.position.maxScrollExtent - 200) {
-
context.read<CommentsProvider>().loadMoreComments();
+
_commentsProvider.loadMoreComments();
}
}
/// Handle pull-to-refresh
Future<void> _onRefresh() async {
-
final commentsProvider = context.read<CommentsProvider>();
-
await commentsProvider.refreshComments();
+
// Don't interact with disposed provider
+
if (_providerInvalidated) return;
+
+
await _commentsProvider.refreshComments();
}
@override
Widget build(BuildContext context) {
-
return Scaffold(
-
backgroundColor: AppColors.background,
-
body: _buildContent(),
-
bottomNavigationBar: _buildActionBar(),
+
// Show loading until provider is initialized
+
if (!_isInitialized) {
+
return const Scaffold(
+
backgroundColor: AppColors.background,
+
body: FullScreenLoading(),
+
);
+
}
+
+
// If provider was invalidated (sign-out), show loading while navigating away
+
if (_providerInvalidated) {
+
return const Scaffold(
+
backgroundColor: AppColors.background,
+
body: FullScreenLoading(),
+
);
+
}
+
+
// Provide the cached CommentsProvider to descendant widgets
+
return ChangeNotifierProvider.value(
+
value: _commentsProvider,
+
child: Scaffold(
+
backgroundColor: AppColors.background,
+
body: _buildContent(),
+
bottomNavigationBar: _buildActionBar(),
+
),
);
}
···
Navigator.of(context).push(
MaterialPageRoute<void>(
builder:
-
(context) =>
-
ReplyScreen(post: widget.post, onSubmit: _handleCommentSubmit),
+
(context) => ReplyScreen(
+
post: widget.post,
+
onSubmit: _handleCommentSubmit,
+
commentsProvider: _commentsProvider,
+
),
),
);
}
/// Handle comment submission (reply to post)
Future<void> _handleCommentSubmit(String content) async {
-
final commentsProvider = context.read<CommentsProvider>();
final messenger = ScaffoldMessenger.of(context);
try {
-
await commentsProvider.createComment(content: content);
+
await _commentsProvider.createComment(content: content);
if (mounted) {
messenger.showSnackBar(
···
String content,
ThreadViewComment parentComment,
) async {
-
final commentsProvider = context.read<CommentsProvider>();
final messenger = ScaffoldMessenger.of(context);
try {
-
await commentsProvider.createComment(
+
await _commentsProvider.createComment(
content: content,
parentComment: parentComment,
);
···
(context) => ReplyScreen(
comment: comment,
onSubmit: (content) => _handleCommentReply(content, comment),
+
commentsProvider: _commentsProvider,
),
),
);
···
) {
Navigator.of(context).push(
MaterialPageRoute<void>(
-
builder: (context) => FocusedThreadScreen(
-
thread: thread,
-
ancestors: ancestors,
-
onReply: _handleCommentReply,
-
),
+
builder:
+
(context) => FocusedThreadScreen(
+
thread: thread,
+
ancestors: ancestors,
+
onReply: _handleCommentReply,
+
commentsProvider: _commentsProvider,
+
),
),
);
}
···
SliverSafeArea(
top: false,
sliver: SliverList(
-
delegate: SliverChildBuilderDelegate(
-
(context, index) {
-
// Post card (index 0)
-
if (index == 0) {
-
return Column(
-
children: [
-
// Reuse PostCard (hide comment button in
-
// detail view)
-
// Use ValueListenableBuilder to only rebuild
-
// when time changes
-
_PostHeader(
-
post: widget.post,
-
currentTimeNotifier:
-
commentsProvider.currentTimeNotifier,
-
),
+
delegate: SliverChildBuilderDelegate(
+
(context, index) {
+
// Post card (index 0)
+
if (index == 0) {
+
return Column(
+
children: [
+
// Reuse PostCard (hide comment button in
+
// detail view)
+
// Use ValueListenableBuilder to only rebuild
+
// when time changes
+
_PostHeader(
+
post: widget.post,
+
currentTimeNotifier:
+
commentsProvider.currentTimeNotifier,
+
),
+
+
// Visual divider before comments section
+
Container(
+
margin: const EdgeInsets.symmetric(
+
vertical: 16,
+
),
+
height: 1,
+
color: AppColors.border,
+
),
-
// Visual divider before comments section
-
Container(
-
margin: const EdgeInsets.symmetric(vertical: 16),
-
height: 1,
-
color: AppColors.border,
-
),
+
// Comments header with sort dropdown
+
CommentsHeader(
+
key: _commentsHeaderKey,
+
commentCount: comments.length,
+
currentSort: commentsProvider.sort,
+
onSortChanged: _onSortChanged,
+
),
+
],
+
);
+
}
-
// Comments header with sort dropdown
-
CommentsHeader(
-
key: _commentsHeaderKey,
-
commentCount: comments.length,
-
currentSort: _currentSort,
-
onSortChanged: _onSortChanged,
-
),
-
],
-
);
-
}
+
// Loading indicator or error at the end
+
if (index == comments.length + 1) {
+
if (isLoadingMore) {
+
return const InlineLoading();
+
}
+
if (error != null) {
+
return InlineError(
+
message: ErrorMessages.getUserFriendly(error),
+
onRetry: () {
+
commentsProvider
+
..clearError()
+
..loadMoreComments();
+
},
+
);
+
}
+
}
-
// Loading indicator or error at the end
-
if (index == comments.length + 1) {
-
if (isLoadingMore) {
-
return const InlineLoading();
-
}
-
if (error != null) {
-
return InlineError(
-
message: ErrorMessages.getUserFriendly(error),
-
onRetry: () {
-
commentsProvider
-
..clearError()
-
..loadMoreComments();
-
},
+
// Comment item - use existing CommentThread widget
+
final comment = comments[index - 1];
+
return _CommentItem(
+
comment: comment,
+
currentTimeNotifier:
+
commentsProvider.currentTimeNotifier,
+
onCommentTap: _openReplyToComment,
+
collapsedComments:
+
commentsProvider.collapsedComments,
+
onCollapseToggle: commentsProvider.toggleCollapsed,
+
onContinueThread: _onContinueThread,
);
-
}
-
}
-
-
// Comment item - use existing CommentThread widget
-
final comment = comments[index - 1];
-
return _CommentItem(
-
comment: comment,
-
currentTimeNotifier:
-
commentsProvider.currentTimeNotifier,
-
onCommentTap: _openReplyToComment,
-
collapsedComments: commentsProvider.collapsedComments,
-
onCollapseToggle: commentsProvider.toggleCollapsed,
-
onContinueThread: _onContinueThread,
-
);
-
},
-
childCount:
-
1 +
-
comments.length +
-
(isLoadingMore || error != null ? 1 : 0),
+
},
+
childCount:
+
1 +
+
comments.length +
+
(isLoadingMore || error != null ? 1 : 0),
+
),
+
),
),
-
),
+
],
),
-
],
-
),
-
),
+
),
// Prevents content showing through transparent status bar
const StatusBarOverlay(),
],
···
final Set<String> collapsedComments;
final void Function(String uri)? onCollapseToggle;
final void Function(ThreadViewComment, List<ThreadViewComment>)?
-
onContinueThread;
+
onContinueThread;
@override
Widget build(BuildContext context) {