diff --git a/mobile/lib/app.dart b/mobile/lib/app.dart index 301c35fd30..7a2e62ce9f 100644 --- a/mobile/lib/app.dart +++ b/mobile/lib/app.dart @@ -48,6 +48,7 @@ import 'shared/relay/relay.dart'; import 'shared/theme/theme.dart'; import 'shared/widgets/buzz_loading_indicator.dart'; import 'shared/widgets/buzz_toast_host.dart'; +import 'shared/widgets/covered_route_keyboard_insets.dart'; const _starterChannelNamespace = '3ce33bea-8f09-5f1b-9c85-8a7d2659e6b0'; @@ -456,19 +457,21 @@ class App extends HookConsumerWidget { data: (state) => switch (state.status) { AuthStatus.authenticated => HostedAppGate( authenticated: true, - child: HomePage( - onAgents: showAgents, - defaultAgentBuilder: (context) => DefaultAgentLauncher( - onChooseAgent: () => showAgents(context, createOnOpen: true), + child: CoveredRouteKeyboardInsets( + child: HomePage( + onAgents: showAgents, + defaultAgentBuilder: (context) => DefaultAgentLauncher( + onChooseAgent: () => showAgents(context, createOnOpen: true), + ), + mePageBuilder: (_, chat, workspace) => MePage( + sliver: true, + newChatAction: chat, + newWorkspaceAction: workspace, + ), + settingsPageBuilder: _buildSettingsPage, + communityInvitePageBuilder: (_) => const CommunityInvitePage(), + communityAppearancePageBuilder: (_) => const ThemePickerPage(), ), - mePageBuilder: (_, chat, workspace) => MePage( - sliver: true, - newChatAction: chat, - newWorkspaceAction: workspace, - ), - settingsPageBuilder: _buildSettingsPage, - communityInvitePageBuilder: (_) => const CommunityInvitePage(), - communityAppearancePageBuilder: (_) => const ThemePickerPage(), ), ), _ => const HostedAppGate( diff --git a/mobile/lib/features/channels/channel_actions_sheet.dart b/mobile/lib/features/channels/channel_actions_sheet.dart index 3f8cb24548..de5d0c482b 100644 --- a/mobile/lib/features/channels/channel_actions_sheet.dart +++ b/mobile/lib/features/channels/channel_actions_sheet.dart @@ -36,6 +36,7 @@ import 'members_sheet.dart'; import '../../shared/profile/user_cache_provider.dart'; import '../../shared/read_state/read_state_provider.dart'; import '../../shared/read_state/read_state_time.dart'; +import '../../shared/widgets/covered_route_keyboard_insets.dart'; part 'channel_details_page.dart'; diff --git a/mobile/lib/features/channels/channel_detail_page.dart b/mobile/lib/features/channels/channel_detail_page.dart index 2ecb2d07f9..a68936e78b 100644 --- a/mobile/lib/features/channels/channel_detail_page.dart +++ b/mobile/lib/features/channels/channel_detail_page.dart @@ -43,6 +43,7 @@ import '../../shared/widgets/avatar_image.dart'; import '../../shared/widgets/buzz_loading_indicator.dart'; import '../../shared/widgets/bouncing_dots_indicator.dart'; import '../../shared/widgets/concentric_sheet_surface.dart'; +import '../../shared/widgets/covered_route_keyboard_insets.dart'; import '../../shared/widgets/frosted_app_bar.dart'; import '../../shared/widgets/frosted_scaffold.dart'; import '../../shared/widgets/flapping_bee.dart'; diff --git a/mobile/lib/features/channels/channel_detail_page/placement_route.dart b/mobile/lib/features/channels/channel_detail_page/placement_route.dart index 84d85f8dfd..3efa58ff9d 100644 --- a/mobile/lib/features/channels/channel_detail_page/placement_route.dart +++ b/mobile/lib/features/channels/channel_detail_page/placement_route.dart @@ -46,21 +46,23 @@ class ChannelDetailPage extends ConsumerWidget { body: const Center(child: BuzzLoadingIndicator()), ); } - return _ResolvedChannelDetailPage( - key: ValueKey((channel.id, placed)), - channel: current, - personalChat: placed, - welcomeAgent: current.isDm - ? ref.watch(bestieWelcomeProvider(channel.id)) - : null, - placementKnown: - placed || - groups != null || - placement != ChannelRoutePlacement.unresolved, - initialAssistant: initialAssistant, - initialMessageId: initialMessageId, - initialThreadRootId: initialThreadRootId, - initialThreadRouteBehavior: initialThreadRouteBehavior, + return CoveredRouteKeyboardInsets( + child: _ResolvedChannelDetailPage( + key: ValueKey((channel.id, placed)), + channel: current, + personalChat: placed, + welcomeAgent: current.isDm + ? ref.watch(bestieWelcomeProvider(channel.id)) + : null, + placementKnown: + placed || + groups != null || + placement != ChannelRoutePlacement.unresolved, + initialAssistant: initialAssistant, + initialMessageId: initialMessageId, + initialThreadRootId: initialThreadRootId, + initialThreadRouteBehavior: initialThreadRouteBehavior, + ), ); } } diff --git a/mobile/lib/features/channels/channel_details_page.dart b/mobile/lib/features/channels/channel_details_page.dart index 3b87018123..d09cd2cfc4 100644 --- a/mobile/lib/features/channels/channel_details_page.dart +++ b/mobile/lib/features/channels/channel_details_page.dart @@ -13,11 +13,13 @@ Future showChannelDetailsPage({ String? sectionId, }) => Navigator.of(context).push( MaterialPageRoute( - builder: (_) => ChannelDetailsPage( - channel: channel, - currentPubkey: currentPubkey, - onMemberTap: onMemberTap, - sectionId: sectionId, + builder: (_) => CoveredRouteKeyboardInsets( + child: ChannelDetailsPage( + channel: channel, + currentPubkey: currentPubkey, + onMemberTap: onMemberTap, + sectionId: sectionId, + ), ), ), ); diff --git a/mobile/lib/features/home/home_page.dart b/mobile/lib/features/home/home_page.dart index a9db0198a9..265880632b 100644 --- a/mobile/lib/features/home/home_page.dart +++ b/mobile/lib/features/home/home_page.dart @@ -18,6 +18,7 @@ import '../channels/channels_page.dart'; import '../search/search_page.dart'; import 'messages_filters.dart'; import 'messages_navigation_bar.dart'; +import '../../shared/widgets/covered_route_keyboard_insets.dart'; class HomePage extends HookConsumerWidget { const HomePage({ @@ -67,7 +68,7 @@ class HomePage extends HookConsumerWidget { final reducedMotion = MediaQuery.disableAnimationsOf(context); void openSearch() => Navigator.of(context).push( BottomUpPageRoute( - builder: (_) => const SearchPage(), + builder: (_) => const CoveredRouteKeyboardInsets(child: SearchPage()), reducedMotion: reducedMotion, ), ); diff --git a/mobile/lib/shared/widgets/covered_route_keyboard_insets.dart b/mobile/lib/shared/widgets/covered_route_keyboard_insets.dart new file mode 100644 index 0000000000..c73461ca16 --- /dev/null +++ b/mobile/lib/shared/widgets/covered_route_keyboard_insets.dart @@ -0,0 +1,92 @@ +import 'package:flutter/widgets.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; + +/// Holds [child]'s keyboard insets steady while its route is covered. +/// +/// A keyboard raised by a route above (a sheet with a search field, or a +/// native picker presented over the app) still changes the app's +/// [MediaQuery] on every animation frame. Without this, a covered page that +/// reads `MediaQuery.of`, `paddingOf` or `viewInsetsOf` (directly or through +/// a [Scaffold]) rebuilds and relays out its whole content for keyboard +/// motion it cannot show. While the route is not current, [child] keeps the +/// view insets and padding it last had as the current route, except that it +/// still follows the keyboard down: when a covering route takes focus and the +/// page's own keyboard closes, the page lowers behind the scrim instead of +/// jumping when it is uncovered. A size change (rotation) drops the held +/// values, so safe-area padding stays right for the new orientation. Every +/// other [MediaQueryData] field passes through. +/// +/// When the route becomes current again with the keyboard still above the +/// held value (the covering route's keyboard has not finished closing), the +/// page keeps the held values instead of jumping up to the live keyboard and +/// riding it back down. It releases to the live values once the keyboard +/// comes down to the held value, or as soon as focus is inside this route +/// (the page raised its own keyboard and has to follow it). +class CoveredRouteKeyboardInsets extends HookWidget { + const CoveredRouteKeyboardInsets({super.key, required this.child}); + + final Widget child; + + @override + Widget build(BuildContext context) { + final data = MediaQuery.of(context); + final current = ModalRoute.isCurrentOf(context) ?? true; + // Focus moving into the route must release the held values even when the + // keyboard height is not changing at that moment. + useListenable(FocusManager.instance); + final focusInside = _hasFocusInside(FocusScope.of(context)); + // The insets and padding from the last build as the current route, or + // the smallest keyboard seen since it was covered at the current size. + final held = useRef<(EdgeInsets, EdgeInsets, Size)?>(null); + // Whether the last build was as a covered route. + final covered = useRef(false); + // Whether the route is current again but still shows the held values. + final settling = useRef(false); + final heldValue = held.value; + final sizeChanged = heldValue != null && data.size != heldValue.$3; + if (!current) { + covered.value = true; + settling.value = false; + if (heldValue == null || + sizeChanged || + data.viewInsets.bottom <= heldValue.$1.bottom) { + held.value = (data.viewInsets, data.padding, data.size); + } + } else { + if (covered.value && + heldValue != null && + !sizeChanged && + !focusInside && + data.viewInsets.bottom > heldValue.$1.bottom) { + settling.value = true; + } + covered.value = false; + if (settling.value && + (sizeChanged || + focusInside || + data.viewInsets.bottom <= heldValue!.$1.bottom)) { + settling.value = false; + } + if (!settling.value) { + held.value = (data.viewInsets, data.padding, data.size); + } + } + final (viewInsets, padding, _) = held.value!; + // Always the same widget shape, so covering never remounts the page. + return MediaQuery( + data: current && !settling.value + ? data + : data.copyWith(viewInsets: viewInsets, padding: padding), + child: child, + ); + } + + /// Whether primary focus is on a descendant of [scope], not [scope] itself + /// (which holds focus when nothing inside the route has it). + static bool _hasFocusInside(FocusScopeNode scope) { + final primary = FocusManager.instance.primaryFocus; + return primary != null && + primary != scope && + primary.ancestors.contains(scope); + } +} diff --git a/mobile/test/shared/widgets/covered_route_keyboard_insets_test.dart b/mobile/test/shared/widgets/covered_route_keyboard_insets_test.dart new file mode 100644 index 0000000000..3dbb70dacb --- /dev/null +++ b/mobile/test/shared/widgets/covered_route_keyboard_insets_test.dart @@ -0,0 +1,391 @@ +import 'package:buzz/shared/widgets/covered_route_keyboard_insets.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// Counts builds and records the insets the page saw last. +class _Probe extends StatelessWidget { + const _Probe(this.builds, this.seen); + + final List builds; + final List seen; + + @override + Widget build(BuildContext context) { + builds[0]++; + seen.add(MediaQuery.viewInsetsOf(context)); + MediaQuery.paddingOf(context); + return const SizedBox.expand(); + } +} + +void main() { + testWidgets('a covered page ignores keyboard motion and gets the live ' + 'insets back when it is current again', (tester) async { + final builds = [0]; + final seen = []; + final navigator = GlobalKey(); + var insets = EdgeInsets.zero; + late StateSetter setOuter; + // Built once, so only the inherited MediaQuery changes below. + final app = MaterialApp( + navigatorKey: navigator, + home: CoveredRouteKeyboardInsets(child: _Probe(builds, seen)), + ); + + await tester.pumpWidget( + StatefulBuilder( + builder: (context, setState) { + setOuter = setState; + return MediaQuery( + data: MediaQueryData( + size: const Size(400, 800), + viewInsets: insets, + padding: EdgeInsets.only(bottom: insets.bottom > 0 ? 0 : 34), + ), + child: app, + ); + }, + ), + ); + expect(seen.last, EdgeInsets.zero); + + // A transparent route above, as the native member picker uses. + navigator.currentState!.push( + PageRouteBuilder( + opaque: false, + transitionDuration: Duration.zero, + pageBuilder: (_, _, _) => const SizedBox(), + ), + ); + await tester.pumpAndSettle(); + final coveredBuilds = builds[0]; + + // Keyboard frames for the covering route's field. + for (final bottom in [80.0, 160.0, 240.0, 300.0]) { + setOuter(() => insets = EdgeInsets.only(bottom: bottom)); + await tester.pump(); + } + expect(builds[0], coveredBuilds); + expect(seen.last, EdgeInsets.zero); + + // Uncovered while the covering route's keyboard is still up: the page + // stays where it was instead of jumping up, then takes live values once + // the keyboard is gone. + navigator.currentState!.pop(); + await tester.pumpAndSettle(); + expect(seen.last, EdgeInsets.zero); + for (final bottom in [200.0, 100.0]) { + setOuter(() => insets = EdgeInsets.only(bottom: bottom)); + await tester.pump(); + expect(seen.last, EdgeInsets.zero); + } + setOuter(() => insets = EdgeInsets.zero); + await tester.pump(); + expect(seen.last, EdgeInsets.zero); + setOuter(() => insets = const EdgeInsets.only(bottom: 120)); + await tester.pump(); + expect(seen.last, const EdgeInsets.only(bottom: 120)); + }); + + testWidgets('an uncovered page never rises above the live keyboard and ' + 'follows it once it reaches the held value', (tester) async { + final builds = [0]; + final seen = []; + final navigator = GlobalKey(); + var insets = const EdgeInsets.only(bottom: 300); + late StateSetter setOuter; + final app = MaterialApp( + navigatorKey: navigator, + home: CoveredRouteKeyboardInsets(child: _Probe(builds, seen)), + ); + + await tester.pumpWidget( + StatefulBuilder( + builder: (context, setState) { + setOuter = setState; + return MediaQuery( + data: MediaQueryData( + size: const Size(400, 800), + viewInsets: insets, + ), + child: app, + ); + }, + ), + ); + + navigator.currentState!.push( + PageRouteBuilder( + opaque: false, + transitionDuration: Duration.zero, + pageBuilder: (_, _, _) => const SizedBox(), + ), + ); + await tester.pumpAndSettle(); + // The page's keyboard closes to 100 behind the covering route, whose own + // field then raises the keyboard back to 300. + for (final bottom in [200.0, 100.0, 200.0, 300.0]) { + setOuter(() => insets = EdgeInsets.only(bottom: bottom)); + await tester.pump(); + } + expect(seen.last, const EdgeInsets.only(bottom: 100)); + + navigator.currentState!.pop(); + await tester.pumpAndSettle(); + expect(seen.last, const EdgeInsets.only(bottom: 100)); + for (final bottom in [250.0, 150.0, 100.0]) { + setOuter(() => insets = EdgeInsets.only(bottom: bottom)); + await tester.pump(); + expect(seen.last, const EdgeInsets.only(bottom: 100)); + } + setOuter(() => insets = const EdgeInsets.only(bottom: 40)); + await tester.pump(); + expect(seen.last, const EdgeInsets.only(bottom: 40)); + }); + + testWidgets('an uncovered page follows the keyboard its own field raises', ( + tester, + ) async { + final builds = [0]; + final seen = []; + final navigator = GlobalKey(); + final focusNode = FocusNode(); + addTearDown(focusNode.dispose); + var insets = EdgeInsets.zero; + late StateSetter setOuter; + final app = MaterialApp( + navigatorKey: navigator, + home: CoveredRouteKeyboardInsets( + child: Scaffold( + resizeToAvoidBottomInset: false, + body: Column( + children: [ + Expanded(child: _Probe(builds, seen)), + TextField(focusNode: focusNode), + ], + ), + ), + ), + ); + + await tester.pumpWidget( + StatefulBuilder( + builder: (context, setState) { + setOuter = setState; + return MediaQuery( + data: MediaQueryData( + size: const Size(400, 800), + viewInsets: insets, + ), + child: app, + ); + }, + ), + ); + + navigator.currentState!.push( + PageRouteBuilder( + opaque: false, + transitionDuration: Duration.zero, + pageBuilder: (_, _, _) => const SizedBox(), + ), + ); + await tester.pumpAndSettle(); + setOuter(() => insets = const EdgeInsets.only(bottom: 300)); + await tester.pump(); + navigator.currentState!.pop(); + await tester.pumpAndSettle(); + expect(seen.last, EdgeInsets.zero); + + // The page's own field takes focus while the keyboard is still up, so the + // page has to follow the keyboard rather than hold below it. + focusNode.requestFocus(); + await tester.pump(); + await tester.pump(); + expect(focusNode.hasPrimaryFocus, isTrue); + expect(seen.last, const EdgeInsets.only(bottom: 300)); + setOuter(() => insets = const EdgeInsets.only(bottom: 280)); + await tester.pump(); + expect(seen.last, const EdgeInsets.only(bottom: 280)); + }); + + testWidgets('a covered page follows its own keyboard closing', ( + tester, + ) async { + final builds = [0]; + final seen = []; + final navigator = GlobalKey(); + var insets = const EdgeInsets.only(bottom: 300); + late StateSetter setOuter; + final app = MaterialApp( + navigatorKey: navigator, + home: CoveredRouteKeyboardInsets(child: _Probe(builds, seen)), + ); + + await tester.pumpWidget( + StatefulBuilder( + builder: (context, setState) { + setOuter = setState; + return MediaQuery( + data: MediaQueryData( + size: const Size(400, 800), + viewInsets: insets, + padding: EdgeInsets.only(bottom: insets.bottom > 0 ? 0 : 34), + ), + child: app, + ); + }, + ), + ); + expect(seen.last, const EdgeInsets.only(bottom: 300)); + + // A sheet takes focus, so the page's keyboard closes behind it. + navigator.currentState!.push( + PageRouteBuilder( + opaque: false, + transitionDuration: Duration.zero, + pageBuilder: (_, _, _) => const SizedBox(), + ), + ); + await tester.pumpAndSettle(); + for (final bottom in [200.0, 100.0, 0.0]) { + setOuter(() => insets = EdgeInsets.only(bottom: bottom)); + await tester.pump(); + expect(seen.last, EdgeInsets.only(bottom: bottom)); + } + + // A keyboard the covering route raises is still ignored. + final coveredBuilds = builds[0]; + for (final bottom in [80.0, 160.0, 300.0]) { + setOuter(() => insets = EdgeInsets.only(bottom: bottom)); + await tester.pump(); + } + expect(builds[0], coveredBuilds); + expect(seen.last, EdgeInsets.zero); + }); + + testWidgets('a covered page takes the new padding when rotated', ( + tester, + ) async { + EdgeInsets? padding; + final navigator = GlobalKey(); + var landscape = false; + var keyboard = 0.0; + late StateSetter setOuter; + final app = MaterialApp( + navigatorKey: navigator, + home: CoveredRouteKeyboardInsets( + child: Builder( + builder: (context) { + padding = MediaQuery.paddingOf(context); + return const SizedBox.expand(); + }, + ), + ), + ); + + await tester.pumpWidget( + StatefulBuilder( + builder: (context, setState) { + setOuter = setState; + return MediaQuery( + data: MediaQueryData( + size: landscape ? const Size(800, 400) : const Size(400, 800), + padding: landscape + ? const EdgeInsets.symmetric(horizontal: 44) + : const EdgeInsets.only(top: 44, bottom: 34), + viewInsets: EdgeInsets.only(bottom: keyboard), + ), + child: app, + ); + }, + ), + ); + expect(padding, const EdgeInsets.only(top: 44, bottom: 34)); + + navigator.currentState!.push( + PageRouteBuilder( + opaque: false, + transitionDuration: Duration.zero, + pageBuilder: (_, _, _) => const SizedBox(), + ), + ); + await tester.pumpAndSettle(); + + // The covering route raises its own keyboard, so only the size change can + // release the held portrait padding. + setOuter(() => keyboard = 300); + await tester.pump(); + expect(padding, const EdgeInsets.only(top: 44, bottom: 34)); + + setOuter(() => landscape = true); + await tester.pump(); + expect(padding, const EdgeInsets.symmetric(horizontal: 44)); + }); + + testWidgets('a current page follows the keyboard', (tester) async { + final builds = [0]; + final seen = []; + await tester.pumpWidget( + MediaQuery( + data: const MediaQueryData(size: Size(400, 800)), + child: MaterialApp( + home: CoveredRouteKeyboardInsets(child: _Probe(builds, seen)), + ), + ), + ); + await tester.pumpWidget( + MediaQuery( + data: const MediaQueryData( + size: Size(400, 800), + viewInsets: EdgeInsets.only(bottom: 300), + ), + child: MaterialApp( + home: CoveredRouteKeyboardInsets(child: _Probe(builds, seen)), + ), + ), + ); + expect(seen.last, const EdgeInsets.only(bottom: 300)); + }); + + testWidgets('covering the page keeps its state', (tester) async { + final navigator = GlobalKey(); + final mounts = [0]; + await tester.pumpWidget( + MaterialApp( + navigatorKey: navigator, + home: CoveredRouteKeyboardInsets(child: _MountCounter(mounts)), + ), + ); + expect(mounts.single, 1); + + navigator.currentState!.push( + PageRouteBuilder( + opaque: false, + transitionDuration: Duration.zero, + pageBuilder: (_, _, _) => const SizedBox(), + ), + ); + await tester.pumpAndSettle(); + navigator.currentState!.pop(); + await tester.pumpAndSettle(); + + expect(mounts.single, 1); + }); +} + +class _MountCounter extends HookWidget { + const _MountCounter(this.mounts); + + final List mounts; + + @override + Widget build(BuildContext context) { + useEffect(() { + mounts[0]++; + return null; + }, const []); + return const SizedBox(); + } +}