From 191183882a10f8066dbfc8a4cf9dd22f71ee7fdb Mon Sep 17 00:00:00 2001 From: Jason Weingardt Date: Fri, 29 May 2026 20:10:47 -0400 Subject: [PATCH] feat: add iPad responsive layout support (#2433) --- mobile/app.json | 4 +- mobile/app/h/[hostId]/index.tsx | 10 ++++- mobile/app/index.tsx | 18 +++++++- mobile/src/components/BottomDrawer.tsx | 12 ++++- .../layout/responsive-layout-metrics.test.ts | 39 ++++++++++++++++ .../src/layout/responsive-layout-metrics.ts | 44 +++++++++++++++++++ mobile/src/layout/responsive-layout.ts | 12 +++++ 7 files changed, 133 insertions(+), 6 deletions(-) create mode 100644 mobile/src/layout/responsive-layout-metrics.test.ts create mode 100644 mobile/src/layout/responsive-layout-metrics.ts create mode 100644 mobile/src/layout/responsive-layout.ts diff --git a/mobile/app.json b/mobile/app.json index 57e43ffdd01..7d77ed5341d 100644 --- a/mobile/app.json +++ b/mobile/app.json @@ -3,7 +3,7 @@ "name": "Orca", "slug": "orca-mobile", "version": "0.0.10", - "orientation": "portrait", + "orientation": "default", "icon": "./assets/icon.png", "userInterfaceStyle": "automatic", "scheme": "orca", @@ -14,7 +14,7 @@ "backgroundColor": "#111111" }, "ios": { - "supportsTablet": false, + "supportsTablet": true, "bundleIdentifier": "com.stably.orca.mobile", "buildNumber": "1", "infoPlist": { diff --git a/mobile/app/h/[hostId]/index.tsx b/mobile/app/h/[hostId]/index.tsx index 194bbcd4a04..fc4765448c6 100644 --- a/mobile/app/h/[hostId]/index.tsx +++ b/mobile/app/h/[hostId]/index.tsx @@ -54,6 +54,7 @@ import { BottomDrawer } from '../../../src/components/BottomDrawer' import { ProtocolBlockScreen } from '../../../src/components/ProtocolBlockScreen' import { getCachedWorktrees } from '../../../src/cache/worktree-cache' import { colors, radii, spacing, typography } from '../../../src/theme/mobile-theme' +import { useResponsiveLayout } from '../../../src/layout/responsive-layout' import { evaluateCompat, type CompatVerdict } from '../../../src/transport/protocol-compat' import { loadPinnedIds, @@ -302,6 +303,9 @@ export default function HostScreen() { const { hostId, action } = useLocalSearchParams<{ hostId: string; action?: string }>() const router = useRouter() const insets = useSafeAreaInsets() + // Why: cap and center the worktree list on wide/tablet canvases; on phones + // isWideLayout is false so the list stays edge-to-edge as before. + const { isWideLayout, contentMaxWidth } = useResponsiveLayout() const [initialCache] = useState(() => hostId ? (getCachedWorktrees(hostId) as Worktree[] | null) : null ) @@ -956,7 +960,11 @@ export default function HostScreen() { // Why: edge-to-edge — the list scrolls under the system nav bar // while reserving insets.bottom keeps the last worktree row reachable // above the Samsung 3-button nav / iOS home indicator. - contentContainerStyle={[styles.list, { paddingBottom: spacing.lg + insets.bottom }]} + contentContainerStyle={[ + styles.list, + { paddingBottom: spacing.lg + insets.bottom }, + isWideLayout && { maxWidth: contentMaxWidth, width: '100%', alignSelf: 'center' } + ]} renderSectionHeader={({ section }) => { if (!section.title) return null const isCollapsed = collapsedGroups.has(section.title) diff --git a/mobile/app/index.tsx b/mobile/app/index.tsx index e0d13d1cd88..3486fd441bc 100644 --- a/mobile/app/index.tsx +++ b/mobile/app/index.tsx @@ -48,6 +48,7 @@ import { normalizeVisibleTaskProviders, type TaskProvider } from '../src/tasks/mobile-task-providers' +import { useResponsiveLayout } from '../src/layout/responsive-layout' function endpointLabel(endpoint: string): string { try { @@ -270,6 +271,9 @@ function repoColor(name: string): string { export default function HomeScreen() { const router = useRouter() const insets = useSafeAreaInsets() + // Why: cap and center content on wide/tablet canvases so cards don't stretch + // edge-to-edge on iPad; on phones isWideLayout is false and layout is unchanged. + const { isWideLayout, contentMaxWidth } = useResponsiveLayout() const [hosts, setHosts] = useState([]) const [actionTarget, setActionTarget] = useState(null) const [renameTarget, setRenameTarget] = useState(null) @@ -676,7 +680,13 @@ export default function HomeScreen() { {hosts.length === 0 ? ( /* ─── Empty state: onboarding ─── */ - + Connect your desktop @@ -712,7 +722,11 @@ export default function HomeScreen() { // Why: edge-to-edge — let the list scroll under the system nav bar // but reserve insets.bottom so the last row stays reachable above // the Samsung 3-button nav / iOS home indicator. - contentContainerStyle={[styles.list, { paddingBottom: spacing.xl + insets.bottom }]} + contentContainerStyle={[ + styles.list, + { paddingBottom: spacing.xl + insets.bottom }, + isWideLayout && { maxWidth: contentMaxWidth, width: '100%', alignSelf: 'center' } + ]} ListHeaderComponent={ diff --git a/mobile/src/components/BottomDrawer.tsx b/mobile/src/components/BottomDrawer.tsx index 88b6e0e7293..e96f035c2a7 100644 --- a/mobile/src/components/BottomDrawer.tsx +++ b/mobile/src/components/BottomDrawer.tsx @@ -22,6 +22,7 @@ import Animated, { Extrapolation } from 'react-native-reanimated' import { colors, spacing } from '../theme/mobile-theme' +import { useResponsiveLayout } from '../layout/responsive-layout' const DISMISS_THRESHOLD = 80 const SPRING_CONFIG = { damping: 28, stiffness: 400 } @@ -93,6 +94,10 @@ function MountedBottomDrawer({ const contentDragCanDismiss = useSharedValue(false) const { height: screenHeight } = useWindowDimensions() const insets = useSafeAreaInsets() + // Why: on wide/tablet canvases a full-width sheet looks stretched; cap it and + // center it horizontally. Vertical bottom-anchoring (and all the drag/keyboard + // transforms below) is unchanged, so phone behavior stays identical. + const { isWideLayout, modalMaxWidth } = useResponsiveLayout() useEffect(() => { if (visible) { @@ -258,11 +263,13 @@ function MountedBottomDrawer({ - + { + it('uses capped tablet layout for iPad portrait and landscape windows', () => { + expect(getResponsiveLayoutMetrics(820, 1180)).toMatchObject({ + isLandscape: false, + isTabletLayout: true, + isWideLayout: true, + contentMaxWidth: 720, + modalMaxWidth: 480, + horizontalPadding: spacing.xl + }) + + expect(getResponsiveLayoutMetrics(1180, 820)).toMatchObject({ + isLandscape: true, + isTabletLayout: true, + isWideLayout: true + }) + }) + + it('keeps narrow iPad split windows phone-like', () => { + expect(getResponsiveLayoutMetrics(560, 1024)).toMatchObject({ + isTabletLayout: false, + isWideLayout: false, + horizontalPadding: spacing.lg + }) + }) + + it('keeps landscape phones out of wide tablet layout', () => { + expect(getResponsiveLayoutMetrics(932, 430)).toMatchObject({ + isLandscape: true, + isTabletLayout: false, + isWideLayout: false, + horizontalPadding: spacing.lg + }) + }) +}) diff --git a/mobile/src/layout/responsive-layout-metrics.ts b/mobile/src/layout/responsive-layout-metrics.ts new file mode 100644 index 00000000000..4fffbcea989 --- /dev/null +++ b/mobile/src/layout/responsive-layout-metrics.ts @@ -0,0 +1,44 @@ +import { spacing } from '../theme/mobile-theme' + +// Use actual window size so narrow iPad splits keep phone-like layouts. +const WIDE_LAYOUT_MIN_WIDTH = 700 + +// Why: width alone catches landscape phones; capped tablet layouts need room +// in both dimensions so phone rotation does not switch UI classes. +const TABLET_LAYOUT_MIN_SHORT_SIDE = 600 + +const CONTENT_MAX_WIDTH = 720 +const MODAL_MAX_WIDTH = 480 + +export type ResponsiveLayoutMetrics = { + width: number + height: number + isLandscape: boolean + /** Window is wide enough to cap and center primary content. */ + isWideLayout: boolean + /** Tablet-class canvas (both dimensions large); false in narrow splits. */ + isTabletLayout: boolean + /** Max width for primary scrollable content on wide layouts. */ + contentMaxWidth: number + /** Max width for centered sheets/dialogs on wide layouts. */ + modalMaxWidth: number + /** Recommended horizontal gutter for the current width. */ + horizontalPadding: number +} + +export function getResponsiveLayoutMetrics(width: number, height: number): ResponsiveLayoutMetrics { + const isTabletLayout = Math.min(width, height) >= TABLET_LAYOUT_MIN_SHORT_SIDE + const isWideLayout = width >= WIDE_LAYOUT_MIN_WIDTH && isTabletLayout + + return { + width, + height, + isLandscape: width > height, + isWideLayout, + isTabletLayout, + contentMaxWidth: CONTENT_MAX_WIDTH, + modalMaxWidth: MODAL_MAX_WIDTH, + // Roomier gutters once content is capped so it isn't glued to the edges. + horizontalPadding: isWideLayout ? spacing.xl : spacing.lg + } +} diff --git a/mobile/src/layout/responsive-layout.ts b/mobile/src/layout/responsive-layout.ts new file mode 100644 index 00000000000..9a93afbaa42 --- /dev/null +++ b/mobile/src/layout/responsive-layout.ts @@ -0,0 +1,12 @@ +import { useWindowDimensions } from 'react-native' +import { + getResponsiveLayoutMetrics, + type ResponsiveLayoutMetrics +} from './responsive-layout-metrics' + +export type ResponsiveLayout = ResponsiveLayoutMetrics + +export function useResponsiveLayout(): ResponsiveLayout { + const { width, height } = useWindowDimensions() + return getResponsiveLayoutMetrics(width, height) +}