@tastic/hud 0.1.6 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,371 @@
1
+ import { AutoAppearancePicker, Dialog, useAutoPaperTheme } from '@rific/auto-paper'
2
+ import { Button, SoundContext, TouchableRipple, useHapticSettings, useSoundSettings, useVibration } from '@rific/feedback-press'
3
+ import { useUpdater } from '@rific/updater'
4
+ import { useIsTouchPrimaryDevice } from '@tastic/core'
5
+ import { ReactNode, useContext, useState } from 'react'
6
+ import { Platform, ScrollView, StyleSheet, View } from 'react-native'
7
+ import { Icon, Portal, Text } from 'react-native-paper'
8
+
9
+ interface SettingIconProps {
10
+ source: string
11
+ color: string
12
+ containerColor: string
13
+ }
14
+
15
+ // Small colored badge per row (icon tinted on its own MD3 container color) — echoes the
16
+ // primary/secondary/tertiary triad that's already each consuming app's own identity, so the
17
+ // settings list picks up that same palette instead of introducing new colors of its own.
18
+ function SettingIcon({ source, color, containerColor }: SettingIconProps) {
19
+ return (
20
+ <View style={[styles.iconBadge, { backgroundColor: containerColor }]}>
21
+ <Icon source={source} size={18} color={color} />
22
+ </View>
23
+ )
24
+ }
25
+
26
+ export interface BaseSettingsDialogProps {
27
+ visible: boolean
28
+ onDismiss: () => void
29
+ // Live physical-hold rotation (see @tastic/split-screen's getViewRotation) — this is a centered,
30
+ // app-wide modal with no per-player zone to match (unlike an in-game round-over dialog), so it
31
+ // just rotates its own content in place; defaults to 0 for a caller that doesn't track one (a
32
+ // game with no face-to-face two-player mode has nothing to stay consistent with anyway).
33
+ rotation?: number
34
+ // Each app's own OTA version (e.g. release.otaVersion) — not something this package can read
35
+ // itself, since every consuming app tracks its own release.ts independently. Accepts a number too
36
+ // since that's what release.otaVersion actually is in most apps (a bare incrementing counter, not
37
+ // a formatted string) — this just interpolates it into the VERSION label same as the original
38
+ // inline JSX did.
39
+ version: string | number
40
+ // Optional, and always passed as a pair — omit both entirely for a game that's portrait-only (or
41
+ // otherwise has no orientation-based reflow at all), where there's nothing to lock against.
42
+ lockOrientation?: boolean
43
+ onLockOrientationChange?: (value: boolean) => void
44
+ // Optional, and always passed as a pair — omit both entirely for a game with no full-screen edge-
45
+ // anchored swipe controls (nothing to guard). iOS-only regardless of whether the pair is passed:
46
+ // @tastic/edge-guard's own swizzle is a no-op on every other platform.
47
+ deferBottomEdgeGestures?: boolean
48
+ onDeferBottomEdgeGestures?: (value: boolean) => void
49
+ // Explicit opt-outs for the rows that pull their own state straight from @rific/feedback-press,
50
+ // @rific/auto-paper, and @rific/updater's own shared contexts/hooks rather than from anything the
51
+ // caller passes in — unlike Lock Orientation/Edge Guard above, there's no "value" to omit for
52
+ // these, so hiding one takes its own explicit flag instead. All default to shown, matching every
53
+ // app's existing behavior before this prop existed.
54
+ hideSound?: boolean
55
+ // Haptics is already native-only (see the row's own Platform.OS check) — this hides it even
56
+ // there, for a game that's deliberately silent on vibration.
57
+ hideHaptics?: boolean
58
+ hideAppearance?: boolean
59
+ // Check for Updates is already native-only (a web build has no OTA update concept at all) — this
60
+ // hides it even there, for a native app that isn't wired up for EAS/OTA updates at all.
61
+ hideUpdateCheck?: boolean
62
+ // Surfaces a failed update check (e.g. via @rific/toaster's error()) — omit for an app with no
63
+ // toast system wired up; the check itself still runs and still reports success/no-update through
64
+ // the ordinary info overlay either way. Meaningless (never called) when hideUpdateCheck is set.
65
+ onUpdateError?: (message: string) => void
66
+ // App-specific extra settings sections (board options, CPU difficulty, stats backup, ...),
67
+ // rendered between Appearance and Check for Updates — this component only owns the settings
68
+ // every game shares, never anything about how a particular game plays.
69
+ children?: ReactNode
70
+ }
71
+
72
+ // The settings shell every @tastic game shares — orientation lock, the optional edge-guard toggle,
73
+ // sound/haptics, appearance, and update checking — extracted after the same six rows were
74
+ // independently copied into LightCycles, AirHockey, BoxHockey, Pong, and Snake and then needed
75
+ // hand-editing in every one of them, twice, for what were meant to be identical fixes. `children`
76
+ // is the seam for whatever isn't shared: a game with its own board/difficulty settings (or a stats
77
+ // backup flow, or anything else genuinely specific to it) renders those below Appearance, not by
78
+ // forking this component.
79
+ export function BaseSettingsDialog({ visible, onDismiss, rotation = 0, version, lockOrientation, onLockOrientationChange, deferBottomEdgeGestures, onDeferBottomEdgeGestures, hideSound = false, hideHaptics = false, hideAppearance = false, hideUpdateCheck = false, onUpdateError, children }: BaseSettingsDialogProps) {
80
+ const { dark, colors } = useAutoPaperTheme()
81
+ // Also gates the Lock Orientation row below (alongside showLockOrientation itself) — true
82
+ // unconditionally on native (see the hook's own doc), so this only actually excludes a
83
+ // desktop/laptop browser, where a mouse-driven window has no physical orientation to lock
84
+ // against; a resize there would just make the setting silently stop reflowing the layout instead
85
+ // of actually locking anything.
86
+ const isTouchPrimary = useIsTouchPrimaryDevice()
87
+ const { settings: hapticSettings, set: setHapticSettings } = useHapticSettings()
88
+ const { settings: soundSettings, set: setSoundSettings } = useSoundSettings()
89
+ // Sound/Haptics toggle themselves: the ripple's automatic press feedback fires on onPressIn,
90
+ // before onPress applies the toggle, so it reflects the OLD enabled value — backwards from what
91
+ // a settings toggle should confirm (turning off would click/buzz, turning on would go silent).
92
+ // Both rows disable their own automatic channel below (soundDisabled/hapticDisabled) and fire it
93
+ // manually here instead, gated on the NEW value so it only plays when switching that channel on.
94
+ const sound = useContext(SoundContext)
95
+ const { forceShort: forceHapticFeedback } = useVibration()
96
+ // Covers check()'s three purely-informational cases (dev-mode disabled, web unsupported, no
97
+ // update found) via onInfo below — a retro card overlay instead of the native Alert.alert those
98
+ // cases fall back to by default.
99
+ const [infoMessage, setInfoMessage] = useState<{ title: string; message: string } | null>(null)
100
+ // autoCheck: false — every consuming app's own root layout already runs the background check via
101
+ // its own useUpdater() instance; a second instance with autoCheck's default (true) would set up a
102
+ // second AppState listener and double every foreground-resume update check. This instance only
103
+ // ever checks on an explicit tap of the button below.
104
+ const { check, checking, updateReady } = useUpdater({
105
+ autoCheck: false,
106
+ autoPrompt: false,
107
+ onError: onUpdateError,
108
+ onInfo: (title, message) => setInfoMessage({ title, message })
109
+ })
110
+
111
+ const showLockOrientation = isTouchPrimary && lockOrientation !== undefined && onLockOrientationChange !== undefined
112
+ const showEdgeGuard = Platform.OS === 'ios' && deferBottomEdgeGestures !== undefined && onDeferBottomEdgeGestures !== undefined
113
+ const showHaptics = Platform.OS !== 'web' && !hideHaptics
114
+ const showSound = !hideSound
115
+ const showAppearance = !hideAppearance
116
+ const showUpdateCheck = Platform.OS !== 'web' && !hideUpdateCheck
117
+
118
+ // High-contrast retro look: literal black/white by appearance, not auto-paper's own (slightly
119
+ // tinted) background role.
120
+ const fg = dark ? '#FFFFFF' : '#000000'
121
+ const cardBg = dark ? '#111111' : '#F2F2F2'
122
+ const cardBorder = dark ? 'rgba(255,255,255,0.2)' : 'rgba(0,0,0,0.2)'
123
+
124
+ return (
125
+ <>
126
+ <Dialog visible={visible} onDismiss={onDismiss} style={[styles.dialog, rotation % 360 !== 0 && { transform: [{ rotate: `${rotation}deg` }] }]}>
127
+ <Dialog.Title>Settings</Dialog.Title>
128
+ {/* react-native-paper's Dialog.ScrollArea has a fixed 24px marginBottom baked in (meant to
129
+ reserve room for a Dialog.Actions row below it) — overridden to 0 since this dialog has
130
+ no actions row, and that gap otherwise reads as unexplained empty footer space. */}
131
+ <Dialog.ScrollArea style={styles.scrollArea}>
132
+ <ScrollView contentContainerStyle={styles.content} showsVerticalScrollIndicator={false}>
133
+ {/* Grouped tightly together (styles.toggleGroup's small internal gap, not the 24px gap
134
+ between sections below) — same bare-row shape for all of them, no segmented control
135
+ and no section heading (each row's own label already says what it is). Haptics only
136
+ joins on native — there's nothing for it to control on web. */}
137
+ <View style={styles.toggleGroup}>
138
+ {/* Omitted entirely for a portrait-only game (no lockOrientation/onLockOrientationChange
139
+ passed at all — nothing to lock), and hidden (not just disabled) on a desktop/laptop
140
+ browser even when they are — see isTouchPrimary's own doc above. Still shown on
141
+ native and on a touch-capable mobile browser, where a rotated window really does
142
+ reflect a rotated device. */}
143
+ {showLockOrientation && (
144
+ <TouchableRipple onPress={() => onLockOrientationChange!(!lockOrientation)} style={styles.toggleButton} accessibilityLabel={`Lock orientation ${lockOrientation ? 'on' : 'off'}`}>
145
+ <View style={styles.toggleContent}>
146
+ <SettingIcon source={lockOrientation ? 'lock' : 'lock-open-variant-outline'} color={lockOrientation ? colors.secondary : colors.onSurfaceVariant} containerColor={lockOrientation ? colors.secondaryContainer : colors.surfaceVariant} />
147
+ <View style={styles.flexShrink}>
148
+ <Text variant='bodyLarge' style={{ color: colors.onSurface }}>
149
+ Lock Orientation
150
+ </Text>
151
+ <Text variant='bodySmall' numberOfLines={1} style={{ color: colors.onSurfaceVariant }}>
152
+ Pins the current layout
153
+ </Text>
154
+ </View>
155
+ </View>
156
+ </TouchableRipple>
157
+ )}
158
+
159
+ {/* iOS only — mirrors into UserDefaults for @tastic/edge-guard's own config plugin
160
+ swizzle to read. Guards both edges — the top (Notification Center/Control Center)
161
+ and the bottom (home-indicator swipe, incidentally Reachability too), per that
162
+ package's own doc. Off by default: a deliberate opt-in for whoever's actually hit an
163
+ accidental-swipe-near-an-edge problem mid-game and come looking for a fix, not a
164
+ surprise every player gets by default. */}
165
+ {showEdgeGuard && (
166
+ <TouchableRipple onPress={() => onDeferBottomEdgeGestures!(!deferBottomEdgeGestures)} style={styles.toggleButton} accessibilityLabel={`Edge guard ${deferBottomEdgeGestures ? 'on' : 'off'}`}>
167
+ <View style={styles.toggleContent}>
168
+ <SettingIcon source={deferBottomEdgeGestures ? 'shield-check-outline' : 'shield-off-outline'} color={deferBottomEdgeGestures ? colors.secondary : colors.onSurfaceVariant} containerColor={deferBottomEdgeGestures ? colors.secondaryContainer : colors.surfaceVariant} />
169
+ <View style={styles.flexShrink}>
170
+ <Text variant='bodyLarge' style={{ color: colors.onSurface }}>
171
+ Edge Guard
172
+ </Text>
173
+ <Text variant='bodySmall' numberOfLines={1} style={{ color: colors.onSurfaceVariant }}>
174
+ Requires a second swipe
175
+ </Text>
176
+ </View>
177
+ </View>
178
+ </TouchableRipple>
179
+ )}
180
+
181
+ {/* Side by side, not stacked — neither needs a full row's width (single-line label,
182
+ no description under it the way Lock Orientation has), so sharing one row reads
183
+ just as clearly and takes half the vertical space. toggleRow carries the same
184
+ edge-alignment negative margin toggleButton normally carries itself; the buttons
185
+ inside it use toggleButtonInRow (flex: 1) instead so the two don't double up on it.
186
+ The row itself only renders when at least one of the two will — an empty flex row
187
+ still eats its own gap/margin otherwise. */}
188
+ {(showSound || showHaptics) && (
189
+ <View style={styles.toggleRow}>
190
+ {showSound && (
191
+ <TouchableRipple
192
+ soundDisabled
193
+ onPress={() => {
194
+ const enabled = !soundSettings.enabled
195
+ setSoundSettings({ enabled })
196
+ if (enabled) sound.selection?.()
197
+ }}
198
+ style={styles.toggleButtonInRow}
199
+ accessibilityLabel={`Sound ${soundSettings.enabled ? 'on' : 'off'}`}
200
+ >
201
+ <View style={styles.toggleContent}>
202
+ <SettingIcon source={soundSettings.enabled ? 'volume-high' : 'volume-off'} color={soundSettings.enabled ? colors.tertiary : colors.onSurfaceVariant} containerColor={soundSettings.enabled ? colors.tertiaryContainer : colors.surfaceVariant} />
203
+ <Text variant='bodyLarge' style={{ color: colors.onSurface }}>
204
+ Sound
205
+ </Text>
206
+ </View>
207
+ </TouchableRipple>
208
+ )}
209
+
210
+ {/* Already native-only (see showHaptics's own definition) — hideHaptics can hide
211
+ it there too, for a game that's deliberately silent on vibration. */}
212
+ {showHaptics && (
213
+ <TouchableRipple
214
+ hapticDisabled
215
+ onPress={() => {
216
+ const vibrate = !hapticSettings.vibrate
217
+ setHapticSettings({ vibrate })
218
+ if (vibrate) forceHapticFeedback()
219
+ }}
220
+ style={styles.toggleButtonInRow}
221
+ accessibilityLabel={`Haptics ${hapticSettings.vibrate ? 'on' : 'off'}`}
222
+ >
223
+ <View style={styles.toggleContent}>
224
+ <SettingIcon source={hapticSettings.vibrate ? 'vibrate' : 'vibrate-off'} color={hapticSettings.vibrate ? colors.tertiary : colors.onSurfaceVariant} containerColor={hapticSettings.vibrate ? colors.tertiaryContainer : colors.surfaceVariant} />
225
+ <Text variant='bodyLarge' style={{ color: colors.onSurface }}>
226
+ Haptics
227
+ </Text>
228
+ </View>
229
+ </TouchableRipple>
230
+ )}
231
+ </View>
232
+ )}
233
+ </View>
234
+
235
+ {showAppearance && (
236
+ <View style={styles.section}>
237
+ <Text variant='labelMedium' style={[styles.sectionLabel, { color: colors.onSurfaceVariant }]}>
238
+ APPEARANCE
239
+ </Text>
240
+ <AutoAppearancePicker showLabels={false} />
241
+ </View>
242
+ )}
243
+
244
+ {children}
245
+
246
+ {/* Native only (a web build has no OTA update concept at all, desktop or mobile browser
247
+ alike) — hideUpdateCheck can hide it there too, for a native app that isn't wired up
248
+ for EAS/OTA updates at all. */}
249
+ {showUpdateCheck && (
250
+ <View style={styles.section}>
251
+ <Text variant='labelSmall' style={[styles.sectionLabel, { color: colors.onSurfaceVariant }]}>
252
+ VERSION {version}
253
+ {updateReady ? ' · UPDATE READY' : ''}
254
+ </Text>
255
+ <Button mode='outlined' onPress={check} loading={checking} disabled={checking}>
256
+ Check for Updates
257
+ </Button>
258
+ </View>
259
+ )}
260
+ </ScrollView>
261
+ </Dialog.ScrollArea>
262
+ </Dialog>
263
+ {infoMessage && (
264
+ <Portal>
265
+ <View style={styles.overlay}>
266
+ <View style={[styles.overlayCard, { backgroundColor: cardBg, borderColor: cardBorder }, rotation % 360 !== 0 && { transform: [{ rotate: `${rotation}deg` }] }]}>
267
+ <Icon source='information-outline' size={64} color={colors.secondary} />
268
+ <Text variant='headlineLarge' style={[styles.overlayTitle, { color: colors.secondary }]}>
269
+ {infoMessage.title}
270
+ </Text>
271
+ <Text variant='bodyLarge' style={[styles.overlayBody, { color: fg }]}>
272
+ {infoMessage.message}
273
+ </Text>
274
+ <Button mode='contained' onPress={() => setInfoMessage(null)} style={styles.overlayButton} buttonColor={colors.primary} textColor={colors.onPrimary}>
275
+ OK
276
+ </Button>
277
+ </View>
278
+ </View>
279
+ </Portal>
280
+ )}
281
+ </>
282
+ )
283
+ }
284
+
285
+ const styles = StyleSheet.create({
286
+ content: {
287
+ gap: 24,
288
+ paddingVertical: 20
289
+ },
290
+ // Caps the card so it never grows past the screen — without this the dialog just keeps
291
+ // growing to fit its content and the overflow gets clipped by the screen edge, which is
292
+ // what happened in landscape where there's less height to work with. Dialog.ScrollArea +
293
+ // ScrollView below then take over and let the content scroll within that bound.
294
+ dialog: {
295
+ maxHeight: '90%'
296
+ },
297
+ flexShrink: {
298
+ flexShrink: 1
299
+ },
300
+ iconBadge: {
301
+ alignItems: 'center',
302
+ borderRadius: 10,
303
+ height: 36,
304
+ justifyContent: 'center',
305
+ width: 36
306
+ },
307
+ overlay: {
308
+ alignItems: 'center',
309
+ backgroundColor: 'rgba(0,0,0,0.72)',
310
+ bottom: 0,
311
+ justifyContent: 'center',
312
+ left: 0,
313
+ position: 'absolute',
314
+ right: 0,
315
+ top: 0
316
+ },
317
+ overlayBody: { textAlign: 'center' },
318
+ overlayButton: { width: 160 },
319
+ overlayCard: {
320
+ alignItems: 'center',
321
+ borderRadius: 20,
322
+ borderWidth: 1,
323
+ gap: 16,
324
+ maxWidth: 360,
325
+ padding: 32
326
+ },
327
+ overlayTitle: { fontWeight: 'bold' },
328
+ scrollArea: {
329
+ marginBottom: 0
330
+ },
331
+ section: {
332
+ gap: 12
333
+ },
334
+ sectionLabel: {
335
+ letterSpacing: 2
336
+ },
337
+ // Negative margin cancels the padding so the icon still lines up with APPEARANCE/SOUND &
338
+ // HAPTICS below, while the ripple/hover highlight itself gets room to breathe on both sides
339
+ // instead of a flush edge-to-edge slab. overflow: 'hidden' makes sure that highlight actually
340
+ // clips to borderRadius instead of drawing as a plain rectangle.
341
+ toggleButton: {
342
+ borderRadius: 12,
343
+ marginHorizontal: -12,
344
+ overflow: 'hidden',
345
+ paddingHorizontal: 12,
346
+ paddingVertical: 10
347
+ },
348
+ // Same shape as toggleButton but `flex: 1` instead of its own `marginHorizontal: -12` — two of
349
+ // these side by side in toggleRow would otherwise both pull inward and collide in the middle.
350
+ // toggleRow carries that edge-alignment margin once, for the row as a whole, instead.
351
+ toggleButtonInRow: {
352
+ borderRadius: 12,
353
+ flex: 1,
354
+ overflow: 'hidden',
355
+ paddingHorizontal: 12,
356
+ paddingVertical: 10
357
+ },
358
+ toggleContent: {
359
+ alignItems: 'center',
360
+ flexDirection: 'row',
361
+ gap: 12
362
+ },
363
+ toggleGroup: {
364
+ gap: 4
365
+ },
366
+ toggleRow: {
367
+ flexDirection: 'row',
368
+ gap: 4,
369
+ marginHorizontal: -12
370
+ }
371
+ })
package/src/index.ts CHANGED
@@ -1,3 +1,4 @@
1
+ export { BaseSettingsDialog, type BaseSettingsDialogProps } from './BaseSettingsDialog'
1
2
  export { MONO_FONT } from './fonts'
2
3
  export { InlineColorPicker } from './InlineColorPicker'
3
4
  export { loadSkiaWeb } from './loadSkiaWeb'