diff --git a/lib/features/profile/presentation/profile_screen.dart b/lib/features/profile/presentation/profile_screen.dart index 9055209..03a2170 100644 --- a/lib/features/profile/presentation/profile_screen.dart +++ b/lib/features/profile/presentation/profile_screen.dart @@ -86,10 +86,8 @@ class _ProfileScreenState extends State with TickerProviderStateM static const _coverRefreshTriggerDistance = 72.0; late TabController _tabController; + final GlobalKey _nestedScrollKey = GlobalKey(); 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; @@ -127,9 +125,6 @@ class _ProfileScreenState extends State with TickerProviderStateM void dispose() { _tabController.dispose(); _profileScrollController.dispose(); - for (final controller in _feedScrollControllers.values) { - controller.dispose(); - } super.dispose(); } @@ -379,10 +374,11 @@ class _ProfileScreenState extends State with TickerProviderStateM 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)); + final innerController = _nestedScrollKey.currentState?.innerController; + if (innerController != null && innerController.hasClients && innerController.offset > 0) { + futures.add( + innerController.animateTo(0, duration: const Duration(milliseconds: 220), curve: Curves.easeOutCubic), + ); } if (_profileScrollController.hasClients && _profileScrollController.offset > 0) { @@ -553,6 +549,7 @@ class _ProfileScreenState extends State with TickerProviderStateM notificationPredicate: (notification) => notification.depth == 0 && notification.metrics.axis == Axis.vertical, child: NestedScrollView( + key: _nestedScrollKey, controller: _profileScrollController, physics: const AlwaysScrollableScrollPhysics(), headerSliverBuilder: (context, innerBoxIsScrolled) { @@ -1235,19 +1232,29 @@ class _ProfileScreenState extends State with TickerProviderStateM final visibleFeedState = sourceState.copyWith(posts: visiblePosts); if (expectedActor != null && isActiveTab && !feedMatchesExpectedActor) { - return const Center(child: CircularProgressIndicator()); + return _buildScrollableTabStatus( + storageKey: 'profile-feed-loading-${slice.name}', + child: const Center(child: CircularProgressIndicator()), + ); } if (isActiveTab && sourceState.status == FeedStatus.initial) { - return const Center(child: CircularProgressIndicator()); + return _buildScrollableTabStatus( + storageKey: 'profile-feed-initial-${slice.name}', + child: const Center(child: CircularProgressIndicator()), + ); } if (sourceState.isLoading && visiblePosts.isEmpty) { - return const Center(child: CircularProgressIndicator()); + return _buildScrollableTabStatus( + storageKey: 'profile-feed-refreshing-${slice.name}', + child: const Center(child: CircularProgressIndicator()), + ); } if (sourceState.hasError && feedMatchesExpectedActor) { - return Center( + return _buildScrollableTabStatus( + storageKey: 'profile-feed-error-${slice.name}', child: Column( mainAxisSize: MainAxisSize.min, children: [ @@ -1263,7 +1270,10 @@ class _ProfileScreenState extends State with TickerProviderStateM } if (visiblePosts.isEmpty) { - return Center(child: Text(emptyLabel)); + return _buildScrollableTabStatus( + storageKey: 'profile-feed-empty-${slice.name}', + child: Center(child: Text(emptyLabel)), + ); } if (slice == _ProfileFeedSlice.replies) { @@ -1281,6 +1291,13 @@ class _ProfileScreenState extends State with TickerProviderStateM ); } + Widget _buildScrollableTabStatus({required String storageKey, required Widget child}) { + return CustomScrollView( + key: PageStorageKey(storageKey), + slivers: [SliverFillRemaining(hasScrollBody: false, child: child)], + ); + } + Widget _buildRepliesFeed(BuildContext context, FeedState feedState, {required FeedFilter requestFilter}) { final accountDid = context.read().state.tokens?.did ?? ''; return RefreshIndicator( @@ -1296,7 +1313,6 @@ 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), @@ -1362,7 +1378,6 @@ 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), @@ -1411,7 +1426,6 @@ 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), @@ -1593,7 +1607,15 @@ class _SuggestedFollowsTabState extends State<_SuggestedFollowsTab> { Widget build(BuildContext context) { final cubit = _cubit; if (cubit == null) { - return Center(child: Text(context.l10n.messageSuggestedFollowsUnavailable)); + return CustomScrollView( + key: const PageStorageKey('profile-suggested-unavailable'), + slivers: [ + SliverFillRemaining( + hasScrollBody: false, + child: Center(child: Text(context.l10n.messageSuggestedFollowsUnavailable)), + ), + ], + ); } return BlocProvider.value( @@ -1648,17 +1670,28 @@ class _ProfileListsPaneState extends State<_ProfileListsPane> { builder: (context, state) { switch (state.status) { case MyListsStatus.loading: - return const Center(child: CircularProgressIndicator()); + return const CustomScrollView( + key: PageStorageKey('profile-lists-loading'), + slivers: [SliverFillRemaining(hasScrollBody: false, child: Center(child: CircularProgressIndicator()))], + ); case MyListsStatus.error: - return Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Text(state.errorMessage ?? context.l10n.errorFailedToLoadLists), - const SizedBox(height: 12), - FilledButton(onPressed: () => _cubit.refresh(), child: Text(context.l10n.buttonRetry)), - ], - ), + return CustomScrollView( + key: const PageStorageKey('profile-lists-error'), + slivers: [ + SliverFillRemaining( + hasScrollBody: false, + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text(state.errorMessage ?? context.l10n.errorFailedToLoadLists), + const SizedBox(height: 12), + FilledButton(onPressed: () => _cubit.refresh(), child: Text(context.l10n.buttonRetry)), + ], + ), + ), + ), + ], ); default: final lists = state.lists @@ -1670,7 +1703,12 @@ class _ProfileListsPaneState extends State<_ProfileListsPane> { .toList(growable: false); if (lists.isEmpty) { - return Center(child: Text(context.l10n.messageNoListsYet)); + return CustomScrollView( + key: const PageStorageKey('profile-lists-empty'), + slivers: [ + SliverFillRemaining(hasScrollBody: false, child: Center(child: Text(context.l10n.messageNoListsYet))), + ], + ); } return RefreshIndicator( diff --git a/lib/features/profile/presentation/widgets/profile_liked_posts_pane.dart b/lib/features/profile/presentation/widgets/profile_liked_posts_pane.dart index 657e2b7..809faee 100644 --- a/lib/features/profile/presentation/widgets/profile_liked_posts_pane.dart +++ b/lib/features/profile/presentation/widgets/profile_liked_posts_pane.dart @@ -3,6 +3,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; import 'package:lazurite/core/l10n/l10n.dart'; +import 'package:lazurite/core/logging/app_logger.dart'; import 'package:lazurite/features/auth/bloc/auth_bloc.dart'; import 'package:lazurite/features/feed/presentation/widgets/post_card_with_actions.dart'; import 'package:lazurite/features/profile/data/profile_repository.dart'; @@ -57,10 +58,10 @@ class _ProfileLikedPostsPaneState extends State { _hasMore = page.cursor != null; _isLoading = false; }); - } catch (e) { + } catch (error) { if (!mounted) return; setState(() { - _error = 'Failed to load liked posts: $e'; + _error = 'Failed to load liked posts: $error'; _isLoading = false; }); } @@ -85,7 +86,8 @@ class _ProfileLikedPostsPaneState extends State { _hasMore = page.cursor != null; _isLoadingMore = false; }); - } catch (_) { + } catch (error, stackTrace) { + log.d('ProfileLikedPostsPane: ignored load-more failure', error: error, stackTrace: stackTrace); if (!mounted) return; setState(() => _isLoadingMore = false); } @@ -94,24 +96,40 @@ class _ProfileLikedPostsPaneState extends State { @override Widget build(BuildContext context) { if (_isLoading) { - return const Center(child: CircularProgressIndicator()); + return const CustomScrollView( + key: PageStorageKey('profile-liked-posts-loading'), + slivers: [SliverFillRemaining(hasScrollBody: false, child: Center(child: CircularProgressIndicator()))], + ); } if (_error != null) { - return Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Text(_error!), - const SizedBox(height: 12), - FilledButton(onPressed: _loadInitial, child: Text(context.l10n.buttonRetry)), - ], - ), + return CustomScrollView( + key: const PageStorageKey('profile-liked-posts-error'), + slivers: [ + SliverFillRemaining( + hasScrollBody: false, + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text(_error!), + const SizedBox(height: 12), + FilledButton(onPressed: _loadInitial, child: Text(context.l10n.buttonRetry)), + ], + ), + ), + ), + ], ); } if (_entries.isEmpty) { - return Center(child: Text(context.l10n.messageNoLikedPostsYet)); + return CustomScrollView( + key: const PageStorageKey('profile-liked-posts-empty'), + slivers: [ + SliverFillRemaining(hasScrollBody: false, child: Center(child: Text(context.l10n.messageNoLikedPostsYet))), + ], + ); } final accountDid = context.read().state.tokens?.did ?? ''; diff --git a/lib/features/profile/presentation/widgets/profile_starter_packs_pane.dart b/lib/features/profile/presentation/widgets/profile_starter_packs_pane.dart index 1cc04f9..5e8268f 100644 --- a/lib/features/profile/presentation/widgets/profile_starter_packs_pane.dart +++ b/lib/features/profile/presentation/widgets/profile_starter_packs_pane.dart @@ -46,27 +46,46 @@ class _ProfileStarterPacksPaneState extends State { bloc: _cubit, builder: (context, state) { if (state.status == ActorStarterPacksStatus.loading) { - return const Center(child: CircularProgressIndicator()); + return const CustomScrollView( + key: PageStorageKey('profile-starter-packs-loading'), + slivers: [SliverFillRemaining(hasScrollBody: false, child: Center(child: CircularProgressIndicator()))], + ); } if (state.status == ActorStarterPacksStatus.error) { - return Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Text(state.errorMessage ?? context.l10n.errorFailedToLoadStarterPacks), - const SizedBox(height: 12), - FilledButton( - onPressed: () => _cubit.load(actor: widget.actor), - child: Text(context.l10n.buttonRetry), + return CustomScrollView( + key: const PageStorageKey('profile-starter-packs-error'), + slivers: [ + SliverFillRemaining( + hasScrollBody: false, + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text(state.errorMessage ?? context.l10n.errorFailedToLoadStarterPacks), + const SizedBox(height: 12), + FilledButton( + onPressed: () => _cubit.load(actor: widget.actor), + child: Text(context.l10n.buttonRetry), + ), + ], + ), ), - ], - ), + ), + ], ); } if (state.starterPacks.isEmpty) { - return Center(child: Text(context.l10n.messageNoStarterPacksYet)); + return CustomScrollView( + key: const PageStorageKey('profile-starter-packs-empty'), + slivers: [ + SliverFillRemaining( + hasScrollBody: false, + child: Center(child: Text(context.l10n.messageNoStarterPacksYet)), + ), + ], + ); } return RefreshIndicator( diff --git a/test/features/profile/presentation/profile_screen_test.dart b/test/features/profile/presentation/profile_screen_test.dart index 0ae3d04..b65af5d 100644 --- a/test/features/profile/presentation/profile_screen_test.dart +++ b/test/features/profile/presentation/profile_screen_test.dart @@ -657,6 +657,22 @@ void main() { await tester.pump(); expect(tester.takeException(), isNull); }); + + testWidgets('cancelling a drag on an empty feed tab keeps nested scroll metrics valid', (tester) async { + useLargeScreen(tester); + await tester.pumpWidget(buildSubject()); + await tester.pump(); + + final gesture = await tester.startGesture( + tester.getCenter(find.byKey(const PageStorageKey('profile-feed-empty-posts'))), + ); + await gesture.moveBy(const Offset(0, -80)); + await tester.pump(); + await gesture.cancel(); + await tester.pump(); + + expect(tester.takeException(), isNull); + }); }); group('Feed layout switching', () {