diff --git a/docs/pr/6084/after-android-home-fab.png b/docs/pr/6084/after-android-home-fab.png new file mode 100644 index 00000000000..2f22acf2e65 Binary files /dev/null and b/docs/pr/6084/after-android-home-fab.png differ diff --git a/docs/pr/6084/before-android-home-fab.png b/docs/pr/6084/before-android-home-fab.png new file mode 100644 index 00000000000..4cafff04b5e Binary files /dev/null and b/docs/pr/6084/before-android-home-fab.png differ diff --git a/mobile/lib/features/channels/channels_page/quick_actions_launcher.dart b/mobile/lib/features/channels/channels_page/quick_actions_launcher.dart index b298c192d0a..1d38f38530a 100644 --- a/mobile/lib/features/channels/channels_page/quick_actions_launcher.dart +++ b/mobile/lib/features/channels/channels_page/quick_actions_launcher.dart @@ -5,6 +5,40 @@ const _kQuickActionsTabMotionCurve = Cubic(0.77, 0, 0.175, 1); const _kQuickActionsHiddenOverlap = Grid.half; const _kQuickActionsHiddenScale = 0.8; +/// Keeps the closed quick-action button beside the tab bar without overlapping +/// the centered pill on compact phone widths. +double _effectiveQuickActionsRightInset({ + required double screenWidth, + required double navigationBarWidth, + required double rightInset, +}) { + final tabBarLeft = (screenWidth - navigationBarWidth) / 2; + final defaultFabRight = screenWidth - rightInset; + final defaultFabLeft = defaultFabRight - _kMorphClosedSize; + final tabBarRight = tabBarLeft + navigationBarWidth; + final overlapsTabBar = + defaultFabRight > tabBarLeft && defaultFabLeft < tabBarRight; + if (!overlapsTabBar) { + return rightInset; + } + + final clearanceGap = Grid.half; + final shiftedFabRight = tabBarLeft - clearanceGap; + final shiftedRightInset = screenWidth - shiftedFabRight; + final shiftedFabLeft = shiftedFabRight - _kMorphClosedSize; + if (shiftedFabLeft >= 0) { + return shiftedRightInset; + } + + final flushRightInset = screenWidth - tabBarLeft; + final flushFabLeft = tabBarLeft - _kMorphClosedSize; + if (flushFabLeft >= 0) { + return flushRightInset; + } + + return (screenWidth - _kMorphClosedSize).clamp(rightInset, double.infinity); +} + /// Places the channel quick-actions button beside mobile navigation. /// /// The button remains available only on Home and moves behind the navigation @@ -55,8 +89,13 @@ class ChannelQuickActionsLauncher extends HookConsumerWidget { ((navigationBarHeight - _kMorphClosedSize) / 2); final effectiveOpen = visible && quickActionsOpen.value; final screenWidth = MediaQuery.sizeOf(context).width; + final effectiveRightInset = _effectiveQuickActionsRightInset( + screenWidth: screenWidth, + navigationBarWidth: navigationBarWidth, + rightInset: rightInset, + ); final navigationBarRight = (screenWidth + navigationBarWidth) / 2; - final launcherRight = screenWidth - rightInset; + final launcherRight = screenWidth - effectiveRightInset; final hiddenHorizontalOffset = navigationBarRight - launcherRight - _kQuickActionsHiddenOverlap; @@ -141,7 +180,7 @@ class ChannelQuickActionsLauncher extends HookConsumerWidget { ? Duration.zero : _kQuickActionsTabMotionDuration, curve: _kQuickActionsTabMotionCurve, - right: rightInset, + right: effectiveRightInset, bottom: closedBottomInset + (effectiveOpen ? openLift : 0), child: IgnorePointer( ignoring: !visible, @@ -171,7 +210,7 @@ class ChannelQuickActionsLauncher extends HookConsumerWidget { ), child: _MorphingQuickActionsButton( open: effectiveOpen, - openEdgeOffset: rightInset - Grid.gutter, + openEdgeOffset: effectiveRightInset - Grid.gutter, onToggle: () { unawaited(HapticFeedback.lightImpact()); quickActionsOpen.value = !quickActionsOpen.value; diff --git a/mobile/test/features/home/home_page_test.dart b/mobile/test/features/home/home_page_test.dart index fc0aaa966b7..c1ebafb284d 100644 --- a/mobile/test/features/home/home_page_test.dart +++ b/mobile/test/features/home/home_page_test.dart @@ -13,6 +13,7 @@ void main() { int unreadInboxCount = 0, bool disableAnimations = false, Gradient? topSectionGradient, + double bottomViewPadding = 0, }) async { SharedPreferences.setMockInitialValues({}); final prefs = await SharedPreferences.getInstance(); @@ -21,9 +22,11 @@ void main() { child: MaterialApp( theme: AppTheme.light(topSectionGradient: topSectionGradient), builder: (context, child) => MediaQuery( - data: MediaQuery.of( - context, - ).copyWith(disableAnimations: disableAnimations), + data: MediaQuery.of(context).copyWith( + disableAnimations: disableAnimations, + viewPadding: EdgeInsets.only(bottom: bottomViewPadding), + padding: EdgeInsets.only(bottom: bottomViewPadding), + ), child: child!, ), home: HomePage( @@ -34,6 +37,69 @@ void main() { ); } + Future pumpHomeOnPhoneSurface( + WidgetTester tester, { + required Size surfaceSize, + required double bottomViewPadding, + }) async { + tester.view.devicePixelRatio = 1; + tester.view.physicalSize = surfaceSize; + tester.view.viewPadding = FakeViewPadding(bottom: bottomViewPadding); + addTearDown(tester.view.reset); + await tester.pumpWidget( + await buildHome(bottomViewPadding: bottomViewPadding), + ); + await tester.pumpAndSettle(); + } + + void expectQuickActionClearOfHomeTab({required WidgetTester tester}) { + final surfaceSize = tester.view.physicalSize / tester.view.devicePixelRatio; + final quickAction = find.byTooltip('Create or start conversation'); + expect(quickAction, findsOneWidget); + + final quickActionRect = tester.getRect( + find.byKey(const Key('channel-quick-actions-motion')), + ); + expect(quickActionRect.left, greaterThanOrEqualTo(0)); + expect(quickActionRect.top, greaterThanOrEqualTo(0)); + expect(quickActionRect.right, lessThanOrEqualTo(surfaceSize.width)); + expect(quickActionRect.bottom, lessThanOrEqualTo(surfaceSize.height)); + + final homeDestinationRect = tester.getRect(find.bySemanticsLabel('Home')); + expect(quickActionRect.overlaps(homeDestinationRect), isFalse); + } + + group('phone quick action layout', () { + const phoneSurfaces = [Size(360, 800), Size(412, 915)]; + const bottomInsets = [0.0, 48.0]; + + for (final surfaceSize in phoneSurfaces) { + for (final bottomInset in bottomInsets) { + testWidgets( + 'keeps the quick action on-screen on ${surfaceSize.width.toInt()}x' + '${surfaceSize.height.toInt()} with bottom inset $bottomInset', + (tester) async { + await pumpHomeOnPhoneSurface( + tester, + surfaceSize: surfaceSize, + bottomViewPadding: bottomInset, + ); + + expectQuickActionClearOfHomeTab(tester: tester); + + await tester.tap( + find.byKey(const Key('channel-quick-actions-motion')), + ); + await tester.pumpAndSettle(); + + expect(find.text('Create channel'), findsOneWidget); + expect(find.text('New direct message'), findsOneWidget); + }, + ); + } + } + }); + testWidgets('shows icon-only navigation and an aligned quick action', ( tester, ) async {