mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
fix(mobile): size onboarding slides to the modal sheet on iPad (#26919)
* fix(mobile): size onboarding slides from the modal viewport * fix(mobile): ignore zero-width onboarding viewport layouts * test(mobile): model slide progress in onboarding width test
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useMemo, useRef, useState } from 'react'
|
||||
import { Animated, BackHandler, Text, useWindowDimensions, View } from 'react-native'
|
||||
import { Animated, BackHandler, Text, View, type LayoutChangeEvent } from 'react-native'
|
||||
import { useFocusEffect, useLocalSearchParams, useRouter } from 'expo-router'
|
||||
import { SafeAreaView } from 'react-native-safe-area-context'
|
||||
import { OrcaLogo } from '../src/components/OrcaLogo'
|
||||
@@ -46,7 +46,9 @@ function MobileOnboardingFlow({
|
||||
}) {
|
||||
const router = useRouter()
|
||||
const steps = useMemo(() => parseMobileOnboardingSteps(rawSteps), [rawSteps])
|
||||
const { width } = useWindowDimensions()
|
||||
// Why: an iPad modal is a centred sheet narrower than the window, so slides must
|
||||
// follow the viewport; null holds the pager back until the first measurement.
|
||||
const [width, setWidth] = useState<number | null>(null)
|
||||
const [activeIndex, setActiveIndex] = useState(0)
|
||||
const [busyChoice, setBusyChoice] = useState<MobileOnboardingBusyChoice>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
@@ -132,7 +134,17 @@ function MobileOnboardingFlow({
|
||||
[advanceOrContinue]
|
||||
)
|
||||
|
||||
const translateX = useMemo(() => Animated.multiply(slideProgress, -width), [slideProgress, width])
|
||||
const translateX = useMemo(
|
||||
() => Animated.multiply(slideProgress, -(width ?? 0)),
|
||||
[slideProgress, width]
|
||||
)
|
||||
const onViewportLayout = useCallback((event: LayoutChangeEvent) => {
|
||||
const measured = event.nativeEvent.layout.width
|
||||
// Why: a transient zero-width pass must not collapse the slides to nothing.
|
||||
if (measured > 0) {
|
||||
setWidth(measured)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<SafeAreaView style={styles.container}>
|
||||
@@ -157,26 +169,28 @@ function MobileOnboardingFlow({
|
||||
) : null}
|
||||
</View>
|
||||
|
||||
<View style={styles.carouselViewport}>
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.carouselTrack,
|
||||
{ width: width * steps.length, transform: [{ translateX }] }
|
||||
]}
|
||||
>
|
||||
{steps.map((step, index) => (
|
||||
<MobileOnboardingPage
|
||||
key={step}
|
||||
step={step}
|
||||
width={width}
|
||||
active={index === activeIndex}
|
||||
busyChoice={busyChoice}
|
||||
error={error}
|
||||
onSessionChoice={(view) => void chooseSessionView(view)}
|
||||
onNotificationChoice={(choice) => void chooseNotifications(choice)}
|
||||
/>
|
||||
))}
|
||||
</Animated.View>
|
||||
<View style={styles.carouselViewport} onLayout={onViewportLayout}>
|
||||
{width === null ? null : (
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.carouselTrack,
|
||||
{ width: width * steps.length, transform: [{ translateX }] }
|
||||
]}
|
||||
>
|
||||
{steps.map((step, index) => (
|
||||
<MobileOnboardingPage
|
||||
key={step}
|
||||
step={step}
|
||||
width={width}
|
||||
active={index === activeIndex}
|
||||
busyChoice={busyChoice}
|
||||
error={error}
|
||||
onSessionChoice={(view) => void chooseSessionView(view)}
|
||||
onNotificationChoice={(choice) => void chooseNotifications(choice)}
|
||||
/>
|
||||
))}
|
||||
</Animated.View>
|
||||
)}
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
)
|
||||
|
||||
@@ -117,12 +117,19 @@ async function connectedHost() {
|
||||
client.sendRequest.mockClear()
|
||||
return client
|
||||
}
|
||||
// The onboarding pager renders once its viewport reports a width.
|
||||
function layoutOnboarding() {
|
||||
renderer!.root
|
||||
.findAll((node) => node.props.onLayout !== undefined)[0]
|
||||
?.props.onLayout({ nativeEvent: { layout: { width: 390 } } })
|
||||
}
|
||||
async function choose(entry: string) {
|
||||
await act(async () => {
|
||||
renderer = create(
|
||||
createElement(entry === 'settings' ? NotificationsScreen : MobileOnboardingScreen)
|
||||
)
|
||||
})
|
||||
await act(async () => layoutOnboarding())
|
||||
await act(async () => {
|
||||
if (entry === 'settings') {
|
||||
renderer!.root.findByType('Switch').props.onValueChange(true)
|
||||
@@ -183,6 +190,7 @@ it('remembers Not now without registering and does not ask again', async () => {
|
||||
await act(async () => {
|
||||
renderer = create(createElement(MobileOnboardingScreen))
|
||||
})
|
||||
await act(async () => layoutOnboarding())
|
||||
await act(async () => {
|
||||
renderer!.root.findByType('Page').props.onNotificationChoice('skip')
|
||||
})
|
||||
|
||||
@@ -6,6 +6,7 @@ import MobileOnboardingScreen from '../../app/mobile-onboarding'
|
||||
const mocks = vi.hoisted(() => ({
|
||||
params: { hostId: 'paired-host', steps: 'session-view,notifications' },
|
||||
replace: vi.fn(),
|
||||
windowWidth: 390,
|
||||
reducedMotionEnabled: false,
|
||||
animatedTiming: vi.fn(),
|
||||
ensureNotificationPermissions: vi.fn(),
|
||||
@@ -19,9 +20,12 @@ vi.mock('react-native', () => ({
|
||||
isReduceMotionEnabled: vi.fn(() => Promise.resolve(mocks.reducedMotionEnabled))
|
||||
},
|
||||
Animated: {
|
||||
Value: class {},
|
||||
Value: class {
|
||||
constructor(public current: number) {}
|
||||
},
|
||||
View: 'AnimatedView',
|
||||
multiply: vi.fn(() => 0),
|
||||
// Lazy like a real Animated node, so translateX tracks later timing updates.
|
||||
multiply: (value: { current: number }, factor: number) => ({ value, factor }),
|
||||
timing: mocks.animatedTiming
|
||||
},
|
||||
BackHandler: {
|
||||
@@ -30,7 +34,7 @@ vi.mock('react-native', () => ({
|
||||
StyleSheet: { create: (styles: unknown) => styles },
|
||||
Text: 'Text',
|
||||
View: 'View',
|
||||
useWindowDimensions: () => ({ width: 390, height: 844 })
|
||||
useWindowDimensions: () => ({ width: mocks.windowWidth, height: 844 })
|
||||
}))
|
||||
|
||||
vi.mock('expo-router', () => ({
|
||||
@@ -58,10 +62,16 @@ describe('MobileOnboardingScreen', () => {
|
||||
beforeEach(() => {
|
||||
mocks.params = { hostId: 'paired-host', steps: 'session-view,notifications' }
|
||||
mocks.replace.mockReset()
|
||||
mocks.windowWidth = 390
|
||||
mocks.reducedMotionEnabled = false
|
||||
mocks.animatedTiming.mockReset().mockReturnValue({
|
||||
start: (callback: (result: { finished: boolean }) => void) => callback({ finished: true })
|
||||
})
|
||||
mocks.animatedTiming
|
||||
.mockReset()
|
||||
.mockImplementation((value: { current: number }, config: { toValue: number }) => ({
|
||||
start: (callback: (result: { finished: boolean }) => void) => {
|
||||
value.current = config.toValue
|
||||
callback({ finished: true })
|
||||
}
|
||||
}))
|
||||
mocks.ensureNotificationPermissions.mockReset().mockResolvedValue(true)
|
||||
mocks.saveDefaultSessionView.mockReset().mockResolvedValue(undefined)
|
||||
mocks.setRemotePushEnabled.mockReset().mockResolvedValue(undefined)
|
||||
@@ -73,7 +83,7 @@ describe('MobileOnboardingScreen', () => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
async function renderScreen() {
|
||||
async function renderScreen(viewportWidth = 390) {
|
||||
const consoleError = vi.spyOn(console, 'error').mockImplementation((...args) => {
|
||||
if (typeof args[0] !== 'string' || !args[0].includes('react-test-renderer is deprecated')) {
|
||||
throw new Error(String(args[0]))
|
||||
@@ -82,9 +92,29 @@ describe('MobileOnboardingScreen', () => {
|
||||
await act(async () => {
|
||||
renderer = create(createElement(MobileOnboardingScreen))
|
||||
})
|
||||
await layoutViewport(viewportWidth)
|
||||
consoleError.mockRestore()
|
||||
}
|
||||
|
||||
async function layoutViewport(width: number) {
|
||||
const viewport = renderer!.root.findAll((node) => node.props.onLayout !== undefined)[0]
|
||||
await act(async () => viewport?.props.onLayout({ nativeEvent: { layout: { width } } }))
|
||||
}
|
||||
|
||||
function trackStyle() {
|
||||
return renderer!.root.findByType('AnimatedView').props.style[1]
|
||||
}
|
||||
|
||||
function trackWidth(): number {
|
||||
return trackStyle().width
|
||||
}
|
||||
|
||||
function translateX(): number {
|
||||
const { value, factor } = trackStyle().transform[0].translateX
|
||||
// + 0 normalises -0 on the first slide.
|
||||
return value.current * factor + 0
|
||||
}
|
||||
|
||||
function pages() {
|
||||
return renderer!.root.findAllByType('MobileOnboardingPage')
|
||||
}
|
||||
@@ -159,6 +189,7 @@ describe('MobileOnboardingScreen', () => {
|
||||
|
||||
mocks.params = { hostId: 'paired-host', steps: 'notifications' }
|
||||
await act(async () => renderer!.update(createElement(MobileOnboardingScreen)))
|
||||
await layoutViewport(390)
|
||||
|
||||
expect(pages()).toHaveLength(1)
|
||||
expect(pages()[0].props).toMatchObject({ step: 'notifications', active: true })
|
||||
@@ -186,4 +217,30 @@ describe('MobileOnboardingScreen', () => {
|
||||
|
||||
expect(pages()[1].props).toMatchObject({ active: true, busyChoice: null })
|
||||
})
|
||||
|
||||
it('sizes slides from the modal viewport rather than the wider window', async () => {
|
||||
// iPad presents the modal as a centred sheet narrower than the window.
|
||||
mocks.windowWidth = 1024
|
||||
await renderScreen(540)
|
||||
|
||||
expect(pages().map((page) => page.props.width)).toEqual([540, 540])
|
||||
expect(trackWidth()).toBe(1080)
|
||||
expect(translateX()).toBe(0)
|
||||
|
||||
await act(async () => pages()[0].props.onSessionChoice('chat'))
|
||||
expect(translateX()).toBe(-540)
|
||||
|
||||
await layoutViewport(700)
|
||||
expect(pages().map((page) => page.props.width)).toEqual([700, 700])
|
||||
expect(trackWidth()).toBe(1400)
|
||||
expect(translateX()).toBe(-700)
|
||||
})
|
||||
|
||||
it('keeps the pager unrendered until the viewport reports a real width', async () => {
|
||||
await renderScreen(0)
|
||||
expect(pages()).toHaveLength(0)
|
||||
|
||||
await layoutViewport(540)
|
||||
expect(pages().map((page) => page.props.width)).toEqual([540, 540])
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user