import { useAutoPaperTheme } from '@rific/auto-paper' import { Button } from '@rific/feedback-press' import { type ComponentRef, useEffect, useRef, useState } from 'react' import { BackHandler, type NativeScrollEvent, type NativeSyntheticEvent, ScrollView, StyleSheet, useWindowDimensions, View } from 'react-native' import { Portal, Text } from 'react-native-paper' import { useReducedMotion } from 'react-native-reanimated' import { DialogHeader, isCompactDialogHeight } from '../DialogHeader' import { overlayActionStyles } from '../overlayCard' import { OverlayBackdrop, OverlayCard, useOverlayCardColors } from '../OverlaySurface' import { SecondaryButton } from '../SecondaryButton' import type { GuideRotation, GuideStep } from './types' // The overlay's own breathing room and the card's own inner padding. Together with the card's max // width these fix the width of one page exactly, which the paging ScrollView below needs up front (it // snaps to multiples of it) rather than measuring after layout. const OVERLAY_GUTTER = 24 const CARD_PADDING = 24 const MAX_CARD_WIDTH = 360 // A quarter-turned card has the phone's long edge to spread across, so it's allowed wider: shorter // lines mean fewer of them, which is what actually has to fit (see sizing below). const MAX_CARD_WIDTH_TURNED = 520 const ART_HEIGHT = 132 // On a short screen (isCompactDialogHeight, the rule every dialog's header follows) the illustration // box shrinks. Before the dialog header, that fit the whole card (padding, art, title, two lines of // body, dots, buttons) on a 360pt-wide phone held sideways; the compact header adds 48pt, so a page // there now scrolls a little (see the pager below). The art can't shrink further: the apps' own art // is drawn at fixed sizes (Solitaire's is 72-98pt tall). The page's own gaps tighten instead (pageCompact). const ART_HEIGHT_COMPACT = 88 // Where the card has to fit. The fleet's rotation is FAKE: the OS window stays portrait and the card // is only turned with a transform, so a quarter-turned card's on-screen width is its layout HEIGHT, // which must fit the window's short edge, and its on-screen height is its layout width. Sizing it // against the unswapped window (what the first version did) let a sideways card run off a small // phone. Exported for tests. export function getGuideCardSize(windowWidth: number, windowHeight: number, rotation: GuideRotation) { const turned = rotation === 90 || rotation === -90 const availableWidth = (turned ? windowHeight : windowWidth) - OVERLAY_GUTTER * 2 const availableHeight = (turned ? windowWidth : windowHeight) - OVERLAY_GUTTER * 2 const width = Math.min(turned ? MAX_CARD_WIDTH_TURNED : MAX_CARD_WIDTH, availableWidth) const compact = isCompactDialogHeight(turned ? windowWidth : windowHeight) return { width, pageWidth: width - CARD_PADDING * 2, maxHeight: availableHeight, artHeight: compact ? ART_HEIGHT_COMPACT : ART_HEIGHT, compact } } export interface HowToPlayDialogProps { visible: boolean steps: GuideStep[] // Last card's primary button. onFinish: () => void // Skip (first card only - see the action row), and Android's hardware back button - both mean "I don't need this", so a caller persisting // "seen" should treat them exactly like onFinish. onSkip: () => void // The app's live fake-landscape rotation (see GuideRotation). Portal content escapes any rotated // ancestor, so the card rotates itself, and sizes itself for the turn (see getGuideCardSize). rotation?: GuideRotation // The dialog's own header (DialogHeader), above the pages: the guide opens unprompted on a first // launch, so it says what it is before the first card's own title (Jay, 2026-10-15). title?: string icon?: string } type CardProps = Omit function HowToPlayCard({ steps, onFinish, onSkip, rotation = 0, title = 'How to Play', icon = 'help-circle-outline' }: CardProps) { const { dark, colors } = useAutoPaperTheme() const { width: windowWidth, height: windowHeight } = useWindowDimensions() const reducedMotion = useReducedMotion() const [index, setIndex] = useState(0) const scrollRef = useRef>(null) // Android's hardware back would otherwise fall through to the router underneath and pop a screen // (or exit the app) with the guide still up. Treated as Skip, the same as the on-screen button. useEffect(() => { const subscription = BackHandler.addEventListener('hardwareBackPress', () => { onSkip() return true }) return () => subscription.remove() }, [onSkip]) const { fg, fgMuted } = useOverlayCardColors() const { width: cardWidth, pageWidth, maxHeight, artHeight, compact } = getGuideCardSize(windowWidth, windowHeight, rotation) const rotationStyle = rotation === 0 ? undefined : { transform: [{ rotate: `${rotation}deg` }] } const isFirst = index === 0 const isLast = index === steps.length - 1 const isOnly = steps.length === 1 const artBackground = dark ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.05)' // The page a Next/Back tap is animating to, while it's on its way. An animated scrollTo emits every // offset in between through onScroll, and without this the dots and buttons would flip back to the // page being left for the first half of the animation. const targetRef = useRef(null) function goTo(next: number) { targetRef.current = next setIndex(next) scrollRef.current?.scrollTo({ x: next * pageWidth, animated: !reducedMotion }) } // Keeps the dots and buttons in step with a swipe (or a trackpad scroll on web). onScroll, not only // onMomentumScrollEnd: react-native-web never fires the momentum events, so on web a swipe moved the // cards while the dots and buttons stayed on the old page (Jay spotted it in Solitaire). The index // follows whichever page is more than half in view, the same moment a native pager's dots change. function syncToOffset(offsetX: number) { const page = Math.round(offsetX / pageWidth) if (page < 0 || page >= steps.length) return if (targetRef.current !== null) { if (page === targetRef.current) targetRef.current = null return } setIndex(page) } function handleScroll(e: NativeSyntheticEvent) { syncToOffset(e.nativeEvent.contentOffset.x) } // Native's settle event: also clears a pending target, in case the animation was interrupted by a // drag before it reached its page. function handleMomentumEnd(e: NativeSyntheticEvent) { targetRef.current = null syncToOffset(e.nativeEvent.contentOffset.x) } return ( {/* The pager shrinks (flexShrink) when the card hits maxHeight, and each page is its own vertical ScrollView, so a page that still doesn't fit (a long translation, large text) scrolls instead of being clipped. In the normal case nothing scrolls. */} {steps.map((step, i) => ( {step.art !== undefined && {step.art}} {step.title} {step.body} ))} {steps.length > 1 && ( {/* fgMuted is the full-strength onSurfaceVariant, so the inactive dots are dimmed to half or they'd stand out more than the active primary one. */} {steps.map((_, i) => ( ))} )} {/* ConfirmDialog's exact row (see overlayActionStyles): outlined secondary on the left, contained primary on the right. Skip and Back share the one secondary slot - three full-size buttons don't fit side by side at the row's 128 minimum - so Skip is only offered on the first card; after that the way out is Next to the end (a short flow) or Android's hardware back, which skips from anywhere. */} {!isOnly && goTo(index - 1)}>{isFirst ? 'Skip' : 'Back'}} ) } // A short, skippable, paged "how to play" card - the one shared shape every fleet game's intro takes, // built on the same overlay card as ConfirmDialog (see OverlaySurface.tsx) so it reads as // part of the same family. Controlled and presentational: whether it's visible, and what happens on // finish/skip (persisting "seen", mostly), is the caller's - see GuideProvider for the wiring nearly // every app wants. Buttons are the primary way through; the paging ScrollView underneath is what // makes a swipe work too, with no gesture-handler dependency. // // Split into an outer visibility gate and an inner card so the card's own page index is created // fresh every time it opens - a replay from Settings always starts on the first card, with no // effect needed to reset it. export function HowToPlayDialog({ visible, steps, ...rest }: HowToPlayDialogProps) { if (!visible || steps.length === 0) return null return } const styles = StyleSheet.create({ art: { alignItems: 'center', borderRadius: 12, justifyContent: 'center', overflow: 'hidden', width: '100%' }, body: { textAlign: 'center' }, // overlayCard's shared `card` padding, restated: the page width above is computed from it. card: { padding: CARD_PADDING }, dot: { borderRadius: 4, height: 8, width: 8 }, dotInactive: { opacity: 0.5 }, dots: { flexDirection: 'row', gap: 8 }, overlay: { paddingHorizontal: OVERLAY_GUTTER }, page: { alignItems: 'center', gap: 12 }, // The 8pt this saves is what the compact dialog header cost a page on a 402pt-tall window (a // Pro-size iPhone held sideways, measured on web): without it the body's second line scrolled. pageCompact: { gap: 8 }, pager: { flexShrink: 1 }, title: { fontWeight: 'bold', textAlign: 'center' } })