mobile bluesky app made with flutter lazurite.stormlightlabs.org/
mobile bluesky flutter
3
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: jump to top for feeds & refresh on tap feed label

* fix dual spinners

+354 -51
+13
CHANGELOG.md
··· 1 1 # CHANGELOG 2 2 3 + ## v1.0.0 (Alpha 3) (Unreleased) 4 + 5 + ### Added 6 + 7 + #### 2026-05-04 8 + 9 + - Jump to top action in feed & profile screens. 10 + 11 + ### Fixed 12 + 13 + - Removed dual loading/refresh spinner/indicator in feeds 14 + - Feed Generator refresh & feed content reload race condition is fixed 15 + 3 16 ## v1.0.0 (Alpha 2) 4 17 5 18 ### Changed
+44 -7
lib/features/feed/data/feed_repository.dart
··· 125 125 return null; 126 126 } 127 127 128 - final posts = cachedPosts 129 - .map((entry) => FeedViewPost.fromJson(jsonDecode(entry.postJson) as Map<String, dynamic>)) 130 - .toList(growable: false); 128 + final posts = <FeedViewPost>[]; 129 + for (final entry in cachedPosts) { 130 + try { 131 + posts.add(FeedViewPost.fromJson(jsonDecode(entry.postJson) as Map<String, dynamic>)); 132 + } catch (error, stackTrace) { 133 + log.w( 134 + 'feed.getCachedFeedPage decode failed account=$_accountDid feedKey=$feedKey postUri=${entry.postUri}', 135 + error: error, 136 + stackTrace: stackTrace, 137 + ); 138 + } 139 + } 140 + if (posts.isEmpty) { 141 + return null; 142 + } 143 + 131 144 final pageMeta = await _database.getCachedFeedPage(_accountDid, feedKey); 132 145 String? cursor; 133 146 if (pageMeta != null) { 134 - final decoded = jsonDecode(pageMeta.payload) as Map<String, dynamic>; 135 - cursor = decoded['cursor'] as String?; 147 + try { 148 + final decoded = jsonDecode(pageMeta.payload) as Map<String, dynamic>; 149 + cursor = decoded['cursor'] as String?; 150 + } catch (error, stackTrace) { 151 + log.w( 152 + 'feed.getCachedFeedPage pageMeta decode failed account=$_accountDid feedKey=$feedKey', 153 + error: error, 154 + stackTrace: stackTrace, 155 + ); 156 + } 136 157 } 137 158 138 159 return FeedResult(posts: posts, cursor: cursor); ··· 340 361 if (seen.contains(cached.postUri)) { 341 362 continue; 342 363 } 343 - addPost(FeedViewPost.fromJson(jsonDecode(cached.postJson) as Map<String, dynamic>)); 364 + try { 365 + addPost(FeedViewPost.fromJson(jsonDecode(cached.postJson) as Map<String, dynamic>)); 366 + } catch (error, stackTrace) { 367 + log.w( 368 + 'feed.cacheWindow decode failed account=$_accountDid feedKey=$feedKey postUri=${cached.postUri}', 369 + error: error, 370 + stackTrace: stackTrace, 371 + ); 372 + } 344 373 } 345 374 } else { 346 375 for (final cached in existingPosts) { 347 - addPost(FeedViewPost.fromJson(jsonDecode(cached.postJson) as Map<String, dynamic>)); 376 + try { 377 + addPost(FeedViewPost.fromJson(jsonDecode(cached.postJson) as Map<String, dynamic>)); 378 + } catch (error, stackTrace) { 379 + log.w( 380 + 'feed.cacheWindow decode failed account=$_accountDid feedKey=$feedKey postUri=${cached.postUri}', 381 + error: error, 382 + stackTrace: stackTrace, 383 + ); 384 + } 348 385 } 349 386 for (final post in result.posts) { 350 387 addPost(post);
+128 -28
lib/features/feed/presentation/home_feed_screen.dart
··· 39 39 40 40 class _HomeFeedScreenState extends State<HomeFeedScreen> { 41 41 late final PageController _pageController; 42 + final Map<String, int> _reloadCommandByFeed = <String, int>{}; 43 + final Map<String, int> _jumpToTopCommandByFeed = <String, int>{}; 42 44 String? _selectedFeedId; 43 45 44 46 @override ··· 117 119 prefsState: prefsState, 118 120 currentTabIndex: currentTabIndex, 119 121 onTabTapped: (index) { 122 + final tappedFeed = pinnedFeeds[index]; 123 + final isRetap = currentTabIndex == index; 124 + if (isRetap) { 125 + setState(() { 126 + _reloadCommandByFeed[tappedFeed.id] = (_reloadCommandByFeed[tappedFeed.id] ?? 0) + 1; 127 + }); 128 + return; 129 + } 120 130 _pageController.animateToPage( 121 131 index, 122 132 duration: const Duration(milliseconds: 300), 123 133 curve: Curves.easeInOut, 124 134 ); 125 - setState(() => _selectedFeedId = pinnedFeeds[index].id); 135 + setState(() => _selectedFeedId = tappedFeed.id); 126 136 }, 127 137 ), 128 138 ), ··· 130 140 controller: _pageController, 131 141 onPageChanged: (index) => setState(() => _selectedFeedId = pinnedFeeds[index].id), 132 142 itemCount: pinnedFeeds.length, 133 - itemBuilder: (context, index) => 134 - _FeedListView(feed: pinnedFeeds[index], key: ValueKey(pinnedFeeds[index].id)), 143 + itemBuilder: (context, index) => _FeedListView( 144 + feed: pinnedFeeds[index], 145 + reloadCommand: _reloadCommandByFeed[pinnedFeeds[index].id] ?? 0, 146 + jumpToTopCommand: _jumpToTopCommandByFeed[pinnedFeeds[index].id] ?? 0, 147 + key: ValueKey('feed-list-${pinnedFeeds[index].id}'), 148 + ), 135 149 ), 136 - floatingActionButton: 137 - FloatingActionButton( 138 - heroTag: 'home-compose-fab', 139 - tooltip: isOffline ? offlineActionMessage('compose a post') : 'Compose', 140 - onPressed: isOffline ? null : () => context.push('/compose'), 141 - shape: const CircleBorder(), 142 - child: const Icon(Icons.add), 143 - ).animateIfAllowed( 144 - context, 145 - effects: const [ 146 - FadeEffect(duration: Anim.feedItem, curve: Anim.enter), 147 - ScaleEffect(begin: Offset(0, 0), end: Offset(1, 1), duration: Anim.feedItem, curve: Anim.emphasis), 148 - ], 149 - ), 150 + floatingActionButtonLocation: FloatingActionButtonLocation.centerFloat, 151 + floatingActionButton: _buildFloatingButtons(context, pinnedFeeds, currentTabIndex, isOffline), 150 152 ), 151 153 ); 152 154 }, 153 155 ); 154 156 } 155 157 158 + Widget _buildFloatingButtons(BuildContext context, List<SavedFeed> pinnedFeeds, int currentTabIndex, bool isOffline) { 159 + final currentFeedId = pinnedFeeds[currentTabIndex].id; 160 + 161 + final jumpToTopButton = FloatingActionButton.small( 162 + heroTag: 'home-jump-top-fab', 163 + tooltip: 'Jump to top', 164 + onPressed: () { 165 + setState(() { 166 + _jumpToTopCommandByFeed[currentFeedId] = (_jumpToTopCommandByFeed[currentFeedId] ?? 0) + 1; 167 + }); 168 + }, 169 + backgroundColor: Theme.of(context).colorScheme.surfaceContainerHighest.withValues(alpha: 0.9), 170 + foregroundColor: Theme.of(context).colorScheme.onSurfaceVariant, 171 + elevation: 1.5, 172 + child: const Icon(Icons.arrow_upward, size: 18), 173 + ); 174 + 175 + final composeButton = 176 + FloatingActionButton( 177 + heroTag: 'home-compose-fab', 178 + tooltip: isOffline ? offlineActionMessage('compose a post') : 'Compose', 179 + onPressed: isOffline ? null : () => context.push('/compose'), 180 + shape: const CircleBorder(), 181 + child: const Icon(Icons.add), 182 + ).animateIfAllowed( 183 + context, 184 + effects: const [ 185 + FadeEffect(duration: Anim.feedItem, curve: Anim.enter), 186 + ScaleEffect(begin: Offset(0, 0), end: Offset(1, 1), duration: Anim.feedItem, curve: Anim.emphasis), 187 + ], 188 + ); 189 + 190 + return Row( 191 + mainAxisSize: MainAxisSize.max, 192 + children: [const SizedBox(width: 24), jumpToTopButton, const Spacer(), composeButton], 193 + ); 194 + } 195 + 156 196 void _syncSelectedFeed(List<SavedFeed> feeds, int currentTabIndex) { 157 197 final selectedFeedId = feeds[currentTabIndex].id; 158 198 if (_selectedFeedId != selectedFeedId) { ··· 238 278 } 239 279 240 280 class _FeedListView extends StatefulWidget { 241 - const _FeedListView({required this.feed, super.key}); 281 + const _FeedListView({required this.feed, required this.reloadCommand, required this.jumpToTopCommand, super.key}); 242 282 243 283 final SavedFeed feed; 284 + final int reloadCommand; 285 + final int jumpToTopCommand; 244 286 245 287 @override 246 288 State<_FeedListView> createState() => _FeedListViewState(); ··· 256 298 String? _errorMessage; 257 299 final ScrollController _scrollController = ScrollController(); 258 300 final Set<String> _seenPostUris = <String>{}; 301 + late int _lastReloadCommand; 302 + late int _lastJumpToTopCommand; 259 303 260 304 @override 261 305 bool get wantKeepAlive => true; ··· 263 307 @override 264 308 void initState() { 265 309 super.initState(); 310 + _lastReloadCommand = widget.reloadCommand; 311 + _lastJumpToTopCommand = widget.jumpToTopCommand; 266 312 _scrollController.addListener(_onScroll); 267 313 _primeFeed(); 268 314 } 269 315 270 316 @override 317 + void didUpdateWidget(covariant _FeedListView oldWidget) { 318 + super.didUpdateWidget(oldWidget); 319 + 320 + if (widget.reloadCommand != _lastReloadCommand) { 321 + _lastReloadCommand = widget.reloadCommand; 322 + WidgetsBinding.instance.addPostFrameCallback((_) { 323 + if (!mounted) { 324 + return; 325 + } 326 + _setStateIfMounted(() { 327 + _hasError = false; 328 + _errorMessage = null; 329 + }); 330 + _loadFeed(); 331 + }); 332 + } 333 + 334 + if (widget.jumpToTopCommand != _lastJumpToTopCommand) { 335 + _lastJumpToTopCommand = widget.jumpToTopCommand; 336 + WidgetsBinding.instance.addPostFrameCallback((_) { 337 + if (!mounted) { 338 + return; 339 + } 340 + jumpToTop(); 341 + }); 342 + } 343 + } 344 + 345 + @override 271 346 void dispose() { 272 347 _scrollController.removeListener(_onScroll); 273 348 _scrollController.dispose(); ··· 291 366 } 292 367 293 368 Future<void> _primeFeed() async { 294 - final cachedResult = await _loadCachedFeed(); 295 - if (cachedResult != null) { 369 + try { 370 + final cachedResult = await _loadCachedFeed(); 371 + if (cachedResult != null) { 372 + _setStateIfMounted(() { 373 + _posts 374 + ..clear() 375 + ..addAll(cachedResult.posts); 376 + _cursor = cachedResult.cursor; 377 + _hasError = false; 378 + _errorMessage = null; 379 + _showInitialLoading = false; 380 + }); 381 + } 382 + 383 + await _loadFeedInternal(showLoading: cachedResult == null, showOfflineFeedback: false); 384 + } catch (e) { 296 385 _setStateIfMounted(() { 297 - _posts 298 - ..clear() 299 - ..addAll(cachedResult.posts); 300 - _cursor = cachedResult.cursor; 301 - _hasError = false; 302 - _errorMessage = null; 386 + _isLoading = false; 387 + _isLoadingMore = false; 303 388 _showInitialLoading = false; 389 + if (_posts.isEmpty) { 390 + _hasError = true; 391 + _errorMessage = e.toString(); 392 + } 304 393 }); 305 394 } 306 - 307 - await _loadFeedInternal(showLoading: cachedResult == null, showOfflineFeedback: false); 308 395 } 309 396 310 397 Future<void> _loadFeedInternal({required bool showLoading, required bool showOfflineFeedback}) async { ··· 387 474 } catch (e) { 388 475 _setStateIfMounted(() => _isLoadingMore = false); 389 476 } 477 + } 478 + 479 + Future<void> jumpToTop() async { 480 + if (!_scrollController.hasClients) { 481 + return; 482 + } 483 + 484 + final currentOffset = _scrollController.offset; 485 + if (currentOffset <= 0) { 486 + return; 487 + } 488 + 489 + await _scrollController.animateTo(0, duration: const Duration(milliseconds: 220), curve: Curves.easeOutCubic); 390 490 } 391 491 392 492 void _setStateIfMounted(VoidCallback fn) {
+2
lib/features/feed/presentation/widgets/feed_layout_view.dart
··· 46 46 Widget _buildCompact(BuildContext context) { 47 47 return AnimatedRefreshIndicator( 48 48 onRefresh: onRefresh, 49 + showCornerSpinner: false, 49 50 child: CustomScrollView( 50 51 controller: scrollController, 51 52 slivers: [ ··· 71 72 Widget _buildCard(BuildContext context) { 72 73 return AnimatedRefreshIndicator( 73 74 onRefresh: onRefresh, 75 + showCornerSpinner: false, 74 76 child: ListView.builder( 75 77 controller: scrollController, 76 78 padding: const EdgeInsets.symmetric(vertical: 4),
+52 -10
lib/features/profile/presentation/profile_screen.dart
··· 129 129 130 130 late TabController _tabController; 131 131 final ScrollController _profileScrollController = ScrollController(); 132 + final Map<_ProfileFeedSlice, ScrollController> _feedScrollControllers = { 133 + for (final tab in _feedTabs) tab.slice: ScrollController(), 134 + }; 132 135 final GlobalKey<RefreshIndicatorState> _profileRefreshKey = GlobalKey<RefreshIndicatorState>(); 133 136 late bool _showSuggestedTab; 134 137 double _coverScrollOffset = 0; ··· 166 169 void dispose() { 167 170 _tabController.dispose(); 168 171 _profileScrollController.dispose(); 172 + for (final controller in _feedScrollControllers.values) { 173 + controller.dispose(); 174 + } 169 175 super.dispose(); 170 176 } 171 177 ··· 383 389 await Future<void>.delayed(const Duration(milliseconds: 250)); 384 390 } 385 391 392 + Future<void> _jumpToTop() async { 393 + final futures = <Future<void>>[]; 394 + final currentSlice = _tabController.index < _feedTabs.length ? _feedTabs[_tabController.index].slice : null; 395 + final feedController = currentSlice == null ? null : _feedScrollControllers[currentSlice]; 396 + if (feedController != null && feedController.hasClients && feedController.offset > 0) { 397 + futures.add(feedController.animateTo(0, duration: const Duration(milliseconds: 220), curve: Curves.easeOutCubic)); 398 + } 399 + 400 + if (_profileScrollController.hasClients && _profileScrollController.offset > 0) { 401 + futures.add( 402 + _profileScrollController.animateTo(0, duration: const Duration(milliseconds: 220), curve: Curves.easeOutCubic), 403 + ); 404 + } 405 + 406 + if (futures.isEmpty) { 407 + return; 408 + } 409 + 410 + try { 411 + await Future.wait(futures); 412 + } catch (_) {} 413 + } 414 + 386 415 bool get _isAtTop => !_profileScrollController.hasClients || _profileScrollController.position.pixels <= 0.5; 387 416 388 417 void _onHeaderPointerDown(PointerDownEvent event) { ··· 624 653 ); 625 654 }, 626 655 ), 627 - floatingActionButton: AnimatedSwitcher( 628 - duration: Anim.feedItem, 629 - switchInCurve: Anim.enter, 630 - switchOutCurve: Anim.exit, 631 - transitionBuilder: (child, animation) => FadeTransition( 632 - opacity: animation, 633 - child: ScaleTransition(scale: animation, child: child), 634 - ), 635 - child: _buildComposeFab(context), 636 - ), 656 + floatingActionButtonLocation: FloatingActionButtonLocation.centerFloat, 657 + floatingActionButton: _buildProfileFabs(context), 637 658 ), 638 659 ), 660 + ); 661 + } 662 + 663 + Widget _buildProfileFabs(BuildContext context) { 664 + final jumpToTopButton = FloatingActionButton.small( 665 + key: const ValueKey('profile-jump-top-fab'), 666 + heroTag: 'profile-jump-top-fab', 667 + tooltip: 'Jump to top', 668 + onPressed: _jumpToTop, 669 + backgroundColor: Theme.of(context).colorScheme.surfaceContainerHighest.withValues(alpha: 0.9), 670 + foregroundColor: Theme.of(context).colorScheme.onSurfaceVariant, 671 + elevation: 1.5, 672 + child: const Icon(Icons.arrow_upward, size: 18), 673 + ); 674 + 675 + return Row( 676 + mainAxisSize: MainAxisSize.max, 677 + children: [const SizedBox(width: 24), jumpToTopButton, const Spacer(), _buildComposeFab(context)], 639 678 ); 640 679 } 641 680 ··· 1188 1227 return false; 1189 1228 }, 1190 1229 child: ListView.builder( 1230 + controller: _feedScrollControllers[_ProfileFeedSlice.replies], 1191 1231 key: const PageStorageKey<String>('profile_replies_thread_list'), 1192 1232 padding: EdgeInsets.zero, 1193 1233 itemCount: feedState.posts.length + (feedState.isLoadingMore ? 1 : 0), ··· 1253 1293 return false; 1254 1294 }, 1255 1295 child: ListView.builder( 1296 + controller: _feedScrollControllers[slice], 1256 1297 key: scrollKey, 1257 1298 padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), 1258 1299 itemCount: feedState.posts.length + (feedState.isLoadingMore ? 1 : 0), ··· 1301 1342 return false; 1302 1343 }, 1303 1344 child: ListView.builder( 1345 + controller: _feedScrollControllers[slice], 1304 1346 key: PageStorageKey<String>('profile_linear_feed_${slice.name}'), 1305 1347 padding: EdgeInsets.zero, 1306 1348 itemCount: feedState.posts.length + (feedState.isLoadingMore ? 1 : 0),
+9 -2
lib/shared/presentation/widgets/animated_refresh_indicator.dart
··· 5 5 import 'package:lazurite/core/theme/animation_utils.dart'; 6 6 7 7 class AnimatedRefreshIndicator extends StatefulWidget { 8 - const AnimatedRefreshIndicator({super.key, required this.onRefresh, required this.child, this.displacement = 40}); 8 + const AnimatedRefreshIndicator({ 9 + super.key, 10 + required this.onRefresh, 11 + required this.child, 12 + this.displacement = 40, 13 + this.showCornerSpinner = true, 14 + }); 9 15 10 16 final RefreshCallback onRefresh; 11 17 final Widget child; 12 18 final double displacement; 19 + final bool showCornerSpinner; 13 20 14 21 @override 15 22 State<AnimatedRefreshIndicator> createState() => _AnimatedRefreshIndicatorState(); ··· 57 64 Widget build(BuildContext context) => Stack( 58 65 children: [ 59 66 RefreshIndicator(onRefresh: _handleRefresh, displacement: widget.displacement, child: widget.child), 60 - if (animationsAllowed(context)) 67 + if (widget.showCornerSpinner && animationsAllowed(context)) 61 68 Positioned( 62 69 top: 12, 63 70 right: 16,
+33
test/features/feed/data/feed_repository_cache_test.dart
··· 1 1 import 'dart:collection'; 2 + import 'dart:convert'; 2 3 3 4 import 'package:atproto_core/atproto_core.dart'; 4 5 import 'package:bluesky/app_bsky_actor_defs.dart'; ··· 105 106 106 107 final rows = await database.getCachedFeedPosts('did:plc:test', FeedRepository.timelineCacheKey); 107 108 expect(rows.length, OfflineCachePolicy.feedPostLimit); 109 + }); 110 + 111 + test('getCachedFeedPage tolerates malformed cached posts and returns valid entries', () async { 112 + final feedApi = _QueuedFeedApi(); 113 + final repository = FeedRepository(bluesky: _FakeBluesky(feedApi), database: database, accountDid: 'did:plc:test'); 114 + final validPost = _post(2); 115 + 116 + await database.upsertCachedFeedPosts( 117 + accountDid: 'did:plc:test', 118 + feedKey: FeedRepository.timelineCacheKey, 119 + posts: [ 120 + CachedFeedPostsCompanion.insert( 121 + accountDid: 'did:plc:test', 122 + feedKey: FeedRepository.timelineCacheKey, 123 + postUri: _post(1).post.uri.toString(), 124 + postJson: '{', 125 + sortOrder: 2, 126 + ), 127 + CachedFeedPostsCompanion.insert( 128 + accountDid: 'did:plc:test', 129 + feedKey: FeedRepository.timelineCacheKey, 130 + postUri: validPost.post.uri.toString(), 131 + postJson: jsonEncode(validPost.toJson()), 132 + sortOrder: 1, 133 + ), 134 + ], 135 + ); 136 + 137 + final cached = await repository.getCachedFeedPage(FeedRepository.timelineCacheKey); 138 + expect(cached, isNotNull); 139 + expect(cached!.posts.length, 1); 140 + expect(cached.posts.single.post.uri.toString(), validPost.post.uri.toString()); 108 141 }); 109 142 }); 110 143 }
+66 -1
test/features/feed/presentation/home_feed_screen_test.dart
··· 16 16 import 'package:lazurite/features/feed/presentation/widgets/feed_layout_view.dart'; 17 17 import 'package:lazurite/features/settings/bloc/settings_cubit.dart'; 18 18 import 'package:lazurite/features/settings/bloc/settings_state.dart'; 19 + import 'package:lazurite/shared/presentation/widgets/animated_refresh_indicator.dart'; 19 20 import 'package:lazurite/shared/presentation/widgets/app_screen_entrance.dart'; 20 21 import 'package:mocktail/mocktail.dart'; 21 22 ··· 78 79 ConnectivityState connectivityState = const ConnectivityState.online(), 79 80 }) { 80 81 final connectivityCubit = MockConnectivityCubit(); 82 + final settingsCubit = MockSettingsCubit(); 81 83 final authBloc = MockAuthBloc(); 82 84 when(() => connectivityCubit.state).thenReturn(connectivityState); 83 85 whenListen(connectivityCubit, const Stream<ConnectivityState>.empty(), initialState: connectivityState); 86 + when(() => settingsCubit.state).thenReturn(_settingsState(FeedLayout.card)); 87 + whenListen(settingsCubit, const Stream<SettingsState>.empty(), initialState: _settingsState(FeedLayout.card)); 84 88 when(() => authBloc.state).thenReturn( 85 89 const AuthState.authenticated(AuthTokens(accessToken: 'access', did: 'did:plc:test', handle: 'test.bsky.social')), 86 90 ); ··· 98 102 child: MultiBlocProvider( 99 103 providers: [ 100 104 BlocProvider<AuthBloc>.value(value: authBloc), 105 + BlocProvider<SettingsCubit>.value(value: settingsCubit), 101 106 BlocProvider<FeedPreferencesCubit>.value(value: feedPreferencesCubit), 102 107 BlocProvider<ConnectivityCubit>.value(value: connectivityCubit), 103 108 ], ··· 138 143 expect(find.byType(SliverGrid), findsNothing); 139 144 expect(find.byType(SliverList), findsOneWidget); 140 145 expect(find.byType(CustomScrollView), findsOneWidget); 146 + final refresh = tester.widget<AnimatedRefreshIndicator>(find.byType(AnimatedRefreshIndicator)); 147 + expect(refresh.showCornerSpinner, isFalse); 141 148 }); 142 149 143 150 testWidgets('uses compact item builder in compact mode', (tester) async { ··· 160 167 161 168 expect(find.byType(ListView), findsOneWidget); 162 169 expect(find.byType(SliverGrid), findsNothing); 170 + final refresh = tester.widget<AnimatedRefreshIndicator>(find.byType(AnimatedRefreshIndicator)); 171 + expect(refresh.showCornerSpinner, isFalse); 163 172 }); 164 173 165 174 testWidgets('uses card item builder in card mode', (tester) async { ··· 326 335 ); 327 336 await tester.pump(); 328 337 329 - final fab = tester.widget<FloatingActionButton>(find.byType(FloatingActionButton)); 338 + final fab = tester 339 + .widgetList<FloatingActionButton>(find.byType(FloatingActionButton)) 340 + .firstWhere((candidate) => candidate.heroTag == 'home-compose-fab'); 330 341 expect(fab.heroTag, 'home-compose-fab'); 342 + }); 343 + 344 + testWidgets('shows a left jump-to-top FAB', (tester) async { 345 + final feedPreferencesCubit = MockFeedPreferencesCubit(); 346 + final feedRepository = MockFeedRepository(); 347 + 348 + when(() => feedPreferencesCubit.state).thenReturn(_homeFeedState); 349 + whenListen(feedPreferencesCubit, const Stream<FeedPreferencesState>.empty(), initialState: _homeFeedState); 350 + when(() => feedRepository.getCachedFeedPage(any())).thenAnswer((_) async => null); 351 + when( 352 + () => feedRepository.getTimeline( 353 + cursor: any(named: 'cursor'), 354 + limit: any(named: 'limit'), 355 + ), 356 + ).thenAnswer((_) async => FeedResult(posts: const [])); 357 + 358 + await tester.pumpWidget( 359 + buildHomeSubject(feedPreferencesCubit: feedPreferencesCubit, feedRepository: feedRepository), 360 + ); 361 + await tester.pumpAndSettle(); 362 + 363 + expect(find.byTooltip('Jump to top'), findsOneWidget); 364 + expect(find.byType(FloatingActionButton), findsNWidgets(2)); 365 + }); 366 + 367 + testWidgets('re-tapping selected feed tab reloads the feed', (tester) async { 368 + final feedPreferencesCubit = MockFeedPreferencesCubit(); 369 + final feedRepository = MockFeedRepository(); 370 + 371 + when(() => feedPreferencesCubit.state).thenReturn(_homeFeedState); 372 + whenListen(feedPreferencesCubit, const Stream<FeedPreferencesState>.empty(), initialState: _homeFeedState); 373 + when(() => feedRepository.getCachedFeedPage(any())).thenAnswer((_) async => null); 374 + when( 375 + () => feedRepository.getTimeline( 376 + cursor: any(named: 'cursor'), 377 + limit: any(named: 'limit'), 378 + ), 379 + ).thenAnswer((_) async => FeedResult(posts: const [], cursor: 'cursor-1')); 380 + 381 + await tester.pumpWidget( 382 + buildHomeSubject(feedPreferencesCubit: feedPreferencesCubit, feedRepository: feedRepository), 383 + ); 384 + await tester.pumpAndSettle(); 385 + 386 + expect(find.text('FOLLOWING'), findsOneWidget); 387 + await tester.tap(find.text('FOLLOWING')); 388 + await tester.pump(); 389 + 390 + verify( 391 + () => feedRepository.getTimeline( 392 + cursor: any(named: 'cursor'), 393 + limit: any(named: 'limit'), 394 + ), 395 + ).called(2); 331 396 }); 332 397 333 398 testWidgets('does not call setState after dispose when feed loading completes', (tester) async {
+7 -3
test/features/profile/presentation/profile_screen_test.dart
··· 382 382 await tester.pumpWidget(MaterialApp.router(routerConfig: router)); 383 383 await tester.pumpAndSettle(); 384 384 385 - expect(find.byType(FloatingActionButton), findsOneWidget); 386 - expect(tester.widget<FloatingActionButton>(find.byType(FloatingActionButton)).heroTag, 'profile-compose-fab'); 385 + expect(find.byKey(const ValueKey('profile-jump-top-fab')), findsOneWidget); 386 + expect(find.byKey(const ValueKey('profile-compose-fab')), findsOneWidget); 387 + expect( 388 + tester.widget<FloatingActionButton>(find.byKey(const ValueKey('profile-compose-fab'))).heroTag, 389 + 'profile-compose-fab', 390 + ); 387 391 388 - await tester.tap(find.byType(FloatingActionButton)); 392 + await tester.tap(find.byKey(const ValueKey('profile-compose-fab'))); 389 393 await tester.pumpAndSettle(); 390 394 391 395 expect(find.text('@other.bsky.social '), findsOneWidget);