Compare changes

Choose any two refs to compare.

Changed files
+80 -1105
lib
test
+21 -5
lib/screens/compose/reply_screen.dart
···
import 'dart:async';
import 'dart:math' as math;
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
···
bool _authInvalidated = false;
double _lastKeyboardHeight = 0;
Timer? _bannerDismissTimer;
@override
void initState() {
···
});
}
void _setupAuthListener() {
try {
context.read<AuthProvider>().addListener(_onAuthChanged);
···
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
if (_lastKeyboardHeight > 0 && keyboardHeight == 0) {
···
with WidgetsBindingObserver {
final ValueNotifier<double> _keyboardMarginNotifier = ValueNotifier(0);
final ValueNotifier<double> _safeAreaBottomNotifier = ValueNotifier(0);
@override
void initState() {
···
@override
void didChangeDependencies() {
super.didChangeDependencies();
_updateMargins();
}
···
@override
void didChangeMetrics() {
-
_updateMargins();
}
void _updateMargins() {
-
if (!mounted) {
return;
}
-
final view = View.of(context);
final devicePixelRatio = view.devicePixelRatio;
final keyboardInset = view.viewInsets.bottom / devicePixelRatio;
final viewPaddingBottom = view.viewPadding.bottom / devicePixelRatio;
···
import 'dart:async';
import 'dart:math' as math;
+
import 'dart:ui' show FlutterView;
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
···
bool _authInvalidated = false;
double _lastKeyboardHeight = 0;
Timer? _bannerDismissTimer;
+
FlutterView? _cachedView;
@override
void initState() {
···
});
}
+
@override
+
void didChangeDependencies() {
+
super.didChangeDependencies();
+
// Cache the view reference so we can safely use it in didChangeMetrics
+
// even when the widget is being deactivated
+
_cachedView = View.of(context);
+
}
+
void _setupAuthListener() {
try {
context.read<AuthProvider>().addListener(_onAuthChanged);
···
super.didChangeMetrics();
// Guard against being called after widget is deactivated
// (can happen during keyboard animation while navigating away)
+
if (!mounted || _cachedView == null) return;
+
final keyboardHeight = _cachedView!.viewInsets.bottom;
// Detect keyboard closing and unfocus text field
if (_lastKeyboardHeight > 0 && keyboardHeight == 0) {
···
with WidgetsBindingObserver {
final ValueNotifier<double> _keyboardMarginNotifier = ValueNotifier(0);
final ValueNotifier<double> _safeAreaBottomNotifier = ValueNotifier(0);
+
FlutterView? _cachedView;
@override
void initState() {
···
@override
void didChangeDependencies() {
super.didChangeDependencies();
+
// Cache view reference for safe access in didChangeMetrics
+
_cachedView = View.of(context);
_updateMargins();
}
···
@override
void didChangeMetrics() {
+
// Schedule update after frame to ensure context is valid
+
WidgetsBinding.instance.addPostFrameCallback((_) {
+
_updateMargins();
+
});
}
void _updateMargins() {
+
if (!mounted || _cachedView == null) {
return;
}
+
final view = _cachedView!;
final devicePixelRatio = view.devicePixelRatio;
final keyboardInset = view.viewInsets.bottom / devicePixelRatio;
final viewPaddingBottom = view.viewPadding.bottom / devicePixelRatio;
+51 -44
lib/screens/home/post_detail_screen.dart
···
/// - Loading, empty, and error states
/// - Automatic comment loading on screen init
class PostDetailScreen extends StatefulWidget {
-
const PostDetailScreen({required this.post, this.isOptimistic = false, super.key});
/// Post to display (passed via route extras)
final FeedViewPost post;
···
}
class _PostDetailScreenState extends State<PostDetailScreen> {
-
final ScrollController _scrollController = ScrollController();
final GlobalKey _commentsHeaderKey = GlobalKey();
// Cached provider from CommentsProviderCache
···
@override
void initState() {
super.initState();
-
_scrollController.addListener(_onScroll);
-
// Initialize provider after frame is built
-
WidgetsBinding.instance.addPostFrameCallback((_) {
-
if (mounted) {
-
_initializeProvider();
-
_setupAuthListener();
-
}
-
});
}
/// Listen for auth state changes to handle sign-out
···
// 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) {
···
}
}
-
/// Initialize provider from cache and restore state
-
void _initializeProvider() {
// Get or create provider from cache
final cache = context.read<CommentsProviderCache>();
_commentsCache = cache;
···
postCid: widget.post.post.cid,
);
// Listen for changes to trigger rebuilds
_commentsProvider.addListener(_onProviderChanged);
// Skip loading for optimistic posts (just created, not yet indexed)
if (widget.isOptimistic) {
if (kDebugMode) {
···
}
// Don't load comments - there won't be any yet
} else 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');
···
// No cached data - load fresh
_commentsProvider.loadComments(refresh: true);
}
-
-
setState(() {
-
_isInitialized = true;
-
});
}
@override
···
}
}
-
/// Restore scroll position from provider
-
void _restoreScrollPosition() {
-
final savedPosition = _commentsProvider.scrollPosition;
-
if (savedPosition <= 0) {
-
return;
-
}
-
-
WidgetsBinding.instance.addPostFrameCallback((_) {
-
if (!mounted || !_scrollController.hasClients) {
-
return;
-
}
-
-
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)');
-
}
-
}
-
});
-
}
-
/// Handle sort changes from dropdown
Future<void> _onSortChanged(String newSort) async {
final success = await _commentsProvider.setSortOption(newSort);
···
/// - Loading, empty, and error states
/// - Automatic comment loading on screen init
class PostDetailScreen extends StatefulWidget {
+
const PostDetailScreen({
+
required this.post,
+
this.isOptimistic = false,
+
super.key,
+
});
/// Post to display (passed via route extras)
final FeedViewPost post;
···
}
class _PostDetailScreenState extends State<PostDetailScreen> {
+
// ScrollController created lazily with cached scroll position for instant restoration
+
late ScrollController _scrollController;
final GlobalKey _commentsHeaderKey = GlobalKey();
// Cached provider from CommentsProviderCache
···
@override
void initState() {
super.initState();
+
// ScrollController and provider initialization moved to didChangeDependencies
+
// where we have access to context for synchronous provider acquisition
+
}
+
@override
+
void didChangeDependencies() {
+
super.didChangeDependencies();
+
// Initialize provider synchronously on first call (has context access)
+
// This ensures cached data is available for the first build, avoiding
+
// the flash from loading state โ†’ content โ†’ scroll position jump
+
if (!_isInitialized) {
+
_initializeProviderSync();
+
}
}
/// Listen for auth state changes to handle sign-out
···
// 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) {
···
}
}
+
/// Initialize provider synchronously from cache
+
///
+
/// Called from didChangeDependencies to ensure cached data is available
+
/// for the first build. Creates ScrollController with initialScrollOffset
+
/// set to cached position for instant scroll restoration without flicker.
+
void _initializeProviderSync() {
// Get or create provider from cache
final cache = context.read<CommentsProviderCache>();
_commentsCache = cache;
···
postCid: widget.post.post.cid,
);
+
// Create scroll controller with cached position for instant restoration
+
// This avoids the flash: loading โ†’ content at top โ†’ jump to cached position
+
final cachedScrollPosition = _commentsProvider.scrollPosition;
+
_scrollController = ScrollController(
+
initialScrollOffset: cachedScrollPosition,
+
);
+
_scrollController.addListener(_onScroll);
+
+
if (kDebugMode && cachedScrollPosition > 0) {
+
debugPrint(
+
'๐Ÿ“ Created ScrollController with initial offset: $cachedScrollPosition',
+
);
+
}
+
// Listen for changes to trigger rebuilds
_commentsProvider.addListener(_onProviderChanged);
+
// Setup auth listener
+
_setupAuthListener();
+
+
// Mark as initialized before triggering any loads
+
// This ensures the first build shows content (not loading) when cached
+
_isInitialized = true;
+
// Skip loading for optimistic posts (just created, not yet indexed)
if (widget.isOptimistic) {
if (kDebugMode) {
···
}
// Don't load comments - there won't be any yet
} else if (_commentsProvider.comments.isNotEmpty) {
+
// Already have cached data - it will render immediately
if (kDebugMode) {
debugPrint(
'๐Ÿ“ฆ Using cached comments (${_commentsProvider.comments.length})',
);
}
+
// Background refresh if data is stale (won't cause flicker)
if (_commentsProvider.isStale) {
if (kDebugMode) {
debugPrint('๐Ÿ”„ Data stale, refreshing in background');
···
// No cached data - load fresh
_commentsProvider.loadComments(refresh: true);
}
}
@override
···
}
}
/// Handle sort changes from dropdown
Future<void> _onSortChanged(String newSort) async {
final success = await _commentsProvider.setSortOption(newSort);
-335
lib/providers/feed_provider.dart
···
-
import 'dart:async';
-
-
import 'package:flutter/foundation.dart';
-
import '../models/post.dart';
-
import '../services/coves_api_service.dart';
-
import 'auth_provider.dart';
-
import 'vote_provider.dart';
-
-
/// Feed types available in the app
-
enum FeedType {
-
/// All posts across the network
-
discover,
-
-
/// Posts from subscribed communities (authenticated only)
-
forYou,
-
}
-
-
/// Feed Provider
-
///
-
/// Manages feed state and fetching logic.
-
/// Supports both authenticated timeline and public discover feed.
-
///
-
/// IMPORTANT: Accepts AuthProvider reference to fetch fresh access
-
/// tokens before each authenticated request (critical for atProto OAuth
-
/// token rotation).
-
class FeedProvider with ChangeNotifier {
-
FeedProvider(
-
this._authProvider, {
-
CovesApiService? apiService,
-
VoteProvider? voteProvider,
-
}) : _voteProvider = voteProvider {
-
// Use injected service (for testing) or create new one (for production)
-
// Pass token getter, refresh handler, and sign out handler to API service
-
// for automatic fresh token retrieval and automatic token refresh on 401
-
_apiService =
-
apiService ??
-
CovesApiService(
-
tokenGetter: _authProvider.getAccessToken,
-
tokenRefresher: _authProvider.refreshToken,
-
signOutHandler: _authProvider.signOut,
-
);
-
-
// Track initial auth state
-
_wasAuthenticated = _authProvider.isAuthenticated;
-
-
// [P0 FIX] Listen to auth state changes and clear feed on sign-out
-
// This prevents privacy bug where logged-out users see their private
-
// timeline until they manually refresh.
-
_authProvider.addListener(_onAuthChanged);
-
}
-
-
/// Handle authentication state changes
-
///
-
/// Only clears and reloads feed when transitioning from authenticated
-
/// to unauthenticated (actual sign-out), not when staying unauthenticated
-
/// (e.g., failed sign-in attempt). This prevents unnecessary API calls.
-
void _onAuthChanged() {
-
final isAuthenticated = _authProvider.isAuthenticated;
-
-
// Only reload if transitioning from authenticated โ†’ unauthenticated
-
if (_wasAuthenticated && !isAuthenticated && _posts.isNotEmpty) {
-
if (kDebugMode) {
-
debugPrint('๐Ÿ”’ User signed out - clearing feed');
-
}
-
// Reset feed type to Discover since For You requires auth
-
_feedType = FeedType.discover;
-
reset();
-
// Automatically load the public discover feed
-
loadFeed(refresh: true);
-
}
-
-
// Update tracked state
-
_wasAuthenticated = isAuthenticated;
-
}
-
-
final AuthProvider _authProvider;
-
late final CovesApiService _apiService;
-
final VoteProvider? _voteProvider;
-
-
// Track previous auth state to detect transitions
-
bool _wasAuthenticated = false;
-
-
// Feed state
-
List<FeedViewPost> _posts = [];
-
bool _isLoading = false;
-
bool _isLoadingMore = false;
-
String? _error;
-
String? _cursor;
-
bool _hasMore = true;
-
-
// Feed configuration
-
String _sort = 'hot';
-
String? _timeframe;
-
FeedType _feedType = FeedType.discover;
-
-
// Time update mechanism for periodic UI refreshes
-
Timer? _timeUpdateTimer;
-
DateTime? _currentTime;
-
-
// Getters
-
List<FeedViewPost> get posts => _posts;
-
bool get isLoading => _isLoading;
-
bool get isLoadingMore => _isLoadingMore;
-
String? get error => _error;
-
bool get hasMore => _hasMore;
-
String get sort => _sort;
-
String? get timeframe => _timeframe;
-
DateTime? get currentTime => _currentTime;
-
FeedType get feedType => _feedType;
-
-
/// Check if For You feed is available (requires authentication)
-
bool get isForYouAvailable => _authProvider.isAuthenticated;
-
-
/// Start periodic time updates for "time ago" strings
-
///
-
/// Updates currentTime every minute to trigger UI rebuilds for
-
/// post timestamps. This ensures "5m ago" updates to "6m ago" without
-
/// requiring user interaction.
-
void startTimeUpdates() {
-
// Cancel existing timer if any
-
_timeUpdateTimer?.cancel();
-
-
// Update current time immediately
-
_currentTime = DateTime.now();
-
notifyListeners();
-
-
// Set up periodic updates (every minute)
-
_timeUpdateTimer = Timer.periodic(const Duration(minutes: 1), (_) {
-
_currentTime = DateTime.now();
-
notifyListeners();
-
});
-
-
if (kDebugMode) {
-
debugPrint('โฐ Started periodic time updates for feed timestamps');
-
}
-
}
-
-
/// Stop periodic time updates
-
void stopTimeUpdates() {
-
_timeUpdateTimer?.cancel();
-
_timeUpdateTimer = null;
-
_currentTime = null;
-
-
if (kDebugMode) {
-
debugPrint('โฐ Stopped periodic time updates');
-
}
-
}
-
-
/// Load feed based on current feed type
-
///
-
/// This method encapsulates the business logic of deciding which feed
-
/// to fetch based on the selected feed type.
-
Future<void> loadFeed({bool refresh = false}) async {
-
// For You requires authentication - fall back to Discover if not
-
if (_feedType == FeedType.forYou && _authProvider.isAuthenticated) {
-
await fetchTimeline(refresh: refresh);
-
} else {
-
await fetchDiscover(refresh: refresh);
-
}
-
-
// Start time updates when feed is loaded
-
if (_posts.isNotEmpty && _timeUpdateTimer == null) {
-
startTimeUpdates();
-
}
-
}
-
-
/// Switch feed type and reload
-
Future<void> setFeedType(FeedType type) async {
-
if (_feedType == type) {
-
return;
-
}
-
-
// For You requires authentication
-
if (type == FeedType.forYou && !_authProvider.isAuthenticated) {
-
return;
-
}
-
-
_feedType = type;
-
// Reset pagination state but keep posts visible until new feed loads
-
_cursor = null;
-
_hasMore = true;
-
_error = null;
-
notifyListeners();
-
-
// Load new feed - old posts stay visible until new ones arrive
-
await loadFeed(refresh: true);
-
}
-
-
/// Common feed fetching logic (DRY principle - eliminates code
-
/// duplication)
-
Future<void> _fetchFeed({
-
required bool refresh,
-
required Future<TimelineResponse> Function() fetcher,
-
required String feedName,
-
}) async {
-
if (_isLoading || _isLoadingMore) {
-
return;
-
}
-
-
try {
-
if (refresh) {
-
_isLoading = true;
-
// DON'T clear _posts, _cursor, or _hasMore yet
-
// Keep existing data visible until refresh succeeds
-
// This prevents transient failures from wiping the user's feed
-
// and pagination state
-
_error = null;
-
} else {
-
_isLoadingMore = true;
-
}
-
notifyListeners();
-
-
final response = await fetcher();
-
-
// Only update state after successful fetch
-
if (refresh) {
-
_posts = response.feed;
-
} else {
-
// Create new list instance to trigger context.select rebuilds
-
// Using spread operator instead of addAll to ensure reference changes
-
_posts = [..._posts, ...response.feed];
-
}
-
-
_cursor = response.cursor;
-
_hasMore = response.cursor != null;
-
_error = null;
-
-
if (kDebugMode) {
-
debugPrint('โœ… $feedName loaded: ${_posts.length} posts total');
-
}
-
-
// Initialize vote state from viewer data in feed response
-
// IMPORTANT: Call setInitialVoteState for ALL feed items, even when
-
// viewer.vote is null. This ensures that if a user removed their vote
-
// on another device, the local state is cleared on refresh.
-
if (_authProvider.isAuthenticated && _voteProvider != null) {
-
for (final feedItem in response.feed) {
-
final viewer = feedItem.post.viewer;
-
_voteProvider.setInitialVoteState(
-
postUri: feedItem.post.uri,
-
voteDirection: viewer?.vote,
-
voteUri: viewer?.voteUri,
-
);
-
}
-
}
-
} on Exception catch (e) {
-
_error = e.toString();
-
if (kDebugMode) {
-
debugPrint('โŒ Failed to fetch $feedName: $e');
-
}
-
} finally {
-
_isLoading = false;
-
_isLoadingMore = false;
-
notifyListeners();
-
}
-
}
-
-
/// Fetch timeline feed (authenticated)
-
///
-
/// Fetches the user's personalized timeline.
-
/// Authentication is handled automatically via tokenGetter.
-
Future<void> fetchTimeline({bool refresh = false}) => _fetchFeed(
-
refresh: refresh,
-
fetcher:
-
() => _apiService.getTimeline(
-
sort: _sort,
-
timeframe: _timeframe,
-
cursor: refresh ? null : _cursor,
-
),
-
feedName: 'Timeline',
-
);
-
-
/// Fetch discover feed (public)
-
///
-
/// Fetches the public discover feed.
-
/// Does not require authentication.
-
Future<void> fetchDiscover({bool refresh = false}) => _fetchFeed(
-
refresh: refresh,
-
fetcher:
-
() => _apiService.getDiscover(
-
sort: _sort,
-
timeframe: _timeframe,
-
cursor: refresh ? null : _cursor,
-
),
-
feedName: 'Discover',
-
);
-
-
/// Load more posts (pagination)
-
Future<void> loadMore() async {
-
if (!_hasMore || _isLoadingMore) {
-
return;
-
}
-
await loadFeed();
-
}
-
-
/// Change sort order
-
void setSort(String newSort, {String? newTimeframe}) {
-
_sort = newSort;
-
_timeframe = newTimeframe;
-
notifyListeners();
-
}
-
-
/// Retry loading after error
-
Future<void> retry() async {
-
_error = null;
-
await loadFeed(refresh: true);
-
}
-
-
/// Clear error
-
void clearError() {
-
_error = null;
-
notifyListeners();
-
}
-
-
/// Reset feed state
-
void reset() {
-
_posts = [];
-
_cursor = null;
-
_hasMore = true;
-
_error = null;
-
_isLoading = false;
-
_isLoadingMore = false;
-
notifyListeners();
-
}
-
-
@override
-
void dispose() {
-
// Stop time updates and cancel timer
-
stopTimeUpdates();
-
// Remove auth listener to prevent memory leaks
-
_authProvider.removeListener(_onAuthChanged);
-
_apiService.dispose();
-
super.dispose();
-
}
-
}
···
-715
test/providers/feed_provider_test.dart
···
-
import 'package:coves_flutter/models/post.dart';
-
import 'package:coves_flutter/providers/auth_provider.dart';
-
import 'package:coves_flutter/providers/feed_provider.dart';
-
import 'package:coves_flutter/providers/vote_provider.dart';
-
import 'package:coves_flutter/services/coves_api_service.dart';
-
import 'package:flutter_test/flutter_test.dart';
-
import 'package:mockito/annotations.dart';
-
import 'package:mockito/mockito.dart';
-
-
import 'feed_provider_test.mocks.dart';
-
-
// Generate mocks
-
@GenerateMocks([AuthProvider, CovesApiService, VoteProvider])
-
void main() {
-
group('FeedProvider', () {
-
late FeedProvider feedProvider;
-
late MockAuthProvider mockAuthProvider;
-
late MockCovesApiService mockApiService;
-
-
setUp(() {
-
mockAuthProvider = MockAuthProvider();
-
mockApiService = MockCovesApiService();
-
-
// Mock default auth state
-
when(mockAuthProvider.isAuthenticated).thenReturn(false);
-
-
// Mock the token getter
-
when(
-
mockAuthProvider.getAccessToken(),
-
).thenAnswer((_) async => 'test-token');
-
-
// Create feed provider with injected mock service
-
feedProvider = FeedProvider(mockAuthProvider, apiService: mockApiService);
-
});
-
-
tearDown(() {
-
feedProvider.dispose();
-
});
-
-
group('loadFeed', () {
-
test('should load discover feed when authenticated by default', () async {
-
when(mockAuthProvider.isAuthenticated).thenReturn(true);
-
-
final mockResponse = TimelineResponse(
-
feed: [_createMockPost()],
-
cursor: 'next-cursor',
-
);
-
-
when(
-
mockApiService.getDiscover(
-
sort: anyNamed('sort'),
-
timeframe: anyNamed('timeframe'),
-
limit: anyNamed('limit'),
-
cursor: anyNamed('cursor'),
-
),
-
).thenAnswer((_) async => mockResponse);
-
-
await feedProvider.loadFeed(refresh: true);
-
-
expect(feedProvider.posts.length, 1);
-
expect(feedProvider.error, null);
-
expect(feedProvider.isLoading, false);
-
});
-
-
test('should load timeline when feed type is For You', () async {
-
when(mockAuthProvider.isAuthenticated).thenReturn(true);
-
-
final mockResponse = TimelineResponse(
-
feed: [_createMockPost()],
-
cursor: 'next-cursor',
-
);
-
-
when(
-
mockApiService.getTimeline(
-
sort: anyNamed('sort'),
-
timeframe: anyNamed('timeframe'),
-
limit: anyNamed('limit'),
-
cursor: anyNamed('cursor'),
-
),
-
).thenAnswer((_) async => mockResponse);
-
-
await feedProvider.setFeedType(FeedType.forYou);
-
-
expect(feedProvider.posts.length, 1);
-
expect(feedProvider.error, null);
-
expect(feedProvider.isLoading, false);
-
});
-
-
test('should load discover feed when not authenticated', () async {
-
when(mockAuthProvider.isAuthenticated).thenReturn(false);
-
-
final mockResponse = TimelineResponse(
-
feed: [_createMockPost()],
-
cursor: 'next-cursor',
-
);
-
-
when(
-
mockApiService.getDiscover(
-
sort: anyNamed('sort'),
-
timeframe: anyNamed('timeframe'),
-
limit: anyNamed('limit'),
-
cursor: anyNamed('cursor'),
-
),
-
).thenAnswer((_) async => mockResponse);
-
-
await feedProvider.loadFeed(refresh: true);
-
-
expect(feedProvider.posts.length, 1);
-
expect(feedProvider.error, null);
-
});
-
});
-
-
group('fetchTimeline', () {
-
test('should fetch timeline successfully', () async {
-
final mockResponse = TimelineResponse(
-
feed: [_createMockPost(), _createMockPost()],
-
cursor: 'next-cursor',
-
);
-
-
when(
-
mockApiService.getTimeline(
-
sort: anyNamed('sort'),
-
timeframe: anyNamed('timeframe'),
-
limit: anyNamed('limit'),
-
cursor: anyNamed('cursor'),
-
),
-
).thenAnswer((_) async => mockResponse);
-
-
await feedProvider.fetchTimeline(refresh: true);
-
-
expect(feedProvider.posts.length, 2);
-
expect(feedProvider.hasMore, true);
-
expect(feedProvider.error, null);
-
});
-
-
test('should handle network errors', () async {
-
when(
-
mockApiService.getTimeline(
-
sort: anyNamed('sort'),
-
timeframe: anyNamed('timeframe'),
-
limit: anyNamed('limit'),
-
cursor: anyNamed('cursor'),
-
),
-
).thenThrow(Exception('Network error'));
-
-
await feedProvider.fetchTimeline(refresh: true);
-
-
expect(feedProvider.error, isNotNull);
-
expect(feedProvider.isLoading, false);
-
});
-
-
test('should append posts when not refreshing', () async {
-
// First load
-
final firstResponse = TimelineResponse(
-
feed: [_createMockPost()],
-
cursor: 'cursor-1',
-
);
-
-
when(
-
mockApiService.getTimeline(
-
sort: anyNamed('sort'),
-
timeframe: anyNamed('timeframe'),
-
limit: anyNamed('limit'),
-
cursor: anyNamed('cursor'),
-
),
-
).thenAnswer((_) async => firstResponse);
-
-
await feedProvider.fetchTimeline(refresh: true);
-
expect(feedProvider.posts.length, 1);
-
-
// Second load (pagination)
-
final secondResponse = TimelineResponse(
-
feed: [_createMockPost()],
-
cursor: 'cursor-2',
-
);
-
-
when(
-
mockApiService.getTimeline(
-
sort: anyNamed('sort'),
-
timeframe: anyNamed('timeframe'),
-
limit: anyNamed('limit'),
-
cursor: 'cursor-1',
-
),
-
).thenAnswer((_) async => secondResponse);
-
-
await feedProvider.fetchTimeline();
-
expect(feedProvider.posts.length, 2);
-
});
-
-
test('should replace posts when refreshing', () async {
-
// First load
-
final firstResponse = TimelineResponse(
-
feed: [_createMockPost()],
-
cursor: 'cursor-1',
-
);
-
-
when(
-
mockApiService.getTimeline(
-
sort: anyNamed('sort'),
-
timeframe: anyNamed('timeframe'),
-
limit: anyNamed('limit'),
-
cursor: anyNamed('cursor'),
-
),
-
).thenAnswer((_) async => firstResponse);
-
-
await feedProvider.fetchTimeline(refresh: true);
-
expect(feedProvider.posts.length, 1);
-
-
// Refresh
-
final refreshResponse = TimelineResponse(
-
feed: [_createMockPost(), _createMockPost()],
-
cursor: 'cursor-2',
-
);
-
-
when(
-
mockApiService.getTimeline(
-
sort: anyNamed('sort'),
-
timeframe: anyNamed('timeframe'),
-
limit: anyNamed('limit'),
-
),
-
).thenAnswer((_) async => refreshResponse);
-
-
await feedProvider.fetchTimeline(refresh: true);
-
expect(feedProvider.posts.length, 2);
-
});
-
-
test('should set hasMore to false when no cursor', () async {
-
final response = TimelineResponse(feed: [_createMockPost()]);
-
-
when(
-
mockApiService.getTimeline(
-
sort: anyNamed('sort'),
-
timeframe: anyNamed('timeframe'),
-
limit: anyNamed('limit'),
-
cursor: anyNamed('cursor'),
-
),
-
).thenAnswer((_) async => response);
-
-
await feedProvider.fetchTimeline(refresh: true);
-
-
expect(feedProvider.hasMore, false);
-
});
-
});
-
-
group('fetchDiscover', () {
-
test('should fetch discover feed successfully', () async {
-
final mockResponse = TimelineResponse(
-
feed: [_createMockPost()],
-
cursor: 'next-cursor',
-
);
-
-
when(
-
mockApiService.getDiscover(
-
sort: anyNamed('sort'),
-
timeframe: anyNamed('timeframe'),
-
limit: anyNamed('limit'),
-
cursor: anyNamed('cursor'),
-
),
-
).thenAnswer((_) async => mockResponse);
-
-
await feedProvider.fetchDiscover(refresh: true);
-
-
expect(feedProvider.posts.length, 1);
-
expect(feedProvider.error, null);
-
});
-
-
test('should handle empty feed', () async {
-
final emptyResponse = TimelineResponse(feed: []);
-
-
when(
-
mockApiService.getDiscover(
-
sort: anyNamed('sort'),
-
timeframe: anyNamed('timeframe'),
-
limit: anyNamed('limit'),
-
cursor: anyNamed('cursor'),
-
),
-
).thenAnswer((_) async => emptyResponse);
-
-
await feedProvider.fetchDiscover(refresh: true);
-
-
expect(feedProvider.posts.isEmpty, true);
-
expect(feedProvider.hasMore, false);
-
});
-
});
-
-
group('loadMore', () {
-
test('should load more posts', () async {
-
when(mockAuthProvider.isAuthenticated).thenReturn(true);
-
-
// Initial load
-
final firstResponse = TimelineResponse(
-
feed: [_createMockPost()],
-
cursor: 'cursor-1',
-
);
-
-
when(
-
mockApiService.getTimeline(
-
sort: anyNamed('sort'),
-
timeframe: anyNamed('timeframe'),
-
limit: anyNamed('limit'),
-
cursor: anyNamed('cursor'),
-
),
-
).thenAnswer((_) async => firstResponse);
-
-
await feedProvider.setFeedType(FeedType.forYou);
-
-
// Load more
-
final secondResponse = TimelineResponse(
-
feed: [_createMockPost()],
-
cursor: 'cursor-2',
-
);
-
-
when(
-
mockApiService.getTimeline(
-
sort: anyNamed('sort'),
-
timeframe: anyNamed('timeframe'),
-
limit: anyNamed('limit'),
-
cursor: 'cursor-1',
-
),
-
).thenAnswer((_) async => secondResponse);
-
-
await feedProvider.loadMore();
-
-
expect(feedProvider.posts.length, 2);
-
});
-
-
test('should not load more if already loading', () async {
-
when(mockAuthProvider.isAuthenticated).thenReturn(true);
-
-
final response = TimelineResponse(
-
feed: [_createMockPost()],
-
cursor: 'cursor-1',
-
);
-
-
when(
-
mockApiService.getTimeline(
-
sort: anyNamed('sort'),
-
timeframe: anyNamed('timeframe'),
-
limit: anyNamed('limit'),
-
cursor: anyNamed('cursor'),
-
),
-
).thenAnswer((_) async => response);
-
-
await feedProvider.setFeedType(FeedType.forYou);
-
await feedProvider.loadMore();
-
-
// Should not make additional calls while loading
-
});
-
-
test('should not load more if hasMore is false', () async {
-
final response = TimelineResponse(feed: [_createMockPost()]);
-
-
when(
-
mockApiService.getTimeline(
-
sort: anyNamed('sort'),
-
timeframe: anyNamed('timeframe'),
-
limit: anyNamed('limit'),
-
cursor: anyNamed('cursor'),
-
),
-
).thenAnswer((_) async => response);
-
-
await feedProvider.fetchTimeline(refresh: true);
-
expect(feedProvider.hasMore, false);
-
-
await feedProvider.loadMore();
-
// Should not attempt to load more
-
});
-
});
-
-
group('retry', () {
-
test('should retry after error', () async {
-
when(mockAuthProvider.isAuthenticated).thenReturn(true);
-
-
// Simulate error
-
when(
-
mockApiService.getTimeline(
-
sort: anyNamed('sort'),
-
timeframe: anyNamed('timeframe'),
-
limit: anyNamed('limit'),
-
cursor: anyNamed('cursor'),
-
),
-
).thenThrow(Exception('Network error'));
-
-
await feedProvider.setFeedType(FeedType.forYou);
-
expect(feedProvider.error, isNotNull);
-
-
// Retry
-
final successResponse = TimelineResponse(
-
feed: [_createMockPost()],
-
cursor: 'cursor',
-
);
-
-
when(
-
mockApiService.getTimeline(
-
sort: anyNamed('sort'),
-
timeframe: anyNamed('timeframe'),
-
limit: anyNamed('limit'),
-
cursor: anyNamed('cursor'),
-
),
-
).thenAnswer((_) async => successResponse);
-
-
await feedProvider.retry();
-
-
expect(feedProvider.error, null);
-
expect(feedProvider.posts.length, 1);
-
});
-
});
-
-
group('State Management', () {
-
test('should notify listeners on state change', () async {
-
var notificationCount = 0;
-
feedProvider.addListener(() {
-
notificationCount++;
-
});
-
-
final mockResponse = TimelineResponse(
-
feed: [_createMockPost()],
-
cursor: 'cursor',
-
);
-
-
when(
-
mockApiService.getTimeline(
-
sort: anyNamed('sort'),
-
timeframe: anyNamed('timeframe'),
-
limit: anyNamed('limit'),
-
cursor: anyNamed('cursor'),
-
),
-
).thenAnswer((_) async => mockResponse);
-
-
await feedProvider.fetchTimeline(refresh: true);
-
-
expect(notificationCount, greaterThan(0));
-
});
-
-
test('should manage loading states correctly', () async {
-
final mockResponse = TimelineResponse(
-
feed: [_createMockPost()],
-
cursor: 'cursor',
-
);
-
-
when(
-
mockApiService.getTimeline(
-
sort: anyNamed('sort'),
-
timeframe: anyNamed('timeframe'),
-
limit: anyNamed('limit'),
-
cursor: anyNamed('cursor'),
-
),
-
).thenAnswer((_) async {
-
await Future.delayed(const Duration(milliseconds: 100));
-
return mockResponse;
-
});
-
-
final loadFuture = feedProvider.fetchTimeline(refresh: true);
-
-
// Should be loading
-
expect(feedProvider.isLoading, true);
-
-
await loadFuture;
-
-
// Should not be loading anymore
-
expect(feedProvider.isLoading, false);
-
});
-
});
-
-
group('Vote state initialization from viewer data', () {
-
late MockVoteProvider mockVoteProvider;
-
late FeedProvider feedProviderWithVotes;
-
-
setUp(() {
-
mockVoteProvider = MockVoteProvider();
-
feedProviderWithVotes = FeedProvider(
-
mockAuthProvider,
-
apiService: mockApiService,
-
voteProvider: mockVoteProvider,
-
);
-
});
-
-
tearDown(() {
-
feedProviderWithVotes.dispose();
-
});
-
-
test('should initialize vote state when viewer.vote is "up"', () async {
-
when(mockAuthProvider.isAuthenticated).thenReturn(true);
-
-
final mockResponse = TimelineResponse(
-
feed: [
-
_createMockPostWithViewer(
-
uri: 'at://did:plc:test/social.coves.post.record/1',
-
vote: 'up',
-
voteUri: 'at://did:plc:test/social.coves.feed.vote/vote1',
-
),
-
],
-
cursor: 'cursor',
-
);
-
-
when(
-
mockApiService.getTimeline(
-
sort: anyNamed('sort'),
-
timeframe: anyNamed('timeframe'),
-
limit: anyNamed('limit'),
-
cursor: anyNamed('cursor'),
-
),
-
).thenAnswer((_) async => mockResponse);
-
-
await feedProviderWithVotes.fetchTimeline(refresh: true);
-
-
verify(
-
mockVoteProvider.setInitialVoteState(
-
postUri: 'at://did:plc:test/social.coves.post.record/1',
-
voteDirection: 'up',
-
voteUri: 'at://did:plc:test/social.coves.feed.vote/vote1',
-
),
-
).called(1);
-
});
-
-
test('should initialize vote state when viewer.vote is "down"', () async {
-
when(mockAuthProvider.isAuthenticated).thenReturn(true);
-
-
final mockResponse = TimelineResponse(
-
feed: [
-
_createMockPostWithViewer(
-
uri: 'at://did:plc:test/social.coves.post.record/1',
-
vote: 'down',
-
voteUri: 'at://did:plc:test/social.coves.feed.vote/vote1',
-
),
-
],
-
cursor: 'cursor',
-
);
-
-
when(
-
mockApiService.getTimeline(
-
sort: anyNamed('sort'),
-
timeframe: anyNamed('timeframe'),
-
limit: anyNamed('limit'),
-
cursor: anyNamed('cursor'),
-
),
-
).thenAnswer((_) async => mockResponse);
-
-
await feedProviderWithVotes.fetchTimeline(refresh: true);
-
-
verify(
-
mockVoteProvider.setInitialVoteState(
-
postUri: 'at://did:plc:test/social.coves.post.record/1',
-
voteDirection: 'down',
-
voteUri: 'at://did:plc:test/social.coves.feed.vote/vote1',
-
),
-
).called(1);
-
});
-
-
test(
-
'should clear stale vote state when viewer.vote is null on refresh',
-
() async {
-
when(mockAuthProvider.isAuthenticated).thenReturn(true);
-
-
// Feed item with null vote (user removed vote on another device)
-
final mockResponse = TimelineResponse(
-
feed: [
-
_createMockPostWithViewer(
-
uri: 'at://did:plc:test/social.coves.post.record/1',
-
vote: null,
-
voteUri: null,
-
),
-
],
-
cursor: 'cursor',
-
);
-
-
when(
-
mockApiService.getTimeline(
-
sort: anyNamed('sort'),
-
timeframe: anyNamed('timeframe'),
-
limit: anyNamed('limit'),
-
cursor: anyNamed('cursor'),
-
),
-
).thenAnswer((_) async => mockResponse);
-
-
await feedProviderWithVotes.fetchTimeline(refresh: true);
-
-
// Should call setInitialVoteState with null to clear stale state
-
verify(
-
mockVoteProvider.setInitialVoteState(
-
postUri: 'at://did:plc:test/social.coves.post.record/1',
-
voteDirection: null,
-
voteUri: null,
-
),
-
).called(1);
-
},
-
);
-
-
test(
-
'should initialize vote state for all feed items including no viewer',
-
() async {
-
when(mockAuthProvider.isAuthenticated).thenReturn(true);
-
-
final mockResponse = TimelineResponse(
-
feed: [
-
_createMockPostWithViewer(
-
uri: 'at://did:plc:test/social.coves.post.record/1',
-
vote: 'up',
-
voteUri: 'at://did:plc:test/social.coves.feed.vote/vote1',
-
),
-
_createMockPost(), // No viewer state
-
],
-
cursor: 'cursor',
-
);
-
-
when(
-
mockApiService.getTimeline(
-
sort: anyNamed('sort'),
-
timeframe: anyNamed('timeframe'),
-
limit: anyNamed('limit'),
-
cursor: anyNamed('cursor'),
-
),
-
).thenAnswer((_) async => mockResponse);
-
-
await feedProviderWithVotes.fetchTimeline(refresh: true);
-
-
// Should be called for both posts
-
verify(
-
mockVoteProvider.setInitialVoteState(
-
postUri: anyNamed('postUri'),
-
voteDirection: anyNamed('voteDirection'),
-
voteUri: anyNamed('voteUri'),
-
),
-
).called(2);
-
},
-
);
-
-
test('should not initialize vote state when not authenticated', () async {
-
when(mockAuthProvider.isAuthenticated).thenReturn(false);
-
-
final mockResponse = TimelineResponse(
-
feed: [
-
_createMockPostWithViewer(
-
uri: 'at://did:plc:test/social.coves.post.record/1',
-
vote: 'up',
-
voteUri: 'at://did:plc:test/social.coves.feed.vote/vote1',
-
),
-
],
-
cursor: 'cursor',
-
);
-
-
when(
-
mockApiService.getDiscover(
-
sort: anyNamed('sort'),
-
timeframe: anyNamed('timeframe'),
-
limit: anyNamed('limit'),
-
cursor: anyNamed('cursor'),
-
),
-
).thenAnswer((_) async => mockResponse);
-
-
await feedProviderWithVotes.fetchDiscover(refresh: true);
-
-
// Should NOT call setInitialVoteState when not authenticated
-
verifyNever(
-
mockVoteProvider.setInitialVoteState(
-
postUri: anyNamed('postUri'),
-
voteDirection: anyNamed('voteDirection'),
-
voteUri: anyNamed('voteUri'),
-
),
-
);
-
});
-
});
-
});
-
}
-
-
// Helper function to create mock posts
-
FeedViewPost _createMockPost() {
-
return FeedViewPost(
-
post: PostView(
-
uri: 'at://did:plc:test/app.bsky.feed.post/test',
-
cid: 'test-cid',
-
rkey: 'test-rkey',
-
author: AuthorView(
-
did: 'did:plc:author',
-
handle: 'test.user',
-
displayName: 'Test User',
-
),
-
community: CommunityRef(did: 'did:plc:community', name: 'test-community'),
-
createdAt: DateTime.parse('2025-01-01T12:00:00Z'),
-
indexedAt: DateTime.parse('2025-01-01T12:00:00Z'),
-
text: 'Test body',
-
title: 'Test Post',
-
stats: PostStats(score: 42, upvotes: 50, downvotes: 8, commentCount: 5),
-
facets: [],
-
),
-
);
-
}
-
-
// Helper function to create mock posts with viewer state
-
FeedViewPost _createMockPostWithViewer({
-
required String uri,
-
String? vote,
-
String? voteUri,
-
}) {
-
return FeedViewPost(
-
post: PostView(
-
uri: uri,
-
cid: 'test-cid',
-
rkey: 'test-rkey',
-
author: AuthorView(
-
did: 'did:plc:author',
-
handle: 'test.user',
-
displayName: 'Test User',
-
),
-
community: CommunityRef(did: 'did:plc:community', name: 'test-community'),
-
createdAt: DateTime.parse('2025-01-01T12:00:00Z'),
-
indexedAt: DateTime.parse('2025-01-01T12:00:00Z'),
-
text: 'Test body',
-
title: 'Test Post',
-
stats: PostStats(score: 42, upvotes: 50, downvotes: 8, commentCount: 5),
-
facets: [],
-
viewer: ViewerState(vote: vote, voteUri: voteUri),
-
),
-
);
-
}
···
+4 -2
test/widget_test.dart
···
import 'package:coves_flutter/main.dart';
import 'package:coves_flutter/providers/auth_provider.dart';
-
import 'package:coves_flutter/providers/feed_provider.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:provider/provider.dart';
···
MultiProvider(
providers: [
ChangeNotifierProvider.value(value: authProvider),
-
ChangeNotifierProvider(create: (_) => FeedProvider(authProvider)),
],
child: const CovesApp(),
),
···
import 'package:coves_flutter/main.dart';
import 'package:coves_flutter/providers/auth_provider.dart';
+
import 'package:coves_flutter/providers/multi_feed_provider.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:provider/provider.dart';
···
MultiProvider(
providers: [
ChangeNotifierProvider.value(value: authProvider),
+
ChangeNotifierProvider(
+
create: (_) => MultiFeedProvider(authProvider),
+
),
],
child: const CovesApp(),
),
+4 -4
pubspec.lock
···
dependency: transitive
description:
name: meta
-
sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
url: "https://pub.dev"
source: hosted
-
version: "1.17.0"
mime:
dependency: transitive
description:
···
dependency: transitive
description:
name: test_api
-
sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55
url: "https://pub.dev"
source: hosted
-
version: "0.7.7"
typed_data:
dependency: transitive
description:
···
dependency: transitive
description:
name: meta
+
sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c
url: "https://pub.dev"
source: hosted
+
version: "1.16.0"
mime:
dependency: transitive
description:
···
dependency: transitive
description:
name: test_api
+
sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00"
url: "https://pub.dev"
source: hosted
+
version: "0.7.6"
typed_data:
dependency: transitive
description: