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 (
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
}
})