diff --git a/mobile/app/_layout.tsx b/mobile/app/_layout.tsx index fe89a82c4..d5cbd0e0f 100644 --- a/mobile/app/_layout.tsx +++ b/mobile/app/_layout.tsx @@ -179,7 +179,7 @@ export default function RootLayout() { diff --git a/mobile/app/index.tsx b/mobile/app/index.tsx index 265e3413c..a8909c372 100644 --- a/mobile/app/index.tsx +++ b/mobile/app/index.tsx @@ -36,7 +36,10 @@ import { } from '../src/transport/client-context' import { classifyConnection } from '../src/transport/connection-health' import { subscribeToDesktopNotifications } from '../src/notifications/mobile-notifications' -import { shouldPresentNotificationOptIn } from '../src/notifications/notification-opt-in-gate' +import { + loadMobileOnboardingSteps, + mobileOnboardingDestination +} from '../src/onboarding/mobile-onboarding-plan' import type { ConnectionState, HostProfile } from '../src/transport/types' import { triggerMediumImpact } from '../src/platform/haptics' import { OrcaLogo } from '../src/components/OrcaLogo' @@ -306,7 +309,9 @@ export default function HomeScreen() { const [lastVisited, setLastVisited] = useState<{ hostId: string; worktreeId: string } | null>( null ) - const notificationOptInCheckedRef = useRef(false) + // Why: focus can fire repeatedly while an async gate is pending; one probe per + // mount avoids duplicate storage/permission reads and competing navigation. + const onboardingOptInCheckedRef = useRef(false) // Why: shared clients from the per-host store, not N independent WebSockets. See docs/mobile-shared-client-per-host.md. const hostIds = useMemo(() => hosts.map((h) => h.id), [hosts]) @@ -378,13 +383,16 @@ export default function HomeScreen() { return } setHosts(h) - if (h.length === 0 || notificationOptInCheckedRef.current) { + if (h.length === 0 || onboardingOptInCheckedRef.current) { return } - notificationOptInCheckedRef.current = true - const showNotificationOptIn = await shouldPresentNotificationOptIn() - if (!stale && showNotificationOptIn) { - router.replace('/notification-opt-in') + onboardingOptInCheckedRef.current = true + const onboardingSteps = await loadMobileOnboardingSteps() + if (stale) { + return + } + if (onboardingSteps.length > 0) { + router.replace(mobileOnboardingDestination(onboardingSteps)) } }) void AsyncStorage.getItem('orca:last-visited-worktree').then((raw) => { diff --git a/mobile/app/mobile-onboarding.tsx b/mobile/app/mobile-onboarding.tsx new file mode 100644 index 000000000..50957a465 --- /dev/null +++ b/mobile/app/mobile-onboarding.tsx @@ -0,0 +1,215 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { + AccessibilityInfo, + Animated, + BackHandler, + Text, + useWindowDimensions, + View +} from 'react-native' +import { useFocusEffect, useLocalSearchParams, useRouter } from 'expo-router' +import { SafeAreaView } from 'react-native-safe-area-context' +import { OrcaLogo } from '../src/components/OrcaLogo' +import { ensureNotificationPermissions } from '../src/notifications/mobile-notifications' +import { + MobileOnboardingPage, + type MobileOnboardingBusyChoice, + type NotificationOnboardingChoice +} from '../src/onboarding/MobileOnboardingPage' +import { parseMobileOnboardingSteps } from '../src/onboarding/mobile-onboarding-plan' +import { mobileOnboardingStyles as styles } from '../src/onboarding/mobile-onboarding-styles' +import { + saveDefaultSessionView, + type MobileSessionView +} from '../src/storage/session-view-preferences' +import { savePushNotificationsEnabled } from '../src/storage/preferences' + +const SLIDE_DURATION_MS = 280 + +export default function MobileOnboardingScreen() { + const params = useLocalSearchParams<{ + hostId?: string | string[] + steps?: string | string[] + }>() + const hostId = firstParam(params.hostId) + const rawSteps = firstParam(params.steps) + + return ( + + ) +} + +function MobileOnboardingFlow({ + hostId, + rawSteps +}: { + hostId: string | undefined + rawSteps: string | undefined +}) { + const router = useRouter() + const steps = useMemo(() => parseMobileOnboardingSteps(rawSteps), [rawSteps]) + const { width } = useWindowDimensions() + const [activeIndex, setActiveIndex] = useState(0) + const [busyChoice, setBusyChoice] = useState(null) + const [error, setError] = useState(null) + const choiceInFlightRef = useRef(false) + const slideProgress = useRef(new Animated.Value(0)).current + const reducedMotionEnabled = useReducedMotionEnabled() + + // Why: onboarding requires an explicit choice for every planned step; disabling + // stack gestures alone still leaves Android hardware Back able to skip the flow. + useFocusEffect( + useCallback(() => { + const subscription = BackHandler.addEventListener('hardwareBackPress', () => true) + return () => subscription.remove() + }, []) + ) + + const continueToApp = useCallback(() => { + router.replace(hostId ? `/h/${hostId}` : '/') + }, [hostId, router]) + + const advanceOrContinue = useCallback(() => { + const nextIndex = activeIndex + 1 + if (nextIndex >= steps.length) { + continueToApp() + return + } + setActiveIndex(nextIndex) + setError(null) + Animated.timing(slideProgress, { + toValue: nextIndex, + // Why: the carousel should preserve continuity without overriding the + // device's reduced-motion preference. + duration: reducedMotionEnabled ? 0 : SLIDE_DURATION_MS, + useNativeDriver: true + }).start(() => { + // Why: a cancelled cosmetic transition must not leave the next decision + // permanently disabled after the previous choice was already persisted. + setBusyChoice(null) + choiceInFlightRef.current = false + }) + }, [activeIndex, continueToApp, reducedMotionEnabled, slideProgress, steps.length]) + + const chooseSessionView = useCallback( + async (view: MobileSessionView) => { + // Why: state does not disable both buttons synchronously; the ref prevents + // rapid taps from persisting conflicting choices or advancing twice. + if (choiceInFlightRef.current) { + return + } + choiceInFlightRef.current = true + setBusyChoice(view) + setError(null) + try { + await saveDefaultSessionView(view) + advanceOrContinue() + } catch { + setError('Your choice could not be saved. Try again.') + setBusyChoice(null) + choiceInFlightRef.current = false + } + }, + [advanceOrContinue] + ) + + const chooseNotifications = useCallback( + async (choice: NotificationOnboardingChoice) => { + if (choiceInFlightRef.current) { + return + } + choiceInFlightRef.current = true + setBusyChoice(choice) + setError(null) + try { + const enabled = choice === 'enable' ? await ensureNotificationPermissions() : false + await savePushNotificationsEnabled(enabled) + advanceOrContinue() + } catch { + setError('Notification settings could not be updated. Try again.') + setBusyChoice(null) + choiceInFlightRef.current = false + } + }, + [advanceOrContinue] + ) + + const translateX = useMemo(() => Animated.multiply(slideProgress, -width), [slideProgress, width]) + + return ( + + + + Orca + {steps.length > 1 ? ( + + {steps.map((step, index) => ( + + ))} + + ) : null} + + + + + {steps.map((step, index) => ( + void chooseSessionView(view)} + onNotificationChoice={(choice) => void chooseNotifications(choice)} + /> + ))} + + + + ) +} + +function firstParam(value: string | string[] | undefined): string | undefined { + return Array.isArray(value) ? value[0] : value +} + +function useReducedMotionEnabled(): boolean { + const [enabled, setEnabled] = useState(false) + + useEffect(() => { + let mounted = true + void AccessibilityInfo.isReduceMotionEnabled() + .then((nextEnabled) => { + if (mounted) { + setEnabled(nextEnabled) + } + }) + .catch(() => undefined) + const subscription = AccessibilityInfo.addEventListener('reduceMotionChanged', setEnabled) + return () => { + mounted = false + subscription.remove() + } + }, []) + + return enabled +} diff --git a/mobile/app/notification-opt-in.tsx b/mobile/app/notification-opt-in.tsx deleted file mode 100644 index a4476687e..000000000 --- a/mobile/app/notification-opt-in.tsx +++ /dev/null @@ -1,236 +0,0 @@ -import { useCallback, useState } from 'react' -import { - ActivityIndicator, - BackHandler, - Pressable, - ScrollView, - StyleSheet, - Text, - View -} from 'react-native' -import { useFocusEffect, useLocalSearchParams, useRouter } from 'expo-router' -import { SafeAreaView } from 'react-native-safe-area-context' -import { BellRing } from 'lucide-react-native' -import { OrcaLogo } from '../src/components/OrcaLogo' -import { ensureNotificationPermissions } from '../src/notifications/mobile-notifications' -import { savePushNotificationsEnabled } from '../src/storage/preferences' -import { colors, radii, spacing, typography } from '../src/theme/mobile-theme' - -export default function NotificationOptInScreen() { - const router = useRouter() - const params = useLocalSearchParams<{ hostId?: string | string[] }>() - const hostId = Array.isArray(params.hostId) ? params.hostId[0] : params.hostId - const [busyChoice, setBusyChoice] = useState<'enable' | 'skip' | null>(null) - const [error, setError] = useState(null) - - // Why: this one-time screen requires an explicit Enable or Not now choice; - // disabling back gestures alone would still leave Android hardware back open. - useFocusEffect( - useCallback(() => { - const subscription = BackHandler.addEventListener('hardwareBackPress', () => true) - return () => subscription.remove() - }, []) - ) - - const continueToApp = useCallback(() => { - router.replace(hostId ? `/h/${hostId}` : '/') - }, [hostId, router]) - - const choose = useCallback( - async (choice: 'enable' | 'skip') => { - if (busyChoice) { - return - } - setBusyChoice(choice) - setError(null) - try { - const enabled = choice === 'enable' ? await ensureNotificationPermissions() : false - await savePushNotificationsEnabled(enabled) - continueToApp() - } catch { - setError('Notification settings could not be updated. Try again.') - setBusyChoice(null) - } - }, - [busyChoice, continueToApp] - ) - - return ( - - - - - Orca - - - - - - - Notifications - Stay updated while away - - Get notified on this device when an agent needs your input or finishes a task. - - - - - {error ? ( - - {error} - - ) : null} - [ - styles.primaryButton, - pressed && styles.buttonPressed, - busyChoice !== null && styles.buttonDisabled - ]} - onPress={() => void choose('enable')} - > - {busyChoice === 'enable' ? ( - - ) : ( - Enable notifications - )} - - [ - styles.secondaryButton, - pressed && styles.buttonPressed, - busyChoice !== null && styles.buttonDisabled - ]} - onPress={() => void choose('skip')} - > - {busyChoice === 'skip' ? ( - - ) : ( - Not now - )} - - You can change this any time in Settings. - - - - ) -} - -const styles = StyleSheet.create({ - container: { - flex: 1, - backgroundColor: colors.bgBase, - paddingHorizontal: spacing.xl - }, - // Why: this decision screen cannot be dismissed with Back, so every action - // must remain reachable in landscape and with accessibility text scaling. - scrollContent: { - flexGrow: 1 - }, - brandRow: { - minHeight: 52, - flexDirection: 'row', - alignItems: 'center', - gap: spacing.sm - }, - brandName: { - color: colors.textPrimary, - fontSize: 17, - fontWeight: '700' - }, - content: { - flexGrow: 1, - alignItems: 'center', - justifyContent: 'center', - paddingVertical: spacing.xl - }, - iconSurface: { - width: 64, - height: 64, - borderRadius: radii.card, - alignItems: 'center', - justifyContent: 'center', - backgroundColor: colors.bgRaised, - marginBottom: spacing.xl - }, - eyebrow: { - color: colors.textMuted, - fontSize: 11, - fontWeight: '600', - letterSpacing: 0.55, - textTransform: 'uppercase', - marginBottom: spacing.sm - }, - title: { - maxWidth: 420, - color: colors.textPrimary, - fontSize: 26, - fontWeight: '700', - letterSpacing: -0.3, - textAlign: 'center' - }, - body: { - maxWidth: 420, - color: colors.textSecondary, - fontSize: typography.bodySize, - lineHeight: 21, - textAlign: 'center', - marginTop: spacing.md - }, - footer: { - width: '100%', - maxWidth: 420, - alignSelf: 'center', - paddingBottom: spacing.lg - }, - primaryButton: { - minHeight: 44, - alignItems: 'center', - justifyContent: 'center', - borderRadius: radii.button, - backgroundColor: colors.surfaceBright, - paddingVertical: spacing.sm - }, - primaryButtonText: { - color: colors.bgBase, - fontSize: typography.bodySize, - fontWeight: '600' - }, - secondaryButton: { - minHeight: 44, - alignItems: 'center', - justifyContent: 'center', - borderRadius: radii.button, - marginTop: spacing.xs, - paddingVertical: spacing.sm - }, - secondaryButtonText: { - color: colors.textSecondary, - fontSize: typography.bodySize, - fontWeight: '500' - }, - buttonPressed: { - opacity: 0.72 - }, - buttonDisabled: { - opacity: 0.58 - }, - footerNote: { - color: colors.textMuted, - fontSize: typography.metaSize, - lineHeight: 18, - textAlign: 'center', - marginTop: spacing.sm - }, - error: { - color: colors.statusRed, - fontSize: typography.metaSize, - lineHeight: 18, - textAlign: 'center', - marginBottom: spacing.sm - } -}) diff --git a/mobile/app/pair-confirm.tsx b/mobile/app/pair-confirm.tsx index d4505b824..0621caa4f 100644 --- a/mobile/app/pair-confirm.tsx +++ b/mobile/app/pair-confirm.tsx @@ -12,7 +12,10 @@ import type { ConnectionLogEntry } from '../src/transport/types' import { useCloseHost } from '../src/transport/client-context' import { colors, spacing, radii, typography } from '../src/theme/mobile-theme' import { ConnectionLog } from '../src/components/ConnectionLog' -import { shouldPresentNotificationOptIn } from '../src/notifications/notification-opt-in-gate' +import { + loadMobileOnboardingSteps, + mobileOnboardingDestination +} from '../src/onboarding/mobile-onboarding-plan' type Status = 'awaiting-confirm' | 'connecting' | 'error' @@ -113,15 +116,11 @@ export default function PairConfirmScreen() { // profile — the removeHost() path already refreshes on re-pair, and a // brand-new host has no cached entry so this is a no-op. closeHost(hostId) - const showNotificationOptIn = await shouldPresentNotificationOptIn() + const onboardingSteps = await loadMobileOnboardingSteps() if (!mountedRef.current) { return } - router.replace( - showNotificationOptIn - ? { pathname: '/notification-opt-in', params: { hostId } } - : `/h/${hostId}` - ) + router.replace(mobileOnboardingDestination(onboardingSteps, hostId)) } catch (err) { const timedOut = attempt.timedOut const attemptIsCurrent = activePairingAttemptRef.current === attempt diff --git a/mobile/app/pair-scan.tsx b/mobile/app/pair-scan.tsx index 6b980ee0b..892c1b359 100644 --- a/mobile/app/pair-scan.tsx +++ b/mobile/app/pair-scan.tsx @@ -22,7 +22,10 @@ import { useCloseHost } from '../src/transport/client-context' import { colors, spacing, radii, typography } from '../src/theme/mobile-theme' import { TextInputModal } from '../src/components/TextInputModal' import { ConnectionLog } from '../src/components/ConnectionLog' -import { shouldPresentNotificationOptIn } from '../src/notifications/notification-opt-in-gate' +import { + loadMobileOnboardingSteps, + mobileOnboardingDestination +} from '../src/onboarding/mobile-onboarding-plan' // Why: see pair-confirm.tsx — cap initial-pair "Connecting…" so a broken // route surfaces as a real error with the log visible instead of a @@ -157,15 +160,11 @@ export default function PairScanScreen() { // profile — the removeHost() path already refreshes on re-pair, and a // brand-new host has no cached entry so this is a no-op. closeHost(hostId) - const showNotificationOptIn = await shouldPresentNotificationOptIn() + const onboardingSteps = await loadMobileOnboardingSteps() if (!mountedRef.current) { return } - router.replace( - showNotificationOptIn - ? { pathname: '/notification-opt-in', params: { hostId } } - : `/h/${hostId}` - ) + router.replace(mobileOnboardingDestination(onboardingSteps, hostId)) } catch (err) { const timedOut = attempt.timedOut const attemptIsCurrent = activePairingAttemptRef.current === attempt diff --git a/mobile/src/onboarding/MobileOnboardingPage.test.ts b/mobile/src/onboarding/MobileOnboardingPage.test.ts new file mode 100644 index 000000000..f36831051 --- /dev/null +++ b/mobile/src/onboarding/MobileOnboardingPage.test.ts @@ -0,0 +1,103 @@ +import { createElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { MobileOnboardingPage } from './MobileOnboardingPage' + +vi.mock('react-native', async () => { + const React = await import('react') + return { + ActivityIndicator: 'ActivityIndicator', + Pressable: 'Pressable', + ScrollView: ({ children, ...props }: { children?: unknown }) => + React.createElement('ScrollView', props, children), + StyleSheet: { create: (styles: unknown) => styles }, + Text: 'Text', + View: 'View' + } +}) + +vi.mock('lucide-react-native', () => ({ + BellRing: 'BellRing', + MessageSquare: 'MessageSquare' +})) + +describe('MobileOnboardingPage', () => { + let renderer: ReactTestRenderer | null = null + + beforeEach(() => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true + }) + + afterEach(() => { + act(() => renderer?.unmount()) + renderer = null + vi.restoreAllMocks() + }) + + async function renderPage( + step: 'session-view' | 'notifications', + options: { active?: boolean; busyChoice?: 'chat' | 'enable' | null } = {} + ) { + const onSessionChoice = vi.fn() + const onNotificationChoice = vi.fn() + 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])) + } + }) + await act(async () => { + renderer = create( + createElement(MobileOnboardingPage, { + step, + width: 390, + active: options.active ?? true, + busyChoice: options.busyChoice ?? null, + error: null, + onSessionChoice, + onNotificationChoice + }) + ) + }) + consoleError.mockRestore() + return { onSessionChoice, onNotificationChoice } + } + + function button(label: string) { + return renderer!.root.find( + (node) => node.type === 'Pressable' && node.props.accessibilityLabel === label + ) + } + + it('renders the session choices and sends exactly one selected view', async () => { + const callbacks = await renderPage('session-view') + + act(() => button('Open sessions in native chat').props.onPress()) + expect(callbacks.onSessionChoice).toHaveBeenCalledWith('chat') + expect(callbacks.onNotificationChoice).not.toHaveBeenCalled() + }) + + it('renders the notification choices and sends the selected option', async () => { + const callbacks = await renderPage('notifications') + act(() => button('Skip notifications for now').props.onPress()) + + expect(callbacks.onNotificationChoice).toHaveBeenCalledWith('skip') + expect(callbacks.onSessionChoice).not.toHaveBeenCalled() + }) + + it('disables both notification choices while permission is pending', async () => { + await renderPage('notifications', { busyChoice: 'enable' }) + const enable = button('Enable agent notifications') + const secondary = button('Skip notifications for now') + + expect(enable.props.disabled).toBe(true) + expect(secondary.props.disabled).toBe(true) + }) + + it('hides an off-screen page from assistive technology', async () => { + await renderPage('notifications', { active: false }) + const scrollView = renderer!.root.findByType('ScrollView') + + expect(scrollView.props.accessibilityElementsHidden).toBe(true) + expect(scrollView.props.importantForAccessibility).toBe('no-hide-descendants') + }) +}) diff --git a/mobile/src/onboarding/MobileOnboardingPage.tsx b/mobile/src/onboarding/MobileOnboardingPage.tsx new file mode 100644 index 000000000..f7bbff0cf --- /dev/null +++ b/mobile/src/onboarding/MobileOnboardingPage.tsx @@ -0,0 +1,173 @@ +import { ActivityIndicator, Pressable, ScrollView, Text, View } from 'react-native' +import { BellRing, MessageSquare } from 'lucide-react-native' +import type { MobileOnboardingStep } from './mobile-onboarding-plan' +import { mobileOnboardingStyles as styles } from './mobile-onboarding-styles' +import type { MobileSessionView } from '../storage/session-view-preferences' +import { colors } from '../theme/mobile-theme' + +export type NotificationOnboardingChoice = 'enable' | 'skip' +export type MobileOnboardingBusyChoice = MobileSessionView | NotificationOnboardingChoice | null + +type Props = { + step: MobileOnboardingStep + width: number + active: boolean + busyChoice: MobileOnboardingBusyChoice + error: string | null + onSessionChoice: (view: MobileSessionView) => void + onNotificationChoice: (choice: NotificationOnboardingChoice) => void +} + +export function MobileOnboardingPage({ + step, + width, + active, + busyChoice, + error, + onSessionChoice, + onNotificationChoice +}: Props) { + const busy = busyChoice !== null + const isSessionView = step === 'session-view' + + return ( + + + + {isSessionView ? ( + + ) : ( + + )} + + + {isSessionView ? 'How should sessions open?' : 'Stay updated while away'} + + + {isSessionView + ? 'Choose whether supported agent sessions open in the terminal or native chat on this device. Press and hold a session tab to switch its view, or change the default later in Settings.' + : 'Get notified on this device when an agent needs your input or finishes a task.'} + + + + + {error ? ( + + {error} + + ) : null} + {isSessionView ? ( + + ) : ( + + )} + + + ) +} + +function SessionViewChoices({ + busyChoice, + disabled, + onChoice +}: { + busyChoice: MobileOnboardingBusyChoice + disabled: boolean + onChoice: (view: MobileSessionView) => void +}) { + return ( + <> + onChoice('chat')} + /> + onChoice('terminal')} + /> + + ) +} + +function NotificationChoices({ + busyChoice, + disabled, + onChoice +}: { + busyChoice: MobileOnboardingBusyChoice + disabled: boolean + onChoice: (choice: NotificationOnboardingChoice) => void +}) { + return ( + <> + onChoice('enable')} + /> + onChoice('skip')} + /> + + ) +} + +function ChoiceButton({ + label, + accessibilityLabel, + primary = false, + busy, + disabled, + onPress +}: { + label: string + accessibilityLabel?: string + primary?: boolean + busy: boolean + disabled: boolean + onPress: () => void +}) { + return ( + [ + primary ? styles.primaryButton : styles.secondaryButton, + pressed && styles.buttonPressed, + disabled && styles.buttonDisabled + ]} + onPress={onPress} + > + {busy ? ( + + ) : ( + {label} + )} + + ) +} diff --git a/mobile/src/onboarding/mobile-onboarding-plan.test.ts b/mobile/src/onboarding/mobile-onboarding-plan.test.ts new file mode 100644 index 000000000..d04396bde --- /dev/null +++ b/mobile/src/onboarding/mobile-onboarding-plan.test.ts @@ -0,0 +1,75 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { shouldPresentNotificationOptIn } from '../notifications/notification-opt-in-gate' +import { shouldPresentSessionViewOptIn } from '../session/session-view-opt-in-gate' +import { + loadMobileOnboardingSteps, + mobileOnboardingDestination, + parseMobileOnboardingSteps +} from './mobile-onboarding-plan' + +vi.mock('../notifications/notification-opt-in-gate', () => ({ + shouldPresentNotificationOptIn: vi.fn() +})) +vi.mock('../session/session-view-opt-in-gate', () => ({ + shouldPresentSessionViewOptIn: vi.fn() +})) + +describe('mobile onboarding plan', () => { + beforeEach(() => { + vi.mocked(shouldPresentNotificationOptIn).mockReset().mockResolvedValue(false) + vi.mocked(shouldPresentSessionViewOptIn).mockReset().mockResolvedValue(false) + }) + + it.each([ + [true, true, ['session-view', 'notifications']], + [true, false, ['session-view']], + [false, true, ['notifications']], + [false, false, []] + ] as const)( + 'builds the exact plan for session=%s notifications=%s', + async (showSession, showNotifications, expected) => { + vi.mocked(shouldPresentSessionViewOptIn).mockResolvedValue(showSession) + vi.mocked(shouldPresentNotificationOptIn).mockResolvedValue(showNotifications) + + await expect(loadMobileOnboardingSteps()).resolves.toEqual(expected) + expect(shouldPresentSessionViewOptIn).toHaveBeenCalledOnce() + expect(shouldPresentNotificationOptIn).toHaveBeenCalledOnce() + } + ) + + it.each([ + [[], undefined, '/'], + [[], 'paired-host', '/h/paired-host'], + [ + ['session-view', 'notifications'], + undefined, + { + pathname: '/mobile-onboarding', + params: { steps: 'session-view,notifications' } + } + ], + [ + ['notifications'], + 'paired-host', + { + pathname: '/mobile-onboarding', + params: { steps: 'notifications', hostId: 'paired-host' } + } + ] + ] as const)('maps %j with host %s to the correct destination', (steps, hostId, destination) => { + expect(mobileOnboardingDestination(steps, hostId)).toEqual(destination) + }) + + it('parses route steps in canonical order without duplicates', () => { + expect(parseMobileOnboardingSteps('notifications,unknown,session-view,notifications')).toEqual([ + 'session-view', + 'notifications' + ]) + expect(parseMobileOnboardingSteps('notifications')).toEqual(['notifications']) + }) + + it('defaults missing or invalid route state to the complete wizard', () => { + expect(parseMobileOnboardingSteps(undefined)).toEqual(['session-view', 'notifications']) + expect(parseMobileOnboardingSteps('unknown')).toEqual(['session-view', 'notifications']) + }) +}) diff --git a/mobile/src/onboarding/mobile-onboarding-plan.ts b/mobile/src/onboarding/mobile-onboarding-plan.ts new file mode 100644 index 000000000..3435034c4 --- /dev/null +++ b/mobile/src/onboarding/mobile-onboarding-plan.ts @@ -0,0 +1,51 @@ +import { shouldPresentNotificationOptIn } from '../notifications/notification-opt-in-gate' +import { shouldPresentSessionViewOptIn } from '../session/session-view-opt-in-gate' + +export const MOBILE_ONBOARDING_STEPS = ['session-view', 'notifications'] as const +export type MobileOnboardingStep = (typeof MOBILE_ONBOARDING_STEPS)[number] +export type MobileOnboardingDestination = + | '/' + | `/h/${string}` + | { + pathname: '/mobile-onboarding' + params: { steps: string; hostId?: string } + } + +/** Loads every outstanding decision in the order the wizard presents them. */ +export async function loadMobileOnboardingSteps(): Promise { + // Why: the wizard needs the complete plan for accurate progress dots; run the + // independent gates together so adding the second decision does not add latency. + const [showSessionView, showNotifications] = await Promise.all([ + shouldPresentSessionViewOptIn(), + shouldPresentNotificationOptIn() + ]) + return MOBILE_ONBOARDING_STEPS.filter( + (step) => + (step === 'session-view' && showSessionView) || + (step === 'notifications' && showNotifications) + ) +} + +/** Preserves a paired host while routing through outstanding decisions. */ +export function mobileOnboardingDestination( + steps: readonly MobileOnboardingStep[], + hostId?: string +): MobileOnboardingDestination { + if (steps.length === 0) { + return hostId ? `/h/${hostId}` : '/' + } + return { + pathname: '/mobile-onboarding', + params: { steps: steps.join(','), ...(hostId ? { hostId } : {}) } + } +} + +/** Restores the canonical order and ignores duplicate or unknown route values. */ +export function parseMobileOnboardingSteps(raw: string | undefined): MobileOnboardingStep[] { + if (!raw) { + return [...MOBILE_ONBOARDING_STEPS] + } + const requested = new Set(raw.split(',')) + const steps = MOBILE_ONBOARDING_STEPS.filter((step) => requested.has(step)) + return steps.length > 0 ? steps : [...MOBILE_ONBOARDING_STEPS] +} diff --git a/mobile/src/onboarding/mobile-onboarding-screen.test.ts b/mobile/src/onboarding/mobile-onboarding-screen.test.ts new file mode 100644 index 000000000..d571ce28a --- /dev/null +++ b/mobile/src/onboarding/mobile-onboarding-screen.test.ts @@ -0,0 +1,165 @@ +import { createElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import MobileOnboardingScreen from '../../app/mobile-onboarding' + +const mocks = vi.hoisted(() => ({ + params: { hostId: 'paired-host', steps: 'session-view,notifications' }, + replace: vi.fn(), + reducedMotionEnabled: false, + animatedTiming: vi.fn(), + ensureNotificationPermissions: vi.fn(), + saveDefaultSessionView: vi.fn(), + savePushNotificationsEnabled: vi.fn() +})) + +vi.mock('react-native', () => ({ + AccessibilityInfo: { + addEventListener: vi.fn(() => ({ remove: vi.fn() })), + isReduceMotionEnabled: vi.fn(() => Promise.resolve(mocks.reducedMotionEnabled)) + }, + Animated: { + Value: class {}, + View: 'AnimatedView', + multiply: vi.fn(() => 0), + timing: mocks.animatedTiming + }, + BackHandler: { + addEventListener: vi.fn(() => ({ remove: vi.fn() })) + }, + StyleSheet: { create: (styles: unknown) => styles }, + Text: 'Text', + View: 'View', + useWindowDimensions: () => ({ width: 390, height: 844 }) +})) + +vi.mock('expo-router', () => ({ + useFocusEffect: vi.fn(), + useLocalSearchParams: () => mocks.params, + useRouter: () => ({ replace: mocks.replace }) +})) + +vi.mock('react-native-safe-area-context', () => ({ SafeAreaView: 'SafeAreaView' })) +vi.mock('../components/OrcaLogo', () => ({ OrcaLogo: 'OrcaLogo' })) +vi.mock('./MobileOnboardingPage', () => ({ MobileOnboardingPage: 'MobileOnboardingPage' })) +vi.mock('../notifications/mobile-notifications', () => ({ + ensureNotificationPermissions: mocks.ensureNotificationPermissions +})) +vi.mock('../storage/session-view-preferences', () => ({ + saveDefaultSessionView: mocks.saveDefaultSessionView +})) +vi.mock('../storage/preferences', () => ({ + savePushNotificationsEnabled: mocks.savePushNotificationsEnabled +})) + +describe('MobileOnboardingScreen', () => { + let renderer: ReactTestRenderer | null = null + + beforeEach(() => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true + mocks.params = { hostId: 'paired-host', steps: 'session-view,notifications' } + mocks.replace.mockReset() + mocks.reducedMotionEnabled = false + mocks.animatedTiming.mockReset().mockReturnValue({ + start: (callback: (result: { finished: boolean }) => void) => callback({ finished: true }) + }) + mocks.ensureNotificationPermissions.mockReset().mockResolvedValue(true) + mocks.saveDefaultSessionView.mockReset().mockResolvedValue(undefined) + mocks.savePushNotificationsEnabled.mockReset().mockResolvedValue(undefined) + }) + + afterEach(() => { + act(() => renderer?.unmount()) + renderer = null + vi.restoreAllMocks() + }) + + async function renderScreen() { + 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])) + } + }) + await act(async () => { + renderer = create(createElement(MobileOnboardingScreen)) + }) + consoleError.mockRestore() + } + + function pages() { + return renderer!.root.findAllByType('MobileOnboardingPage') + } + + it('advances from session view to notifications before opening the paired host', async () => { + await renderScreen() + expect(pages().map((page) => page.props.active)).toEqual([true, false]) + + await act(async () => pages()[0].props.onSessionChoice('chat')) + expect(mocks.saveDefaultSessionView).toHaveBeenCalledWith('chat') + expect(pages().map((page) => page.props.active)).toEqual([false, true]) + expect(mocks.replace).not.toHaveBeenCalled() + + await act(async () => pages()[1].props.onNotificationChoice('skip')) + expect(mocks.ensureNotificationPermissions).not.toHaveBeenCalled() + expect(mocks.savePushNotificationsEnabled).toHaveBeenCalledWith(false) + expect(mocks.replace).toHaveBeenCalledWith('/h/paired-host') + }) + + it('finishes immediately when the plan contains only one outstanding step', async () => { + mocks.params = { hostId: 'paired-host', steps: 'session-view' } + await renderScreen() + + await act(async () => pages()[0].props.onSessionChoice('terminal')) + expect(mocks.replace).toHaveBeenCalledWith('/h/paired-host') + }) + + it('keeps the current step retryable when persistence fails', async () => { + mocks.params = { hostId: 'paired-host', steps: 'session-view' } + mocks.saveDefaultSessionView + .mockRejectedValueOnce(new Error('storage unavailable')) + .mockResolvedValueOnce(undefined) + await renderScreen() + + await act(async () => pages()[0].props.onSessionChoice('chat')) + expect(pages()[0].props.error).toBe('Your choice could not be saved. Try again.') + + await act(async () => pages()[0].props.onSessionChoice('chat')) + expect(mocks.saveDefaultSessionView).toHaveBeenCalledTimes(2) + expect(mocks.replace).toHaveBeenCalledWith('/h/paired-host') + }) + + it('resets carousel state when the route supplies a new onboarding plan', async () => { + await renderScreen() + await act(async () => pages()[0].props.onSessionChoice('chat')) + expect(pages().map((page) => page.props.active)).toEqual([false, true]) + + mocks.params = { hostId: 'paired-host', steps: 'notifications' } + await act(async () => renderer!.update(createElement(MobileOnboardingScreen))) + + expect(pages()).toHaveLength(1) + expect(pages()[0].props).toMatchObject({ step: 'notifications', active: true }) + }) + + it('skips the slide animation when the device requests reduced motion', async () => { + mocks.reducedMotionEnabled = true + await renderScreen() + + await act(async () => pages()[0].props.onSessionChoice('chat')) + + expect(mocks.animatedTiming).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ duration: 0, useNativeDriver: true }) + ) + }) + + it('keeps the next decision available if the cosmetic transition is interrupted', async () => { + mocks.animatedTiming.mockReturnValue({ + start: (callback: (result: { finished: boolean }) => void) => callback({ finished: false }) + }) + await renderScreen() + + await act(async () => pages()[0].props.onSessionChoice('chat')) + + expect(pages()[1].props).toMatchObject({ active: true, busyChoice: null }) + }) +}) diff --git a/mobile/src/onboarding/mobile-onboarding-styles.ts b/mobile/src/onboarding/mobile-onboarding-styles.ts new file mode 100644 index 000000000..20f36ec0f --- /dev/null +++ b/mobile/src/onboarding/mobile-onboarding-styles.ts @@ -0,0 +1,132 @@ +import { StyleSheet } from 'react-native' +import { colors, radii, spacing, typography } from '../theme/mobile-theme' + +export const mobileOnboardingStyles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: colors.bgBase + }, + brandRow: { + minHeight: 52, + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm, + paddingHorizontal: spacing.xl + }, + brandName: { + color: colors.textPrimary, + fontSize: 17, + fontWeight: '700' + }, + progress: { + position: 'absolute', + left: '50%', + flexDirection: 'row', + alignItems: 'center', + gap: 7, + transform: [{ translateX: -18 }] + }, + progressDot: { + width: 7, + height: 7, + borderRadius: 999, + backgroundColor: colors.borderSubtle + }, + progressDotActive: { + width: 22, + backgroundColor: colors.textPrimary + }, + carouselViewport: { + flex: 1, + overflow: 'hidden' + }, + carouselTrack: { + height: '100%', + flexDirection: 'row' + }, + page: { + height: '100%' + }, + // Why: every decision remains reachable in landscape and with accessibility + // text scaling even though Back and swipe-to-skip are intentionally disabled. + pageContent: { + flexGrow: 1, + paddingHorizontal: spacing.xl + }, + content: { + flexGrow: 1, + alignItems: 'center', + justifyContent: 'center', + paddingVertical: spacing.xl + }, + iconSurface: { + width: 64, + height: 64, + borderRadius: radii.card, + alignItems: 'center', + justifyContent: 'center', + backgroundColor: colors.bgRaised, + marginBottom: spacing.xl + }, + title: { + maxWidth: 420, + color: colors.textPrimary, + fontSize: 26, + fontWeight: '700', + letterSpacing: -0.3, + textAlign: 'center' + }, + body: { + maxWidth: 420, + color: colors.textSecondary, + fontSize: typography.bodySize, + lineHeight: 21, + textAlign: 'center', + marginTop: spacing.md + }, + footer: { + width: '100%', + maxWidth: 420, + alignSelf: 'center', + paddingBottom: spacing.lg + }, + primaryButton: { + minHeight: 44, + alignItems: 'center', + justifyContent: 'center', + borderRadius: radii.button, + backgroundColor: colors.surfaceBright, + paddingVertical: spacing.sm + }, + primaryButtonText: { + color: colors.bgBase, + fontSize: typography.bodySize, + fontWeight: '600' + }, + secondaryButton: { + minHeight: 44, + alignItems: 'center', + justifyContent: 'center', + borderRadius: radii.button, + marginTop: spacing.xs, + paddingVertical: spacing.sm + }, + secondaryButtonText: { + color: colors.textSecondary, + fontSize: typography.bodySize, + fontWeight: '500' + }, + buttonPressed: { + opacity: 0.72 + }, + buttonDisabled: { + opacity: 0.58 + }, + error: { + color: colors.statusRed, + fontSize: typography.metaSize, + lineHeight: 18, + textAlign: 'center', + marginBottom: spacing.sm + } +}) diff --git a/mobile/src/session/session-view-opt-in-gate.test.ts b/mobile/src/session/session-view-opt-in-gate.test.ts new file mode 100644 index 000000000..8a31f466e --- /dev/null +++ b/mobile/src/session/session-view-opt-in-gate.test.ts @@ -0,0 +1,49 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { readDefaultSessionViewPreference } from '../storage/session-view-preferences' +import { shouldPresentSessionViewOptIn } from './session-view-opt-in-gate' + +vi.mock('../storage/session-view-preferences', () => ({ + readDefaultSessionViewPreference: vi.fn() +})) + +describe('session view opt-in gate', () => { + beforeEach(() => { + vi.mocked(readDefaultSessionViewPreference).mockReset() + }) + + it('presents when no default has ever been saved', async () => { + vi.mocked(readDefaultSessionViewPreference).mockResolvedValue({ + value: null, + loaded: true, + hasStoredValue: false + }) + await expect(shouldPresentSessionViewOptIn()).resolves.toBe(true) + }) + + it.each(['terminal', 'chat'] as const)('preserves an existing %s choice', async (value) => { + vi.mocked(readDefaultSessionViewPreference).mockResolvedValue({ + value, + loaded: true, + hasStoredValue: true + }) + await expect(shouldPresentSessionViewOptIn()).resolves.toBe(false) + }) + + it('does not overwrite an unknown stored value', async () => { + vi.mocked(readDefaultSessionViewPreference).mockResolvedValue({ + value: null, + loaded: true, + hasStoredValue: true + }) + await expect(shouldPresentSessionViewOptIn()).resolves.toBe(false) + }) + + it('does not block startup when storage is unreadable', async () => { + vi.mocked(readDefaultSessionViewPreference).mockResolvedValue({ + value: null, + loaded: false, + hasStoredValue: false + }) + await expect(shouldPresentSessionViewOptIn()).resolves.toBe(false) + }) +}) diff --git a/mobile/src/session/session-view-opt-in-gate.ts b/mobile/src/session/session-view-opt-in-gate.ts new file mode 100644 index 000000000..b7e6cb1a0 --- /dev/null +++ b/mobile/src/session/session-view-opt-in-gate.ts @@ -0,0 +1,10 @@ +import { readDefaultSessionViewPreference } from '../storage/session-view-preferences' + +/** Whether to show the one-time screen that lets the user pick terminal vs native + * chat. Present it only when the read succeeded and no default was ever saved, so + * people who already chose (in the opt-in or Settings) are never prompted again, + * and a transient storage failure doesn't trap startup behind the screen. */ +export async function shouldPresentSessionViewOptIn(): Promise { + const preference = await readDefaultSessionViewPreference() + return preference.loaded && !preference.hasStoredValue +} diff --git a/mobile/src/storage/preferences.test.ts b/mobile/src/storage/preferences.test.ts index 13e43d45b..b636ea12d 100644 --- a/mobile/src/storage/preferences.test.ts +++ b/mobile/src/storage/preferences.test.ts @@ -24,6 +24,7 @@ import { import { loadDefaultSessionView, loadSessionViewOverrides, + readDefaultSessionViewPreference, readSessionViewOverridesPreference, saveDefaultSessionView, updateSessionViewOverride @@ -62,6 +63,39 @@ describe('session view preference', () => { await expect(loadDefaultSessionView()).resolves.toBe('terminal') }) + it('reports an absent default as an undecided (null) preference', async () => { + vi.mocked(AsyncStorage.getItem).mockResolvedValue(null) + await expect(readDefaultSessionViewPreference()).resolves.toEqual({ + value: null, + loaded: true, + hasStoredValue: false + }) + + vi.mocked(AsyncStorage.getItem).mockResolvedValue('chat') + await expect(readDefaultSessionViewPreference()).resolves.toEqual({ + value: 'chat', + loaded: true, + hasStoredValue: true + }) + + vi.mocked(AsyncStorage.getItem).mockResolvedValue('bogus') + await expect(readDefaultSessionViewPreference()).resolves.toEqual({ + value: null, + loaded: true, + hasStoredValue: true + }) + }) + + it('marks an unreadable default as not loaded so the opt-in gate can bail', async () => { + vi.mocked(AsyncStorage.getItem).mockRejectedValue(new Error('storage unavailable')) + await expect(readDefaultSessionViewPreference()).resolves.toEqual({ + value: null, + loaded: false, + hasStoredValue: false + }) + await expect(loadDefaultSessionView()).resolves.toBe('terminal') + }) + it('loads and updates per-tab overrides under a host-and-worktree scoped key', async () => { vi.mocked(AsyncStorage.getItem).mockResolvedValue( JSON.stringify({ 'tab-1': 'chat', 'tab-2': 'terminal', 'tab-3': 'bogus' }) diff --git a/mobile/src/storage/session-view-preferences.ts b/mobile/src/storage/session-view-preferences.ts index 750167e3d..cd9597d6f 100644 --- a/mobile/src/storage/session-view-preferences.ts +++ b/mobile/src/storage/session-view-preferences.ts @@ -22,17 +22,32 @@ function clearDefaultViewWriteBarrier(barrier: Promise): void { } } -/** Global (per-device) default for how supported agent sessions open. */ -export async function loadDefaultSessionView(): Promise { +export type DefaultSessionViewPreference = { + readonly value: MobileSessionView | null + readonly loaded: boolean + readonly hasStoredValue: boolean +} + +/** Reads the raw per-device default and whether its storage key exists. */ +export async function readDefaultSessionViewPreference(): Promise { await defaultViewWriteBarrier try { const raw = await AsyncStorage.getItem(DEFAULT_SESSION_VIEW_KEY) - return raw === 'chat' || raw === 'terminal' ? raw : DEFAULT_SESSION_VIEW + return { + value: raw === 'chat' || raw === 'terminal' ? raw : null, + loaded: true, + hasStoredValue: raw !== null + } } catch { - return DEFAULT_SESSION_VIEW + return { value: null, loaded: false, hasStoredValue: false } } } +/** Global (per-device) default for how supported agent sessions open. */ +export async function loadDefaultSessionView(): Promise { + return (await readDefaultSessionViewPreference()).value ?? DEFAULT_SESSION_VIEW +} + export function saveDefaultSessionView(view: MobileSessionView): Promise { // Why: callers can outlive their route; a shared barrier keeps remounted // Settings screens from letting an older write land after a newer choice.