import { AutoAppearancePicker, Dialog, FadingScrollView, useAutoPaperTheme, useBlurModule, useThemeSettings } from '@rific/auto-paper' import { useExpoKeepAwake, useKeepAwakeSettings } from '@rific/core' import { Button, SoundContext, TouchableRipple, useHapticSettings, useSoundSettings, useVibration } from '@rific/feedback-press' import { useUpdater } from '@rific/updater' import { rotateInsets, useIsTouchPrimaryDevice, useRotation, type ViewRotation } from '@tastic/core' import { ReactNode, useContext, useState } from 'react' import { Platform, StyleSheet, useWindowDimensions, View } from 'react-native' import { Icon, Portal, Text } from 'react-native-paper' import { useSafeAreaInsets } from 'react-native-safe-area-context' import { ConfirmDialog } from './ConfirmDialog' import { DialogHeader, isCompactDialogHeight } from './DialogHeader' import { overlayActionStyles, overlayStyles } from './overlayCard' import { RotatedFrame } from './RotatedFrame' import { useUpdateConfirmBridge } from './useUpdateConfirmBridge' interface SettingIconProps { source: string color: string containerColor: string } // Small colored badge per row (icon tinted on its own MD3 container color) - echoes the // primary/secondary/tertiary triad that's already each consuming app's own identity, so the // settings list picks up that same palette instead of introducing new colors of its own. Every // on/off row (Lock Orientation, Edge Guard, Sound, Haptics) is tertiary while on, and an app adding // its own toggle rows should match; secondary is for the always-on action row (How to Play). function SettingIcon({ source, color, containerColor }: SettingIconProps) { return ( ) } export interface BaseSettingsDialogProps { visible: boolean onDismiss: () => void // Explicit override for the live physical-hold rotation (see @tastic/core's useRotation). This is // a centered, app-wide modal with no per-player zone to match (unlike an in-game round-over // dialog), so it lays itself out in its own frame turned by this angle (see the return below). // Defaults to a live ambient read via useRotation() when omitted. Pass 0 when the dialog already // renders under a Portal.Host inside a turned frame of the caller's own (Solitaire's screens). rotation?: ViewRotation // Each app's own OTA version (e.g. release.otaVersion) - not something this package can read // itself, since every consuming app tracks its own release.ts independently. Accepts a number too // since that's what release.otaVersion actually is in most apps (a bare incrementing counter, not // a formatted string) - this just interpolates it into the VERSION label same as the original // inline JSX did. version: string | number // Optional, and always passed as a pair - omit both entirely for a game that's portrait-only (or // otherwise has no orientation-based reflow at all), where there's nothing to lock against. lockOrientation?: boolean onLockOrientationChange?: (value: boolean) => void // Optional, and always passed as a pair - omit both entirely for a game with no full-screen edge- // anchored swipe controls (nothing to guard). iOS-only regardless of whether the pair is passed: // @tastic/edge-guard's own swizzle is a no-op on every other platform. deferBottomEdgeGestures?: boolean onDeferBottomEdgeGestures?: (value: boolean) => void // No Keep Awake props: that row reads @rific/core's Keep Awake setting itself (see showKeepAwake). // Explicit opt-outs for the rows that pull their own state straight from @rific/feedback-press, // @rific/auto-paper, and @rific/updater's own shared contexts/hooks rather than from anything the // caller passes in - unlike Lock Orientation/Edge Guard above, there's no "value" to omit for // these, so hiding one takes its own explicit flag instead. All default to shown, matching every // app's existing behavior before this prop existed. hideSound?: boolean // Haptics is already native-only (see the row's own Platform.OS check) - this hides it even // there, for a game that's deliberately silent on vibration. hideHaptics?: boolean hideAppearance?: boolean // Check for Updates is already native-only (a web build has no OTA update concept at all) - this // hides it even there, for a native app that isn't wired up for EAS/OTA updates at all. hideUpdateCheck?: boolean // Surfaces a failed update check (e.g. via @rific/toaster's error()) - omit for an app with no // toast system wired up; the check itself still runs and still reports success/no-update through // the ordinary info overlay either way. Meaningless (never called) when hideUpdateCheck is set. onUpdateError?: (message: string) => void // Omit to hide the row entirely (a game with no how-to-play flow, or a screen where replaying one // would be disruptive - e.g. over a live match). Called AFTER this dialog's own onDismiss, so the // two overlays never stack: wire it straight to @tastic/hud/guide's `useGuide().open`. onShowHowToPlay?: () => void // App-specific extra settings sections (board options, CPU difficulty, stats backup, ...), // rendered between Appearance and Check for Updates - this component only owns the settings // every game shares, never anything about how a particular game plays. children?: ReactNode } // The settings shell every @tastic game shares - orientation lock, the optional edge-guard and // keep-awake toggles, sound/haptics, appearance, and update checking - extracted after the same six rows were // independently copied into LightCycles, AirHockey, BoxHockey, Pong, and Snake and then needed // hand-editing in every one of them, twice, for what were meant to be identical fixes. `children` // is the seam for whatever isn't shared: a game with its own board/difficulty settings (or a stats // backup flow, or anything else genuinely specific to it) renders those below Appearance, not by // forking this component. export function BaseSettingsDialog({ visible, onDismiss, rotation: rotationOverride, version, lockOrientation, onLockOrientationChange, deferBottomEdgeGestures, onDeferBottomEdgeGestures, hideSound = false, hideHaptics = false, hideAppearance = false, hideUpdateCheck = false, onUpdateError, onShowHowToPlay, children }: BaseSettingsDialogProps) { const { colors } = useAutoPaperTheme() const ambientRotation = useRotation() const rotation = rotationOverride ?? ambientRotation // The sheet lays out in a RotatedFrame turned by `rotation` (below), so its height is the window's // width when turned a quarter: the same compact rule as every other dialog's header. const window = useWindowDimensions() const turned = rotation === 90 || rotation === -90 const compactHeader = isCompactDialogHeight(turned ? window.width : window.height) // The overlay cards' width (ConfirmDialog, the guide, every app's result card): 24pt from the // frame's edges, never under a notch, capped at their 360 (overlayStyles.card). auto-paper's Dialog // would give the sheet a 32pt margin and a 400 cap, 16pt narrower than a card on a phone, which // showed when Settings and a confirm opened one after the other. The card is centered, so the // Dialog's own 32pt padding is simply overhung. const insets = rotateInsets(useSafeAreaInsets(), rotation) const sheetWidth = Math.min(overlayStyles.card.maxWidth, (turned ? window.height : window.width) - 2 * Math.max(insets.left, insets.right, SHEET_GUTTER)) // Also gates the Lock Orientation and Keep Awake rows below (alongside their own conditions) - true // unconditionally on native (see the hook's own doc), so this only actually excludes a // desktop/laptop browser, where a mouse-driven window has no physical orientation to lock // against; a resize there would just make the setting silently stop reflowing the layout instead // of actually locking anything. const isTouchPrimary = useIsTouchPrimaryDevice() const { settings: hapticSettings, set: setHapticSettings } = useHapticSettings() const { settings: soundSettings, set: setSoundSettings } = useSoundSettings() const { settings: keepAwakeSettings, set: setKeepAwakeSettings } = useKeepAwakeSettings() const expoKeepAwake = useExpoKeepAwake() // Sound/Haptics toggle themselves: the ripple's automatic press feedback fires on onPressIn, // before onPress applies the toggle, so it reflects the OLD enabled value - backwards from what // a settings toggle should confirm (turning off would click/buzz, turning on would go silent). // Both rows disable their own automatic channel below (soundDisabled/hapticDisabled) and fire it // manually here instead, gated on the NEW value so it only plays when switching that channel on. const sound = useContext(SoundContext) const { forceShort: forceHapticFeedback } = useVibration() const { settings: themeSettings, set: setThemeSettings } = useThemeSettings() const expoBlur = useBlurModule() // Covers check()'s three purely-informational cases (dev-mode disabled, web unsupported, no // update found) via onInfo below - a retro card overlay instead of the native Alert.alert those // cases fall back to by default. const [infoMessage, setInfoMessage] = useState<{ title: string; message: string } | null>(null) // autoCheck: false - every consuming app's own root layout already runs the background check via // its own useUpdater() instance; a second instance with autoCheck's default (true) would set up a // second AppState listener and double every foreground-resume update check. This instance only // ever checks on an explicit tap of the button below. Deliberately its own useUpdater() instance // rather than rendering UpdateDialog directly here, for the same reason - UpdateDialog owns a // second AppState listener of its own. const { manifest: updateManifest, onConfirm: onUpdateConfirm, respond: respondToUpdateConfirm } = useUpdateConfirmBridge() const { check, checking, updateReady } = useUpdater({ autoCheck: false, autoPrompt: false, onConfirm: onUpdateConfirm, onError: onUpdateError, onInfo: (title, message) => setInfoMessage({ title, message }) }) const updateManifestDate = updateManifest ? new Date(updateManifest.createdAt) : null // The frame below is mounted from the first open on and then kept, so the Dialog's close fade // still plays after `visible` turns false. Never mounting it before that keeps a screen that never // opens Settings free of the extra (pass-through) layer, mounts the Portal at open time like the // Dialog's own Portal always was (so it lands above portals mounted earlier), and means nothing // here needs a Paper Portal.Host until the sheet is actually opened. The update and info cards are // only reachable from inside the sheet, so they never need it earlier either. const [opened, setOpened] = useState(visible) if (visible && !opened) setOpened(true) const showLockOrientation = isTouchPrimary && lockOrientation !== undefined && onLockOrientationChange !== undefined const showEdgeGuard = Platform.OS === 'ios' && deferBottomEdgeGestures !== undefined && onDeferBottomEdgeGestures !== undefined // Only in an app that opted in by handing expo-keep-awake to @rific/core's KeepAwakeProvider, and // only where it can work: core withholds the module from a browser without the Screen Wake Lock // API (from @rific/core 0.4.1), so without it the setting would do nothing. On the same devices // as Lock Orientation, since a desktop/laptop browser stays awake on its own while someone plays // with its keyboard or mouse. const showKeepAwake = isTouchPrimary && expoKeepAwake !== undefined const showHaptics = Platform.OS !== 'web' && !hideHaptics const showSound = !hideSound const showAppearance = !hideAppearance // See the Blur row's own comment below for why it hides on Android and without expo-blur. const showBlur = showAppearance && Platform.OS !== 'android' && expoBlur !== undefined && expoBlur !== null const showUpdateCheck = Platform.OS !== 'web' && !hideUpdateCheck // Laid out in its own turned frame rather than rotated in place. Paper's Portal mounts at the app // root, full screen and portrait-shaped, so a sheet that only rotated its card kept resolving its // maxHeight against the PORTRAIT height: turned 90deg it ran past the ~402pt short edge, and the top // row (Lock Orientation, the one you need to get back out of landscape) was clipped with no way to // scroll to it. So the whole output goes through one Portal to the root, into a RotatedFrame sized // to the turned footprint, with rotated safe-area insets and its own Portal.Host. The Dialog and the // two ConfirmDialogs below portal into THAT host, so they lay out against the turned window with no // transform of their own (hence rotation={0} on the ConfirmDialogs). The frame is pass-through at // every level and stays mounted once the sheet has opened, which keeps the Dialog's close fade; at // rotation 0 it is an identity frame. if (!opened) return null return ( {/* react-native-paper's Dialog.ScrollArea has a fixed 24px marginBottom baked in (meant to reserve room for a Dialog.Actions row below it) - overridden to 0: the Done row below carries its own spacing. It is the sheet's first child, so it runs to the card's top edge. */} {/* The fleet's one dialog header and Done button (DialogHeader, overlayActionStyles' single button), so the sheet reads as one family with ConfirmDialog, the guide and every app's result cards (Jay, 2026-10-15). The header scrolls away with the rows, so a long sheet gives them the whole card once scrolled; Done stays pinned below. Tapping outside still closes it too. */} {/* Grouped tightly together (styles.toggleGroup's small internal gap, not the 24px gap between sections below) - same bare-row shape for all of them, no segmented control and no section heading (each row's own label already says what it is). Haptics only joins on native - there's nothing for it to control on web. */} {/* Omitted entirely for a portrait-only game (no lockOrientation/onLockOrientationChange passed at all - nothing to lock), and hidden (not just disabled) on a desktop/laptop browser even when they are - see isTouchPrimary's own doc above. Still shown on native and on a touch-capable mobile browser, where a rotated window really does reflect a rotated device. */} {showLockOrientation && ( onLockOrientationChange!(!lockOrientation)} style={styles.toggleButton} accessibilityLabel={`Lock orientation ${lockOrientation ? 'on' : 'off'}`}> Lock Orientation Pin current layout )} {/* iOS only - mirrors into UserDefaults for @tastic/edge-guard's own config plugin swizzle to read. Guards both edges - the top (Notification Center/Control Center) and the bottom (home-indicator swipe, incidentally Reachability too), per that package's own doc. Off by default: a deliberate opt-in for whoever's actually hit an accidental-swipe-near-an-edge problem mid-game and come looking for a fix, not a surprise every player gets by default. */} {showEdgeGuard && ( onDeferBottomEdgeGestures!(!deferBottomEdgeGestures)} style={styles.toggleButton} accessibilityLabel={`Edge guard ${deferBottomEdgeGestures ? 'on' : 'off'}`}> Edge Guard Require second swipe )} {/* The app's own useKeepAwake('game') does the keeping awake; this only flips the shared setting. */} {showKeepAwake && ( setKeepAwakeSettings({ enabled: !keepAwakeSettings.enabled })} style={styles.toggleButton} accessibilityLabel={`Keep awake ${keepAwakeSettings.enabled ? 'on' : 'off'}`}> Keep Awake Stop auto-lock mid-match )} {/* Side by side, not stacked - neither needs a full row's width (single-line label, no description under it the way Lock Orientation has), so sharing one row reads just as clearly and takes half the vertical space. toggleRow carries the same edge-alignment negative margin toggleButton normally carries itself; the buttons inside it use toggleButtonInRow (flex: 1) instead so the two don't double up on it. The row itself only renders when at least one of the two will - an empty flex row still eats its own gap/margin otherwise. */} {(showSound || showHaptics) && ( {showSound && ( { const enabled = !soundSettings.enabled setSoundSettings({ enabled }) if (enabled) sound.selection?.() }} style={styles.toggleButtonInRow} accessibilityLabel={`Sound ${soundSettings.enabled ? 'on' : 'off'}`} > Sound )} {/* Already native-only (see showHaptics's own definition) - hideHaptics can hide it there too, for a game that's deliberately silent on vibration. */} {showHaptics && ( { const vibrate = !hapticSettings.vibrate setHapticSettings({ vibrate }) if (vibrate) forceHapticFeedback() }} style={styles.toggleButtonInRow} accessibilityLabel={`Haptics ${hapticSettings.vibrate ? 'on' : 'off'}`} > Haptics )} )} {/* Not a toggle, but it reads like one of the rows above and belongs with them rather than down in `children` (which sits below Appearance) - this is the "replay the intro" entry point every game's own how-to-play flow shares. Dismisses this dialog first so the guide's own overlay never opens on top of a still-visible Settings card. */} {onShowHowToPlay && ( { onDismiss() onShowHowToPlay() }} style={styles.toggleButton} accessibilityLabel='How to Play' > How to Play Replay quick intro )} {showAppearance && ( APPEARANCE {/* Blur (Jay, 2026-10-15): one fleet-wide switch for auto-paper's own `blur` setting, which every blur-capable surface follows on its own (this sheet, and the OverlayCard behind ConfirmDialog, UpdateDialog and the guide). Off, or wherever blur can't happen, those surfaces paint the theme's raised surface instead. So the row only shows where turning it on would change something: not on Android (auto-paper's BlurView needs a blur target there, which these cards don't have) and not in an app that never handed expo-blur to auto-paper's Provider. */} {showBlur && ( setThemeSettings({ blur: !themeSettings.blur })} style={styles.toggleButton} accessibilityLabel={`Blur ${themeSettings.blur ? 'on' : 'off'}`}> {/* The one-word label alone didn't say what blurs (Jay, 2026-10-15). One line even on a 360pt phone (216pt of room). */} Blur Frost behind dialogs )} )} {children} {/* Native only (a web build has no OTA update concept at all, desktop or mobile browser alike) - hideUpdateCheck can hide it there too, for a native app that isn't wired up for EAS/OTA updates at all. */} {showUpdateCheck && ( VERSION {version} {updateReady ? ' ยท UPDATE READY' : ''} )} respondToUpdateConfirm(true)} onCancel={() => respondToUpdateConfirm(false)} /> {/* onCancel with no cancelLabel adds no button: it's only so Android back dismisses the message the way OK does (ConfirmDialog consumes back either way). */} setInfoMessage(null)} onCancel={() => setInfoMessage(null)} /> ) } // The backdrop gutter every overlay card keeps from the screen's edges. const SHEET_GUTTER = 24 const styles = StyleSheet.create({ // The rows' sections, 24pt apart. content: { gap: 24 }, // Caps the card so it never grows past the screen - without this the dialog just keeps // growing to fit its content and the overflow gets clipped by the screen edge, which is // what happened in landscape where there's less height to work with. Dialog.ScrollArea + // ScrollView below then take over and let the content scroll within that bound. dialog: { maxHeight: '90%' }, flexShrink: { flexShrink: 1 }, // The overlay cards' 24pt padding, as the sheet's own insets (its scroll area pads 24 sideways). footer: { paddingBottom: 24, paddingHorizontal: 24 }, iconBadge: { alignItems: 'center', borderRadius: 10, height: 36, justifyContent: 'center', width: 36 }, // No hairline dividers above and below the scroll area (Paper's MD2-era borders): none of the // fleet's other dialogs draws one, so the sheet matches them (Jay, 2026-10-15). scrollArea: { borderBottomWidth: 0, borderTopWidth: 0, marginBottom: 0 }, // The header and the rows scroll together: the card's 24pt inset above the header, the overlay // cards' 16pt gap between it and the rows, and 16pt below the last row before the Done row. scrollContent: { gap: 16, paddingBottom: 16, paddingTop: 24 }, section: { gap: 12 }, sectionLabel: { letterSpacing: 2 }, // Used to carry marginHorizontal: -12 + paddingHorizontal: 12 (netting to the same inset as // APPEARANCE/SOUND & HAPTICS below, while giving the ripple/hover highlight room to breathe // into the dialog's own gutter beyond that). Removed: this dialog scrolls (Dialog.ScrollArea + // ScrollView above), and on web a ScrollView's own content wrapper clips to its own bounds // regardless of a child's negative margin - confirmed live, the icon's own rounded-square badge // was getting its left edge sliced off by exactly that overhang. No horizontal inset of its own // now - it relies purely on the same dialog padding APPEARANCE/every other row already does, // which can never overflow because it's never negative. toggleButton: { borderRadius: 12, overflow: 'hidden', paddingVertical: 10 }, // Same shape as toggleButton, `flex: 1` instead of its own width - two of these side by side in // toggleRow share the row's own edge alignment instead of each carrying their own. toggleButtonInRow: { borderRadius: 12, flex: 1, overflow: 'hidden', paddingVertical: 10 }, toggleContent: { alignItems: 'center', flexDirection: 'row', gap: 12 }, toggleGroup: { gap: 4 }, // No horizontal margin of its own now either - see toggleButton's identical note. toggleRow: { flexDirection: 'row', gap: 4 } })