mirror of
https://github.com/block/buzz.git
synced 2026-10-27 04:30:52 +00:00
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:
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
4
mobile/lib/shared/widgets/app_route_observer.dart
Normal file
4
mobile/lib/shared/widgets/app_route_observer.dart
Normal file
@@ -0,0 +1,4 @@
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
/// Shared route lifecycle for native navigation and recording cleanup.
|
||||
final appRouteObserver = RouteObserver<ModalRoute<dynamic>>();
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user