diff --git a/CHANGELOG.md b/CHANGELOG.md index 8aea403..25eaa14 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,18 @@ # CHANGELOG +## v1.0.0 (Alpha 3) (Unreleased) + +### Added + +#### 2026-05-04 + +- Jump to top action in feed & profile screens. + +### Fixed + +- Removed dual loading/refresh spinner/indicator in feeds +- Feed Generator refresh & feed content reload race condition is fixed + ## v1.0.0 (Alpha 2) ### Changed diff --git a/lib/features/feed/data/feed_repository.dart b/lib/features/feed/data/feed_repository.dart index 8ea2e6b..2e593f6 100644 --- a/lib/features/feed/data/feed_repository.dart +++ b/lib/features/feed/data/feed_repository.dart @@ -125,14 +125,35 @@ class FeedRepository { return null; } - final posts = cachedPosts - .map((entry) => FeedViewPost.fromJson(jsonDecode(entry.postJson) as Map)) - .toList(growable: false); + final posts = []; + for (final entry in cachedPosts) { + try { + posts.add(FeedViewPost.fromJson(jsonDecode(entry.postJson) as Map)); + } catch (error, stackTrace) { + log.w( + 'feed.getCachedFeedPage decode failed account=$_accountDid feedKey=$feedKey postUri=${entry.postUri}', + error: error, + stackTrace: stackTrace, + ); + } + } + if (posts.isEmpty) { + return null; + } + final pageMeta = await _database.getCachedFeedPage(_accountDid, feedKey); String? cursor; if (pageMeta != null) { - final decoded = jsonDecode(pageMeta.payload) as Map; - cursor = decoded['cursor'] as String?; + try { + final decoded = jsonDecode(pageMeta.payload) as Map; + cursor = decoded['cursor'] as String?; + } catch (error, stackTrace) { + log.w( + 'feed.getCachedFeedPage pageMeta decode failed account=$_accountDid feedKey=$feedKey', + error: error, + stackTrace: stackTrace, + ); + } } return FeedResult(posts: posts, cursor: cursor); @@ -340,11 +361,27 @@ class FeedRepository { if (seen.contains(cached.postUri)) { continue; } - addPost(FeedViewPost.fromJson(jsonDecode(cached.postJson) as Map)); + try { + addPost(FeedViewPost.fromJson(jsonDecode(cached.postJson) as Map)); + } catch (error, stackTrace) { + log.w( + 'feed.cacheWindow decode failed account=$_accountDid feedKey=$feedKey postUri=${cached.postUri}', + error: error, + stackTrace: stackTrace, + ); + } } } else { for (final cached in existingPosts) { - addPost(FeedViewPost.fromJson(jsonDecode(cached.postJson) as Map)); + try { + addPost(FeedViewPost.fromJson(jsonDecode(cached.postJson) as Map)); + } catch (error, stackTrace) { + log.w( + 'feed.cacheWindow decode failed account=$_accountDid feedKey=$feedKey postUri=${cached.postUri}', + error: error, + stackTrace: stackTrace, + ); + } } for (final post in result.posts) { addPost(post); diff --git a/lib/features/feed/presentation/home_feed_screen.dart b/lib/features/feed/presentation/home_feed_screen.dart index edf5fee..fb91c0b 100644 --- a/lib/features/feed/presentation/home_feed_screen.dart +++ b/lib/features/feed/presentation/home_feed_screen.dart @@ -39,6 +39,8 @@ class HomeFeedScreen extends StatefulWidget { class _HomeFeedScreenState extends State { late final PageController _pageController; + final Map _reloadCommandByFeed = {}; + final Map _jumpToTopCommandByFeed = {}; String? _selectedFeedId; @override @@ -117,12 +119,20 @@ class _HomeFeedScreenState extends State { prefsState: prefsState, currentTabIndex: currentTabIndex, onTabTapped: (index) { + final tappedFeed = pinnedFeeds[index]; + final isRetap = currentTabIndex == index; + if (isRetap) { + setState(() { + _reloadCommandByFeed[tappedFeed.id] = (_reloadCommandByFeed[tappedFeed.id] ?? 0) + 1; + }); + return; + } _pageController.animateToPage( index, duration: const Duration(milliseconds: 300), curve: Curves.easeInOut, ); - setState(() => _selectedFeedId = pinnedFeeds[index].id); + setState(() => _selectedFeedId = tappedFeed.id); }, ), ), @@ -130,29 +140,59 @@ class _HomeFeedScreenState extends State { controller: _pageController, onPageChanged: (index) => setState(() => _selectedFeedId = pinnedFeeds[index].id), itemCount: pinnedFeeds.length, - itemBuilder: (context, index) => - _FeedListView(feed: pinnedFeeds[index], key: ValueKey(pinnedFeeds[index].id)), + itemBuilder: (context, index) => _FeedListView( + feed: pinnedFeeds[index], + reloadCommand: _reloadCommandByFeed[pinnedFeeds[index].id] ?? 0, + jumpToTopCommand: _jumpToTopCommandByFeed[pinnedFeeds[index].id] ?? 0, + key: ValueKey('feed-list-${pinnedFeeds[index].id}'), + ), ), - floatingActionButton: - FloatingActionButton( - heroTag: 'home-compose-fab', - tooltip: isOffline ? offlineActionMessage('compose a post') : 'Compose', - onPressed: isOffline ? null : () => context.push('/compose'), - shape: const CircleBorder(), - child: const Icon(Icons.add), - ).animateIfAllowed( - context, - effects: const [ - FadeEffect(duration: Anim.feedItem, curve: Anim.enter), - ScaleEffect(begin: Offset(0, 0), end: Offset(1, 1), duration: Anim.feedItem, curve: Anim.emphasis), - ], - ), + floatingActionButtonLocation: FloatingActionButtonLocation.centerFloat, + floatingActionButton: _buildFloatingButtons(context, pinnedFeeds, currentTabIndex, isOffline), ), ); }, ); } + Widget _buildFloatingButtons(BuildContext context, List pinnedFeeds, int currentTabIndex, bool isOffline) { + final currentFeedId = pinnedFeeds[currentTabIndex].id; + + final jumpToTopButton = FloatingActionButton.small( + heroTag: 'home-jump-top-fab', + tooltip: 'Jump to top', + onPressed: () { + setState(() { + _jumpToTopCommandByFeed[currentFeedId] = (_jumpToTopCommandByFeed[currentFeedId] ?? 0) + 1; + }); + }, + backgroundColor: Theme.of(context).colorScheme.surfaceContainerHighest.withValues(alpha: 0.9), + foregroundColor: Theme.of(context).colorScheme.onSurfaceVariant, + elevation: 1.5, + child: const Icon(Icons.arrow_upward, size: 18), + ); + + final composeButton = + FloatingActionButton( + heroTag: 'home-compose-fab', + tooltip: isOffline ? offlineActionMessage('compose a post') : 'Compose', + onPressed: isOffline ? null : () => context.push('/compose'), + shape: const CircleBorder(), + child: const Icon(Icons.add), + ).animateIfAllowed( + context, + effects: const [ + FadeEffect(duration: Anim.feedItem, curve: Anim.enter), + ScaleEffect(begin: Offset(0, 0), end: Offset(1, 1), duration: Anim.feedItem, curve: Anim.emphasis), + ], + ); + + return Row( + mainAxisSize: MainAxisSize.max, + children: [const SizedBox(width: 24), jumpToTopButton, const Spacer(), composeButton], + ); + } + void _syncSelectedFeed(List feeds, int currentTabIndex) { final selectedFeedId = feeds[currentTabIndex].id; if (_selectedFeedId != selectedFeedId) { @@ -238,9 +278,11 @@ class _FeedTabBar extends StatelessWidget implements PreferredSizeWidget { } class _FeedListView extends StatefulWidget { - const _FeedListView({required this.feed, super.key}); + const _FeedListView({required this.feed, required this.reloadCommand, required this.jumpToTopCommand, super.key}); final SavedFeed feed; + final int reloadCommand; + final int jumpToTopCommand; @override State<_FeedListView> createState() => _FeedListViewState(); @@ -256,6 +298,8 @@ class _FeedListViewState extends State<_FeedListView> with AutomaticKeepAliveCli String? _errorMessage; final ScrollController _scrollController = ScrollController(); final Set _seenPostUris = {}; + late int _lastReloadCommand; + late int _lastJumpToTopCommand; @override bool get wantKeepAlive => true; @@ -263,10 +307,41 @@ class _FeedListViewState extends State<_FeedListView> with AutomaticKeepAliveCli @override void initState() { super.initState(); + _lastReloadCommand = widget.reloadCommand; + _lastJumpToTopCommand = widget.jumpToTopCommand; _scrollController.addListener(_onScroll); _primeFeed(); } + @override + void didUpdateWidget(covariant _FeedListView oldWidget) { + super.didUpdateWidget(oldWidget); + + if (widget.reloadCommand != _lastReloadCommand) { + _lastReloadCommand = widget.reloadCommand; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) { + return; + } + _setStateIfMounted(() { + _hasError = false; + _errorMessage = null; + }); + _loadFeed(); + }); + } + + if (widget.jumpToTopCommand != _lastJumpToTopCommand) { + _lastJumpToTopCommand = widget.jumpToTopCommand; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) { + return; + } + jumpToTop(); + }); + } + } + @override void dispose() { _scrollController.removeListener(_onScroll); @@ -291,20 +366,32 @@ class _FeedListViewState extends State<_FeedListView> with AutomaticKeepAliveCli } Future _primeFeed() async { - final cachedResult = await _loadCachedFeed(); - if (cachedResult != null) { + try { + final cachedResult = await _loadCachedFeed(); + if (cachedResult != null) { + _setStateIfMounted(() { + _posts + ..clear() + ..addAll(cachedResult.posts); + _cursor = cachedResult.cursor; + _hasError = false; + _errorMessage = null; + _showInitialLoading = false; + }); + } + + await _loadFeedInternal(showLoading: cachedResult == null, showOfflineFeedback: false); + } catch (e) { _setStateIfMounted(() { - _posts - ..clear() - ..addAll(cachedResult.posts); - _cursor = cachedResult.cursor; - _hasError = false; - _errorMessage = null; + _isLoading = false; + _isLoadingMore = false; _showInitialLoading = false; + if (_posts.isEmpty) { + _hasError = true; + _errorMessage = e.toString(); + } }); } - - await _loadFeedInternal(showLoading: cachedResult == null, showOfflineFeedback: false); } Future _loadFeedInternal({required bool showLoading, required bool showOfflineFeedback}) async { @@ -389,6 +476,19 @@ class _FeedListViewState extends State<_FeedListView> with AutomaticKeepAliveCli } } + Future jumpToTop() async { + if (!_scrollController.hasClients) { + return; + } + + final currentOffset = _scrollController.offset; + if (currentOffset <= 0) { + return; + } + + await _scrollController.animateTo(0, duration: const Duration(milliseconds: 220), curve: Curves.easeOutCubic); + } + void _setStateIfMounted(VoidCallback fn) { if (!mounted) { return; diff --git a/lib/features/feed/presentation/widgets/feed_layout_view.dart b/lib/features/feed/presentation/widgets/feed_layout_view.dart index 1e08acc..594821a 100644 --- a/lib/features/feed/presentation/widgets/feed_layout_view.dart +++ b/lib/features/feed/presentation/widgets/feed_layout_view.dart @@ -46,6 +46,7 @@ class FeedLayoutView extends StatelessWidget { Widget _buildCompact(BuildContext context) { return AnimatedRefreshIndicator( onRefresh: onRefresh, + showCornerSpinner: false, child: CustomScrollView( controller: scrollController, slivers: [ @@ -71,6 +72,7 @@ class FeedLayoutView extends StatelessWidget { Widget _buildCard(BuildContext context) { return AnimatedRefreshIndicator( onRefresh: onRefresh, + showCornerSpinner: false, child: ListView.builder( controller: scrollController, padding: const EdgeInsets.symmetric(vertical: 4), diff --git a/lib/features/profile/presentation/profile_screen.dart b/lib/features/profile/presentation/profile_screen.dart index 91f0ba9..befcfdc 100644 --- a/lib/features/profile/presentation/profile_screen.dart +++ b/lib/features/profile/presentation/profile_screen.dart @@ -129,6 +129,9 @@ class _ProfileScreenState extends State with TickerProviderStateM late TabController _tabController; final ScrollController _profileScrollController = ScrollController(); + final Map<_ProfileFeedSlice, ScrollController> _feedScrollControllers = { + for (final tab in _feedTabs) tab.slice: ScrollController(), + }; final GlobalKey _profileRefreshKey = GlobalKey(); late bool _showSuggestedTab; double _coverScrollOffset = 0; @@ -166,6 +169,9 @@ class _ProfileScreenState extends State with TickerProviderStateM void dispose() { _tabController.dispose(); _profileScrollController.dispose(); + for (final controller in _feedScrollControllers.values) { + controller.dispose(); + } super.dispose(); } @@ -383,6 +389,29 @@ class _ProfileScreenState extends State with TickerProviderStateM await Future.delayed(const Duration(milliseconds: 250)); } + Future _jumpToTop() async { + final futures = >[]; + final currentSlice = _tabController.index < _feedTabs.length ? _feedTabs[_tabController.index].slice : null; + final feedController = currentSlice == null ? null : _feedScrollControllers[currentSlice]; + if (feedController != null && feedController.hasClients && feedController.offset > 0) { + futures.add(feedController.animateTo(0, duration: const Duration(milliseconds: 220), curve: Curves.easeOutCubic)); + } + + if (_profileScrollController.hasClients && _profileScrollController.offset > 0) { + futures.add( + _profileScrollController.animateTo(0, duration: const Duration(milliseconds: 220), curve: Curves.easeOutCubic), + ); + } + + if (futures.isEmpty) { + return; + } + + try { + await Future.wait(futures); + } catch (_) {} + } + bool get _isAtTop => !_profileScrollController.hasClients || _profileScrollController.position.pixels <= 0.5; void _onHeaderPointerDown(PointerDownEvent event) { @@ -624,21 +653,31 @@ class _ProfileScreenState extends State with TickerProviderStateM ); }, ), - floatingActionButton: AnimatedSwitcher( - duration: Anim.feedItem, - switchInCurve: Anim.enter, - switchOutCurve: Anim.exit, - transitionBuilder: (child, animation) => FadeTransition( - opacity: animation, - child: ScaleTransition(scale: animation, child: child), - ), - child: _buildComposeFab(context), - ), + floatingActionButtonLocation: FloatingActionButtonLocation.centerFloat, + floatingActionButton: _buildProfileFabs(context), ), ), ); } + Widget _buildProfileFabs(BuildContext context) { + final jumpToTopButton = FloatingActionButton.small( + key: const ValueKey('profile-jump-top-fab'), + heroTag: 'profile-jump-top-fab', + tooltip: 'Jump to top', + onPressed: _jumpToTop, + backgroundColor: Theme.of(context).colorScheme.surfaceContainerHighest.withValues(alpha: 0.9), + foregroundColor: Theme.of(context).colorScheme.onSurfaceVariant, + elevation: 1.5, + child: const Icon(Icons.arrow_upward, size: 18), + ); + + return Row( + mainAxisSize: MainAxisSize.max, + children: [const SizedBox(width: 24), jumpToTopButton, const Spacer(), _buildComposeFab(context)], + ); + } + Widget _buildCoverSection(BuildContext context, ProfileViewDetailed? profile) { final width = MediaQuery.of(context).size.width; final coverHeight = width >= 600 ? 256.0 : 192.0; @@ -1188,6 +1227,7 @@ class _ProfileScreenState extends State with TickerProviderStateM return false; }, child: ListView.builder( + controller: _feedScrollControllers[_ProfileFeedSlice.replies], key: const PageStorageKey('profile_replies_thread_list'), padding: EdgeInsets.zero, itemCount: feedState.posts.length + (feedState.isLoadingMore ? 1 : 0), @@ -1253,6 +1293,7 @@ class _ProfileScreenState extends State with TickerProviderStateM return false; }, child: ListView.builder( + controller: _feedScrollControllers[slice], key: scrollKey, padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), itemCount: feedState.posts.length + (feedState.isLoadingMore ? 1 : 0), @@ -1301,6 +1342,7 @@ class _ProfileScreenState extends State with TickerProviderStateM return false; }, child: ListView.builder( + controller: _feedScrollControllers[slice], key: PageStorageKey('profile_linear_feed_${slice.name}'), padding: EdgeInsets.zero, itemCount: feedState.posts.length + (feedState.isLoadingMore ? 1 : 0), diff --git a/lib/shared/presentation/widgets/animated_refresh_indicator.dart b/lib/shared/presentation/widgets/animated_refresh_indicator.dart index 41616b4..dcd59a9 100644 --- a/lib/shared/presentation/widgets/animated_refresh_indicator.dart +++ b/lib/shared/presentation/widgets/animated_refresh_indicator.dart @@ -5,11 +5,18 @@ import 'package:lazurite/core/theme/animation_tokens.dart'; import 'package:lazurite/core/theme/animation_utils.dart'; class AnimatedRefreshIndicator extends StatefulWidget { - const AnimatedRefreshIndicator({super.key, required this.onRefresh, required this.child, this.displacement = 40}); + const AnimatedRefreshIndicator({ + super.key, + required this.onRefresh, + required this.child, + this.displacement = 40, + this.showCornerSpinner = true, + }); final RefreshCallback onRefresh; final Widget child; final double displacement; + final bool showCornerSpinner; @override State createState() => _AnimatedRefreshIndicatorState(); @@ -57,7 +64,7 @@ class _AnimatedRefreshIndicatorState extends State wit Widget build(BuildContext context) => Stack( children: [ RefreshIndicator(onRefresh: _handleRefresh, displacement: widget.displacement, child: widget.child), - if (animationsAllowed(context)) + if (widget.showCornerSpinner && animationsAllowed(context)) Positioned( top: 12, right: 16, diff --git a/test/features/feed/data/feed_repository_cache_test.dart b/test/features/feed/data/feed_repository_cache_test.dart index 4bbe048..3c71682 100644 --- a/test/features/feed/data/feed_repository_cache_test.dart +++ b/test/features/feed/data/feed_repository_cache_test.dart @@ -1,4 +1,5 @@ import 'dart:collection'; +import 'dart:convert'; import 'package:atproto_core/atproto_core.dart'; import 'package:bluesky/app_bsky_actor_defs.dart'; @@ -106,6 +107,38 @@ void main() { final rows = await database.getCachedFeedPosts('did:plc:test', FeedRepository.timelineCacheKey); expect(rows.length, OfflineCachePolicy.feedPostLimit); }); + + test('getCachedFeedPage tolerates malformed cached posts and returns valid entries', () async { + final feedApi = _QueuedFeedApi(); + final repository = FeedRepository(bluesky: _FakeBluesky(feedApi), database: database, accountDid: 'did:plc:test'); + final validPost = _post(2); + + await database.upsertCachedFeedPosts( + accountDid: 'did:plc:test', + feedKey: FeedRepository.timelineCacheKey, + posts: [ + CachedFeedPostsCompanion.insert( + accountDid: 'did:plc:test', + feedKey: FeedRepository.timelineCacheKey, + postUri: _post(1).post.uri.toString(), + postJson: '{', + sortOrder: 2, + ), + CachedFeedPostsCompanion.insert( + accountDid: 'did:plc:test', + feedKey: FeedRepository.timelineCacheKey, + postUri: validPost.post.uri.toString(), + postJson: jsonEncode(validPost.toJson()), + sortOrder: 1, + ), + ], + ); + + final cached = await repository.getCachedFeedPage(FeedRepository.timelineCacheKey); + expect(cached, isNotNull); + expect(cached!.posts.length, 1); + expect(cached.posts.single.post.uri.toString(), validPost.post.uri.toString()); + }); }); } diff --git a/test/features/feed/presentation/home_feed_screen_test.dart b/test/features/feed/presentation/home_feed_screen_test.dart index 7a25464..f36c32c 100644 --- a/test/features/feed/presentation/home_feed_screen_test.dart +++ b/test/features/feed/presentation/home_feed_screen_test.dart @@ -16,6 +16,7 @@ import 'package:lazurite/features/feed/presentation/home_feed_screen.dart'; import 'package:lazurite/features/feed/presentation/widgets/feed_layout_view.dart'; import 'package:lazurite/features/settings/bloc/settings_cubit.dart'; import 'package:lazurite/features/settings/bloc/settings_state.dart'; +import 'package:lazurite/shared/presentation/widgets/animated_refresh_indicator.dart'; import 'package:lazurite/shared/presentation/widgets/app_screen_entrance.dart'; import 'package:mocktail/mocktail.dart'; @@ -78,9 +79,12 @@ void main() { ConnectivityState connectivityState = const ConnectivityState.online(), }) { final connectivityCubit = MockConnectivityCubit(); + final settingsCubit = MockSettingsCubit(); final authBloc = MockAuthBloc(); when(() => connectivityCubit.state).thenReturn(connectivityState); whenListen(connectivityCubit, const Stream.empty(), initialState: connectivityState); + when(() => settingsCubit.state).thenReturn(_settingsState(FeedLayout.card)); + whenListen(settingsCubit, const Stream.empty(), initialState: _settingsState(FeedLayout.card)); when(() => authBloc.state).thenReturn( const AuthState.authenticated(AuthTokens(accessToken: 'access', did: 'did:plc:test', handle: 'test.bsky.social')), ); @@ -98,6 +102,7 @@ void main() { child: MultiBlocProvider( providers: [ BlocProvider.value(value: authBloc), + BlocProvider.value(value: settingsCubit), BlocProvider.value(value: feedPreferencesCubit), BlocProvider.value(value: connectivityCubit), ], @@ -138,6 +143,8 @@ void main() { expect(find.byType(SliverGrid), findsNothing); expect(find.byType(SliverList), findsOneWidget); expect(find.byType(CustomScrollView), findsOneWidget); + final refresh = tester.widget(find.byType(AnimatedRefreshIndicator)); + expect(refresh.showCornerSpinner, isFalse); }); testWidgets('uses compact item builder in compact mode', (tester) async { @@ -160,6 +167,8 @@ void main() { expect(find.byType(ListView), findsOneWidget); expect(find.byType(SliverGrid), findsNothing); + final refresh = tester.widget(find.byType(AnimatedRefreshIndicator)); + expect(refresh.showCornerSpinner, isFalse); }); testWidgets('uses card item builder in card mode', (tester) async { @@ -326,10 +335,66 @@ void main() { ); await tester.pump(); - final fab = tester.widget(find.byType(FloatingActionButton)); + final fab = tester + .widgetList(find.byType(FloatingActionButton)) + .firstWhere((candidate) => candidate.heroTag == 'home-compose-fab'); expect(fab.heroTag, 'home-compose-fab'); }); + testWidgets('shows a left jump-to-top FAB', (tester) async { + final feedPreferencesCubit = MockFeedPreferencesCubit(); + final feedRepository = MockFeedRepository(); + + when(() => feedPreferencesCubit.state).thenReturn(_homeFeedState); + whenListen(feedPreferencesCubit, const Stream.empty(), initialState: _homeFeedState); + when(() => feedRepository.getCachedFeedPage(any())).thenAnswer((_) async => null); + when( + () => feedRepository.getTimeline( + cursor: any(named: 'cursor'), + limit: any(named: 'limit'), + ), + ).thenAnswer((_) async => FeedResult(posts: const [])); + + await tester.pumpWidget( + buildHomeSubject(feedPreferencesCubit: feedPreferencesCubit, feedRepository: feedRepository), + ); + await tester.pumpAndSettle(); + + expect(find.byTooltip('Jump to top'), findsOneWidget); + expect(find.byType(FloatingActionButton), findsNWidgets(2)); + }); + + testWidgets('re-tapping selected feed tab reloads the feed', (tester) async { + final feedPreferencesCubit = MockFeedPreferencesCubit(); + final feedRepository = MockFeedRepository(); + + when(() => feedPreferencesCubit.state).thenReturn(_homeFeedState); + whenListen(feedPreferencesCubit, const Stream.empty(), initialState: _homeFeedState); + when(() => feedRepository.getCachedFeedPage(any())).thenAnswer((_) async => null); + when( + () => feedRepository.getTimeline( + cursor: any(named: 'cursor'), + limit: any(named: 'limit'), + ), + ).thenAnswer((_) async => FeedResult(posts: const [], cursor: 'cursor-1')); + + await tester.pumpWidget( + buildHomeSubject(feedPreferencesCubit: feedPreferencesCubit, feedRepository: feedRepository), + ); + await tester.pumpAndSettle(); + + expect(find.text('FOLLOWING'), findsOneWidget); + await tester.tap(find.text('FOLLOWING')); + await tester.pump(); + + verify( + () => feedRepository.getTimeline( + cursor: any(named: 'cursor'), + limit: any(named: 'limit'), + ), + ).called(2); + }); + testWidgets('does not call setState after dispose when feed loading completes', (tester) async { final feedPreferencesCubit = MockFeedPreferencesCubit(); final feedRepository = MockFeedRepository(); diff --git a/test/features/profile/presentation/profile_screen_test.dart b/test/features/profile/presentation/profile_screen_test.dart index 16214b3..f5989f6 100644 --- a/test/features/profile/presentation/profile_screen_test.dart +++ b/test/features/profile/presentation/profile_screen_test.dart @@ -382,10 +382,14 @@ void main() { await tester.pumpWidget(MaterialApp.router(routerConfig: router)); await tester.pumpAndSettle(); - expect(find.byType(FloatingActionButton), findsOneWidget); - expect(tester.widget(find.byType(FloatingActionButton)).heroTag, 'profile-compose-fab'); + expect(find.byKey(const ValueKey('profile-jump-top-fab')), findsOneWidget); + expect(find.byKey(const ValueKey('profile-compose-fab')), findsOneWidget); + expect( + tester.widget(find.byKey(const ValueKey('profile-compose-fab'))).heroTag, + 'profile-compose-fab', + ); - await tester.tap(find.byType(FloatingActionButton)); + await tester.tap(find.byKey(const ValueKey('profile-compose-fab'))); await tester.pumpAndSettle(); expect(find.text('@other.bsky.social '), findsOneWidget);