feat: add iPad responsive layout support (#2433)

This commit is contained in:
Jason Weingardt
2026-05-29 20:10:47 -04:00
committed by GitHub
parent b581f64daf
commit 191183882a
7 changed files with 133 additions and 6 deletions
+2 -2
View File
@@ -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": {
+9 -1
View File
@@ -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)
+16 -2
View File
@@ -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<HostProfile[]>([])
const [actionTarget, setActionTarget] = useState<HostProfile | null>(null)
const [renameTarget, setRenameTarget] = useState<HostProfile | null>(null)
@@ -676,7 +680,13 @@ export default function HomeScreen() {
{hosts.length === 0 ? (
/* ─── Empty state: onboarding ─── */
<View style={[styles.emptyContainer, { paddingBottom: insets.bottom }]}>
<View
style={[
styles.emptyContainer,
{ paddingBottom: insets.bottom },
isWideLayout && { maxWidth: contentMaxWidth, width: '100%', alignSelf: 'center' }
]}
>
<View style={styles.emptyHero}>
<Text style={styles.emptyTitle}>Connect your desktop</Text>
<Text style={styles.emptyBody}>
@@ -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={
<View>
<View style={styles.hero}>
+11 -1
View File
@@ -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({
<Pressable style={StyleSheet.absoluteFill} onPress={dismiss} />
</Animated.View>
<View style={styles.anchor} pointerEvents="box-none">
<View style={[styles.anchor, isWideLayout && styles.anchorWide]} pointerEvents="box-none">
<Animated.View
style={[
styles.drawer,
{
width: '100%',
maxWidth: isWideLayout ? modalMaxWidth : undefined,
maxHeight: screenHeight - insets.top - spacing.lg,
paddingBottom: insets.bottom + spacing.lg
},
@@ -340,6 +347,9 @@ const styles = StyleSheet.create({
flex: 1,
justifyContent: 'flex-end'
},
anchorWide: {
alignItems: 'center'
},
drawer: {
backgroundColor: colors.bgBase,
borderTopLeftRadius: 16,
@@ -0,0 +1,39 @@
import { describe, expect, it } from 'vitest'
import { getResponsiveLayoutMetrics } from './responsive-layout-metrics'
import { spacing } from '../theme/mobile-theme'
describe('responsive layout metrics', () => {
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
})
})
})
@@ -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
}
}
+12
View File
@@ -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)
}