mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 08:02:28 +00:00
feat: add iPad responsive layout support (#2433)
This commit is contained in:
+2
-2
@@ -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": {
|
||||
|
||||
@@ -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
@@ -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}>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user