From a83ea69b722a8dcfcacf7b560157651d208a1018 Mon Sep 17 00:00:00 2001 From: klopez4212 Date: Wed, 14 Oct 2026 16:37:59 -0400 Subject: [PATCH] fix(mobile): replay native navigation state when returning Home (#8314) Restore the iOS navigation bar after any covering route pops, including opaque custom Settings and community routes. Use the shared app RouteObserver and didPopNext, then replay scroll depth and request native layout after Flutter paints the uncovered view. Voice recording retains the same observer through its existing alias. Regression coverage uses the real HomePage with both MaterialPageRoute and opaque PageRouteBuilder, at scrolled and top-of-list positions. The custom-route case fails before this change. Validation: 107 focused tests and analysis passed. Full mobile runs stalled under local disk pressure, so full-suite validation is incomplete. On-device verification of this follow-up remains pending. --------- Signed-off-by: kenny lopez --- mobile/lib/app.dart | 4 +- .../channels/voice_note_recording.dart | 3 +- .../shared/widgets/app_route_observer.dart | 4 + .../shared/widgets/ios_navigation_bar.dart | 51 +++++ .../features/channels/channels_page_test.dart | 200 ++++++++++++++++-- 5 files changed, 236 insertions(+), 26 deletions(-) create mode 100644 mobile/lib/shared/widgets/app_route_observer.dart diff --git a/mobile/lib/app.dart b/mobile/lib/app.dart index 03c73bb915..3c16eb5dba 100644 --- a/mobile/lib/app.dart +++ b/mobile/lib/app.dart @@ -27,7 +27,7 @@ import 'features/pairing/pairing_provider.dart'; import 'features/channels/agent_activity/observer_subscription.dart'; import 'features/channels/channel_detail_page.dart'; import 'features/channels/deep_link_dispatcher.dart'; -import 'features/channels/voice_note_recording.dart'; +import 'shared/widgets/app_route_observer.dart'; import 'features/profile/user_status_cache_provider.dart'; import 'features/profile/settings_profile_header.dart'; import 'features/profile/set_status_sheet.dart'; @@ -392,7 +392,7 @@ class App extends HookConsumerWidget { return MaterialApp( navigatorKey: _mobileRootNavigatorKey, - navigatorObservers: [voiceNoteRouteObserver], + navigatorObservers: [appRouteObserver], title: 'Buzz', theme: AppTheme.light( colorScheme: lightScheme, diff --git a/mobile/lib/features/channels/voice_note_recording.dart b/mobile/lib/features/channels/voice_note_recording.dart index c34565c341..772232978b 100644 --- a/mobile/lib/features/channels/voice_note_recording.dart +++ b/mobile/lib/features/channels/voice_note_recording.dart @@ -11,6 +11,7 @@ import 'package:path_provider/path_provider.dart'; import 'package:record/record.dart'; import '../../shared/relay/media_image.dart'; +import '../../shared/widgets/app_route_observer.dart'; /// Maximum duration accepted for a recorded voice note. const voiceNoteMaxDuration = Duration(minutes: 5); @@ -25,7 +26,7 @@ const voiceNoteMaxDownloadBytes = 32 * 1024 * 1024; const voiceNotePlaybackRates = [1, 1.5, 2, 0.5]; /// Route observer used to cancel recording when its composer is covered. -final voiceNoteRouteObserver = RouteObserver>(); +final voiceNoteRouteObserver = appRouteObserver; /// Returns the playback rate following [current] in the supported rate cycle. double nextVoiceNotePlaybackRate(double current) { diff --git a/mobile/lib/shared/widgets/app_route_observer.dart b/mobile/lib/shared/widgets/app_route_observer.dart new file mode 100644 index 0000000000..74a8bbd7cc --- /dev/null +++ b/mobile/lib/shared/widgets/app_route_observer.dart @@ -0,0 +1,4 @@ +import 'package:flutter/widgets.dart'; + +/// Shared route lifecycle for native navigation and recording cleanup. +final appRouteObserver = RouteObserver>(); diff --git a/mobile/lib/shared/widgets/ios_navigation_bar.dart b/mobile/lib/shared/widgets/ios_navigation_bar.dart index 081a94722d..42cf258d17 100644 --- a/mobile/lib/shared/widgets/ios_navigation_bar.dart +++ b/mobile/lib/shared/widgets/ios_navigation_bar.dart @@ -9,6 +9,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import '../relay/relay.dart'; import 'avatar_image.dart'; +import 'app_route_observer.dart'; import 'ios_navigation_metrics.dart'; export 'ios_navigation_metrics.dart'; @@ -343,6 +344,47 @@ class IosNavigationBar extends HookConsumerWidget { return () => offset?.removeListener(sync); }, [channel.value, offset, collapseRange]); + final route = ModalRoute.of(context); + useEffect(() { + var active = true; + Future restoreOnReveal() async { + final current = channel.value; + if (!active || + !context.mounted || + route?.isCurrent != true || + current == null) { + return; + } + try { + // Home stays mounted while another route covers its platform view. + // Returning does not emit a scroll notification, so replay the live + // depth before asking UIKit to lay out the revealed navigation bar. + await current.invokeMethod( + 'scroll', + (offset?.value ?? 0).clamp(0.0, collapseRange), + ); + if (!active || route?.isCurrent != true) return; + await current.invokeMethod('prepareForReveal'); + } on PlatformException catch (error) { + debugPrint('Native navigation reveal failed: $error'); + } + } + + final observer = _NavigationRevealObserver(() { + // didPopNext is independent of route transition compatibility. Defer + // until Flutter has painted the uncovered platform view for this frame. + WidgetsBinding.instance.addPostFrameCallback((_) { + unawaited(restoreOnReveal()); + }); + WidgetsBinding.instance.ensureVisualUpdate(); + }); + if (route != null) appRouteObserver.subscribe(observer, route); + return () { + active = false; + appRouteObserver.unsubscribe(observer); + }; + }, [channel.value, offset, collapseRange, route]); + return UiKitView( key: viewKey, viewType: viewType, @@ -354,3 +396,12 @@ class IosNavigationBar extends HookConsumerWidget { ); } } + +class _NavigationRevealObserver extends RouteAware { + _NavigationRevealObserver(this.onReveal); + + final VoidCallback onReveal; + + @override + void didPopNext() => onReveal(); +} diff --git a/mobile/test/features/channels/channels_page_test.dart b/mobile/test/features/channels/channels_page_test.dart index 1a543e3fd1..0a34bf2957 100644 --- a/mobile/test/features/channels/channels_page_test.dart +++ b/mobile/test/features/channels/channels_page_test.dart @@ -1,3 +1,5 @@ +import 'package:buzz/features/home/home_page.dart'; +import 'package:buzz/shared/widgets/app_route_observer.dart'; import 'package:buzz/features/pairing/pairing_provider.dart'; import 'package:buzz/shared/community/paired_community_landing.dart'; import 'dart:async'; @@ -80,6 +82,7 @@ void main() { ValueChanged? onSettingsTransitionProgress, ValueListenable? tabReselection, _FakeProfileNotifier? profile, + bool realHome = false, }) { return ProviderScope( overrides: [ @@ -100,6 +103,7 @@ void main() { ...overrides, ], child: MaterialApp( + navigatorObservers: [appRouteObserver], theme: brightness == Brightness.dark ? AppTheme.dark() : AppTheme.light(topSectionGradient: topSectionGradient), @@ -112,30 +116,35 @@ void main() { ), child: child!, ), - home: Stack( - children: [ - ChannelsPage( - settingsPageBuilder: _buildSettingsPage, - communityInvitePageBuilder: (_) => - const Scaffold(body: Text('Invite destination')), - communityAppearancePageBuilder: (_) => - const Scaffold(body: Text('Appearance destination')), - onSettingsTransitionProgress: - onSettingsTransitionProgress ?? (_) {}, - tabReselection: tabReselection, - ), - const Positioned.fill( - child: ChannelQuickActionsLauncher( - visible: true, - navigationBarHeight: 60, - navigationBarBottomGap: 12, - navigationBarWidth: 218, - systemBottomInset: 0, - rightInset: 16, + home: realHome + ? HomePage( + settingsPageBuilder: _buildSettingsPage, + hasUnreadInbox: false, + ) + : Stack( + children: [ + ChannelsPage( + settingsPageBuilder: _buildSettingsPage, + communityInvitePageBuilder: (_) => + const Scaffold(body: Text('Invite destination')), + communityAppearancePageBuilder: (_) => + const Scaffold(body: Text('Appearance destination')), + onSettingsTransitionProgress: + onSettingsTransitionProgress ?? (_) {}, + tabReselection: tabReselection, + ), + const Positioned.fill( + child: ChannelQuickActionsLauncher( + visible: true, + navigationBarHeight: 60, + navigationBarBottomGap: 12, + navigationBarWidth: 218, + systemBottomInset: 0, + rightInset: 16, + ), + ), + ], ), - ), - ], - ), ), ); } @@ -666,6 +675,151 @@ void main() { expect(appBar.frostedBlurSigma, 23.12); }); + for (final customRoute in [false, true]) { + testWidgets( + 'Home replays native scroll state on return: custom=$customRoute', + (tester) async { + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + addTearDown(() => debugDefaultTargetPlatformOverride = null); + tester.view.physicalSize = const Size(390, 600); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + final messenger = tester.binding.defaultBinaryMessenger; + final calls = >{}; + messenger.setMockMethodCallHandler(SystemChannels.platform_views, ( + call, + ) async { + if (call.method == 'create') { + final args = call.arguments as Map; + if (args['viewType'] == IosNavigationBar.viewType) { + final id = args['id'] as int; + calls[id] = []; + messenger.setMockMethodCallHandler( + MethodChannel('${IosNavigationBar.viewType}/$id'), + (call) async { + calls[id]!.add(call); + return null; + }, + ); + } + } + return null; + }); + addTearDown(() { + messenger.setMockMethodCallHandler( + SystemChannels.platform_views, + null, + ); + for (final id in calls.keys) { + messenger.setMockMethodCallHandler( + MethodChannel('${IosNavigationBar.viewType}/$id'), + null, + ); + } + }); + await tester.pumpWidget( + buildTestable( + realHome: true, + overrides: [ + channelsProvider.overrideWith( + () => _FakeNotifier( + List.generate( + 40, + (i) => Channel( + id: 'channel-$i', + description: '', + createdBy: 'test', + createdAt: DateTime(2026), + memberCount: 1, + visibility: 'open', + name: 'channel-$i', + channelType: 'stream', + isMember: true, + ), + ), + ), + ), + ], + ), + ); + await tester.pumpAndSettle(); + final homeCalls = calls.values.single; + final position = tester + .state( + find + .descendant( + of: find.byType(CustomScrollView), + matching: find.byType(Scrollable), + ) + .first, + ) + .position; + position.jumpTo(200); + await tester.pumpAndSettle(); + final scrolledOffset = + homeCalls.where((call) => call.method == 'scroll').last.arguments + as double; + expect(scrolledOffset, greaterThan(0)); + final navigator = tester.state( + find.byType(Navigator).first, + ); + unawaited( + navigator.push( + customRoute + ? PageRouteBuilder( + opaque: true, + pageBuilder: (_, _, _) => + const Scaffold(body: Text('Custom route')), + ) + : MaterialPageRoute( + builder: (_) => const Scaffold(body: Text('Channel')), + ), + ), + ); + await tester.pumpAndSettle(); + homeCalls.clear(); + navigator.pop(); + await tester.pumpAndSettle(); + expect(position.pixels, 200); + expect( + homeCalls + .where((call) => call.method == 'scroll') + .map((call) => call.arguments), + contains(scrolledOffset), + ); + expect(homeCalls.last.method, 'prepareForReveal'); + // The replay must use the live offset, including returning at the top. + position.jumpTo(0); + await tester.pumpAndSettle(); + unawaited( + navigator.push( + customRoute + ? PageRouteBuilder( + opaque: true, + pageBuilder: (_, _, _) => + const Scaffold(body: Text('Custom route')), + ) + : MaterialPageRoute( + builder: (_) => const Scaffold(body: Text('Channel')), + ), + ), + ); + await tester.pumpAndSettle(); + homeCalls.clear(); + navigator.pop(); + await tester.pumpAndSettle(); + expect( + homeCalls + .where((call) => call.method == 'scroll') + .map((call) => call.arguments), + contains(0.0), + ); + await tester.pumpWidget(const SizedBox()); + debugDefaultTargetPlatformOverride = null; + }, + ); + } + testWidgets('scrolls Home to the top when its tab is selected again', ( tester, ) async {