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 <klopez4212@gmail.com>
This commit is contained in:
klopez4212
2026-10-14 16:37:59 -04:00
committed by GitHub
parent 231a469de5
commit a83ea69b72
5 changed files with 236 additions and 26 deletions

View File

@@ -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,

View File

@@ -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 = <double>[1, 1.5, 2, 0.5];
/// Route observer used to cancel recording when its composer is covered.
final voiceNoteRouteObserver = RouteObserver<ModalRoute<void>>();
final voiceNoteRouteObserver = appRouteObserver;
/// Returns the playback rate following [current] in the supported rate cycle.
double nextVoiceNotePlaybackRate(double current) {

View File

@@ -0,0 +1,4 @@
import 'package:flutter/widgets.dart';
/// Shared route lifecycle for native navigation and recording cleanup.
final appRouteObserver = RouteObserver<ModalRoute<dynamic>>();

View File

@@ -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<void> 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<void>(
'scroll',
(offset?.value ?? 0).clamp(0.0, collapseRange),
);
if (!active || route?.isCurrent != true) return;
await current.invokeMethod<void>('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();
}

View File

@@ -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<double>? onSettingsTransitionProgress,
ValueListenable<int>? 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 = <int, List<MethodCall>>{};
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<ScrollableState>(
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<NavigatorState>(
find.byType(Navigator).first,
);
unawaited(
navigator.push<void>(
customRoute
? PageRouteBuilder<void>(
opaque: true,
pageBuilder: (_, _, _) =>
const Scaffold(body: Text('Custom route')),
)
: MaterialPageRoute<void>(
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<void>(
customRoute
? PageRouteBuilder<void>(
opaque: true,
pageBuilder: (_, _, _) =>
const Scaffold(body: Text('Custom route')),
)
: MaterialPageRoute<void>(
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 {