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 {