+16
-19
lib/main.dart
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
);