@tastic/hud 0.12.0 → 0.14.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.
- package/README.md +169 -11
- package/dist/chunk-32U2OIRW.mjs +77 -0
- package/dist/guide/index.d.mts +118 -0
- package/dist/guide/index.d.ts +118 -0
- package/dist/guide/index.js +543 -0
- package/dist/guide/index.mjs +428 -0
- package/dist/index.d.mts +80 -9
- package/dist/index.d.ts +80 -9
- package/dist/index.js +417 -317
- package/dist/index.mjs +324 -296
- package/package.json +34 -7
- package/src/AchievementUnlockList.tsx +98 -0
- package/src/BaseSettingsDialog.tsx +179 -135
- package/src/ConfirmDialog.tsx +4 -43
- package/src/ContentGutter.tsx +12 -5
- package/src/RotatedFrame.tsx +71 -0
- package/src/guide/DirectionKeysHint.tsx +96 -0
- package/src/guide/GuideProvider.tsx +87 -0
- package/src/guide/HowToPlayDialog.tsx +224 -0
- package/src/guide/SeatDevicesHint.tsx +52 -0
- package/src/guide/SeatDiagram.tsx +72 -0
- package/src/guide/SwipeHint.tsx +115 -0
- package/src/guide/guideSlice.ts +131 -0
- package/src/guide/index.ts +14 -0
- package/src/guide/types.ts +16 -0
- package/src/index.ts +3 -1
- package/src/overlayCard.ts +51 -4
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tastic/hud",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.14.0",
|
|
4
4
|
"description": "Visual component kit for local-multiplayer React Native games: inline (non-modal) popovers, dropdowns, color pickers, gauges, ready buttons, and dialogs, built to stay scoped to one player's own zone on a shared screen",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"react-native",
|
|
@@ -34,6 +34,13 @@
|
|
|
34
34
|
"import": "./dist/index.mjs",
|
|
35
35
|
"require": "./dist/index.js"
|
|
36
36
|
},
|
|
37
|
+
"./guide": {
|
|
38
|
+
"react-native": "./src/guide/index.ts",
|
|
39
|
+
"browser": "./src/guide/index.ts",
|
|
40
|
+
"types": "./dist/guide/index.d.ts",
|
|
41
|
+
"import": "./dist/guide/index.mjs",
|
|
42
|
+
"require": "./dist/guide/index.js"
|
|
43
|
+
},
|
|
37
44
|
"./skia-gate": {
|
|
38
45
|
"react-native": "./src/SkiaGate.tsx",
|
|
39
46
|
"browser": "./src/SkiaGate.web.tsx",
|
|
@@ -73,13 +80,13 @@
|
|
|
73
80
|
"@infinitetoken/eslint-config": "^0.2.0",
|
|
74
81
|
"@infinitetoken/jest-config": "^0.4.0",
|
|
75
82
|
"@infinitetoken/tsconfig": "^0.5.0",
|
|
76
|
-
"@rific/auto-paper": "^0.12.
|
|
83
|
+
"@rific/auto-paper": "^0.12.3",
|
|
77
84
|
"@rific/core": "^0.2.0",
|
|
78
85
|
"@rific/feedback-press": "^0.12.0",
|
|
79
86
|
"@rific/updater": "^0.6.0",
|
|
80
87
|
"@shopify/react-native-skia": "^2.6.2",
|
|
81
88
|
"@tastic/achievements": "^0.2.0",
|
|
82
|
-
"@tastic/core": "^0.
|
|
89
|
+
"@tastic/core": "^0.12.0",
|
|
83
90
|
"@tastic/profile": "^0.7.0",
|
|
84
91
|
"@tastic/split-screen": "^0.5.0",
|
|
85
92
|
"@testing-library/dom": "^10.4.1",
|
|
@@ -102,12 +109,12 @@
|
|
|
102
109
|
"typescript": "^6.0.3"
|
|
103
110
|
},
|
|
104
111
|
"peerDependencies": {
|
|
105
|
-
"@rific/auto-paper": ">=0.
|
|
106
|
-
"@rific/feedback-press": ">=0.10.
|
|
112
|
+
"@rific/auto-paper": ">=0.12.2",
|
|
113
|
+
"@rific/feedback-press": ">=0.10.2",
|
|
107
114
|
"@rific/updater": ">=0.4.0",
|
|
108
115
|
"@shopify/react-native-skia": ">=1.5.0",
|
|
109
|
-
"@tastic/achievements": ">=0.
|
|
110
|
-
"@tastic/core": ">=0.
|
|
116
|
+
"@tastic/achievements": ">=0.2.0",
|
|
117
|
+
"@tastic/core": ">=0.12.0",
|
|
111
118
|
"@tastic/profile": ">=0.4.0",
|
|
112
119
|
"@tastic/split-screen": ">=0.5.0",
|
|
113
120
|
"react": ">=19.0.0",
|
|
@@ -116,6 +123,26 @@
|
|
|
116
123
|
"react-native-reanimated": ">=3.0.0",
|
|
117
124
|
"react-native-safe-area-context": ">=5.0.0"
|
|
118
125
|
},
|
|
126
|
+
"peerDependenciesMeta": {
|
|
127
|
+
"@rific/updater": {
|
|
128
|
+
"optional": true
|
|
129
|
+
},
|
|
130
|
+
"@shopify/react-native-skia": {
|
|
131
|
+
"optional": true
|
|
132
|
+
},
|
|
133
|
+
"@tastic/achievements": {
|
|
134
|
+
"optional": true
|
|
135
|
+
},
|
|
136
|
+
"@tastic/core": {
|
|
137
|
+
"optional": true
|
|
138
|
+
},
|
|
139
|
+
"@tastic/profile": {
|
|
140
|
+
"optional": true
|
|
141
|
+
},
|
|
142
|
+
"@tastic/split-screen": {
|
|
143
|
+
"optional": true
|
|
144
|
+
}
|
|
145
|
+
},
|
|
119
146
|
"publishConfig": {
|
|
120
147
|
"access": "public"
|
|
121
148
|
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { getContrastColor, useAutoPaperTheme } from '@rific/auto-paper'
|
|
2
|
+
import type { AchievementTier } from '@tastic/achievements'
|
|
3
|
+
import { type Profile, ProfileChip } from '@tastic/profile'
|
|
4
|
+
import { StyleSheet, View, type ViewStyle } from 'react-native'
|
|
5
|
+
import { Icon, Text } from 'react-native-paper'
|
|
6
|
+
|
|
7
|
+
// The only fields a row reads - structural, so any app's own AchievementDefinition<Stats> fits
|
|
8
|
+
// without this package caring about its stats type parameter.
|
|
9
|
+
export interface UnlockedAchievement {
|
|
10
|
+
id: string
|
|
11
|
+
icon: string
|
|
12
|
+
title: string
|
|
13
|
+
tier: AchievementTier
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// Who a seat's rows belong to: their ProfileChip when a profile is selected, otherwise a plain
|
|
17
|
+
// avatar in the seat's own color (a guest seat has no tag to show). An app that draws profile colors
|
|
18
|
+
// muted wraps its tree in @tastic/profile's ProfileColorProvider (ProfileChip reads it) and passes
|
|
19
|
+
// an already-muted `color` here.
|
|
20
|
+
export interface AchievementUnlockOwner {
|
|
21
|
+
profile?: Profile | null
|
|
22
|
+
color: string
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
interface Props<Seat extends number> {
|
|
26
|
+
unlocks: Partial<Record<Seat, UnlockedAchievement[]>>
|
|
27
|
+
// Row order: every unlock of the first seat, then the next.
|
|
28
|
+
seats: readonly Seat[]
|
|
29
|
+
owners: Record<Seat, AchievementUnlockOwner>
|
|
30
|
+
// Taken as a prop rather than imported: @tastic/achievements stays a type-only dependency of
|
|
31
|
+
// this package. Callers pass its ACHIEVEMENT_TIER_COLORS (or their own palette).
|
|
32
|
+
tierColors: Record<AchievementTier, string>
|
|
33
|
+
fg?: string
|
|
34
|
+
style?: ViewStyle
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const AVATAR_SIZE = 18
|
|
38
|
+
|
|
39
|
+
// The newly-unlocked achievements on a game-over / round-over card, one row per achievement:
|
|
40
|
+
// owner avatar, tier-colored icon badge, title. Extracted from the per-app copies in Snake,
|
|
41
|
+
// LightCycles, Pong, BoxHockey and AirHockey, which had drifted to three different owner markers;
|
|
42
|
+
// this is the ProfileChip-or-color-avatar one most of them used. One row per achievement rather
|
|
43
|
+
// than a per-seat summary, so the list stays aligned when only one seat unlocked anything.
|
|
44
|
+
// Renders nothing when there are no unlocks at all.
|
|
45
|
+
export function AchievementUnlockList<Seat extends number>({ unlocks, seats, owners, tierColors, fg: fgOverride, style }: Props<Seat>) {
|
|
46
|
+
const { dark } = useAutoPaperTheme()
|
|
47
|
+
const fg = fgOverride ?? (dark ? '#FFFFFF' : '#000000')
|
|
48
|
+
const rows = seats.flatMap((seat) => (unlocks[seat] ?? []).map((achievement) => ({ seat, achievement })))
|
|
49
|
+
if (rows.length === 0) return null
|
|
50
|
+
|
|
51
|
+
return (
|
|
52
|
+
<View style={[styles.list, style]}>
|
|
53
|
+
{rows.map(({ seat, achievement }) => {
|
|
54
|
+
const owner = owners[seat]
|
|
55
|
+
return (
|
|
56
|
+
<View key={`${seat}-${achievement.id}`} style={styles.row}>
|
|
57
|
+
{owner.profile ? (
|
|
58
|
+
<ProfileChip profile={owner.profile} size={AVATAR_SIZE} />
|
|
59
|
+
) : (
|
|
60
|
+
<View style={[styles.circle, { backgroundColor: owner.color }]}>
|
|
61
|
+
<Icon source='account' size={10} color={getContrastColor(owner.color)} />
|
|
62
|
+
</View>
|
|
63
|
+
)}
|
|
64
|
+
<View style={[styles.circle, { backgroundColor: tierColors[achievement.tier] }]}>
|
|
65
|
+
<Icon source={achievement.icon} size={12} color='#000000' />
|
|
66
|
+
</View>
|
|
67
|
+
<Text style={[styles.label, { color: fg }]} numberOfLines={1}>
|
|
68
|
+
{achievement.title}
|
|
69
|
+
</Text>
|
|
70
|
+
</View>
|
|
71
|
+
)
|
|
72
|
+
})}
|
|
73
|
+
</View>
|
|
74
|
+
)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const styles = StyleSheet.create({
|
|
78
|
+
circle: {
|
|
79
|
+
alignItems: 'center',
|
|
80
|
+
borderRadius: AVATAR_SIZE / 2,
|
|
81
|
+
height: AVATAR_SIZE,
|
|
82
|
+
justifyContent: 'center',
|
|
83
|
+
width: AVATAR_SIZE
|
|
84
|
+
},
|
|
85
|
+
label: {
|
|
86
|
+
flexShrink: 1,
|
|
87
|
+
fontSize: 12,
|
|
88
|
+
fontWeight: '700'
|
|
89
|
+
},
|
|
90
|
+
list: {
|
|
91
|
+
gap: 6
|
|
92
|
+
},
|
|
93
|
+
row: {
|
|
94
|
+
alignItems: 'center',
|
|
95
|
+
flexDirection: 'row',
|
|
96
|
+
gap: 6
|
|
97
|
+
}
|
|
98
|
+
})
|
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
import { AutoAppearancePicker, Dialog, useAutoPaperTheme } from '@rific/auto-paper'
|
|
2
2
|
import { Button, SoundContext, TouchableRipple, useHapticSettings, useSoundSettings, useVibration } from '@rific/feedback-press'
|
|
3
3
|
import { useUpdater } from '@rific/updater'
|
|
4
|
-
import {
|
|
4
|
+
import { useIsTouchPrimaryDevice, useRotation, type ViewRotation } from '@tastic/core'
|
|
5
5
|
import { ReactNode, useContext, useState } from 'react'
|
|
6
6
|
import { Platform, ScrollView, StyleSheet, View } from 'react-native'
|
|
7
|
-
import { Icon, Text } from 'react-native-paper'
|
|
7
|
+
import { Icon, Portal, Text } from 'react-native-paper'
|
|
8
8
|
|
|
9
9
|
import { ConfirmDialog } from './ConfirmDialog'
|
|
10
|
+
import { RotatedFrame } from './RotatedFrame'
|
|
10
11
|
import { useUpdateConfirmBridge } from './useUpdateConfirmBridge'
|
|
11
12
|
|
|
12
13
|
interface SettingIconProps {
|
|
@@ -29,11 +30,11 @@ function SettingIcon({ source, color, containerColor }: SettingIconProps) {
|
|
|
29
30
|
export interface BaseSettingsDialogProps {
|
|
30
31
|
visible: boolean
|
|
31
32
|
onDismiss: () => void
|
|
32
|
-
// Explicit override for the live physical-hold rotation (see @tastic/core's useRotation)
|
|
33
|
+
// Explicit override for the live physical-hold rotation (see @tastic/core's useRotation). This is
|
|
33
34
|
// a centered, app-wide modal with no per-player zone to match (unlike an in-game round-over
|
|
34
|
-
// dialog), so it
|
|
35
|
-
//
|
|
36
|
-
//
|
|
35
|
+
// dialog), so it lays itself out in its own frame turned by this angle (see the return below).
|
|
36
|
+
// Defaults to a live ambient read via useRotation() when omitted. Pass 0 when the dialog already
|
|
37
|
+
// renders under a Portal.Host inside a turned frame of the caller's own (Solitaire's screens).
|
|
37
38
|
rotation?: ViewRotation
|
|
38
39
|
// Each app's own OTA version (e.g. release.otaVersion) — not something this package can read
|
|
39
40
|
// itself, since every consuming app tracks its own release.ts independently. Accepts a number too
|
|
@@ -67,6 +68,10 @@ export interface BaseSettingsDialogProps {
|
|
|
67
68
|
// toast system wired up; the check itself still runs and still reports success/no-update through
|
|
68
69
|
// the ordinary info overlay either way. Meaningless (never called) when hideUpdateCheck is set.
|
|
69
70
|
onUpdateError?: (message: string) => void
|
|
71
|
+
// Omit to hide the row entirely (a game with no how-to-play flow, or a screen where replaying one
|
|
72
|
+
// would be disruptive — e.g. over a live match). Called AFTER this dialog's own onDismiss, so the
|
|
73
|
+
// two overlays never stack: wire it straight to @tastic/hud/guide's `useGuide().open`.
|
|
74
|
+
onShowHowToPlay?: () => void
|
|
70
75
|
// App-specific extra settings sections (board options, CPU difficulty, stats backup, ...),
|
|
71
76
|
// rendered between Appearance and Check for Updates — this component only owns the settings
|
|
72
77
|
// every game shares, never anything about how a particular game plays.
|
|
@@ -80,7 +85,7 @@ export interface BaseSettingsDialogProps {
|
|
|
80
85
|
// is the seam for whatever isn't shared: a game with its own board/difficulty settings (or a stats
|
|
81
86
|
// backup flow, or anything else genuinely specific to it) renders those below Appearance, not by
|
|
82
87
|
// forking this component.
|
|
83
|
-
export function BaseSettingsDialog({ visible, onDismiss, rotation: rotationOverride, version, lockOrientation, onLockOrientationChange, deferBottomEdgeGestures, onDeferBottomEdgeGestures, hideSound = false, hideHaptics = false, hideAppearance = false, hideUpdateCheck = false, onUpdateError, children }: BaseSettingsDialogProps) {
|
|
88
|
+
export function BaseSettingsDialog({ visible, onDismiss, rotation: rotationOverride, version, lockOrientation, onLockOrientationChange, deferBottomEdgeGestures, onDeferBottomEdgeGestures, hideSound = false, hideHaptics = false, hideAppearance = false, hideUpdateCheck = false, onUpdateError, onShowHowToPlay, children }: BaseSettingsDialogProps) {
|
|
84
89
|
const { colors } = useAutoPaperTheme()
|
|
85
90
|
const ambientRotation = useRotation()
|
|
86
91
|
const rotation = rotationOverride ?? ambientRotation
|
|
@@ -126,148 +131,187 @@ export function BaseSettingsDialog({ visible, onDismiss, rotation: rotationOverr
|
|
|
126
131
|
const showAppearance = !hideAppearance
|
|
127
132
|
const showUpdateCheck = Platform.OS !== 'web' && !hideUpdateCheck
|
|
128
133
|
|
|
134
|
+
// Laid out in its own turned frame rather than rotated in place. Paper's Portal mounts at the app
|
|
135
|
+
// root, full screen and portrait-shaped, so a sheet that only rotated its card kept resolving its
|
|
136
|
+
// maxHeight against the PORTRAIT height: turned 90deg it ran past the ~402pt short edge, and the top
|
|
137
|
+
// row (Lock Orientation, the one you need to get back out of landscape) was clipped with no way to
|
|
138
|
+
// scroll to it. So the whole output goes through one Portal to the root, into a RotatedFrame sized
|
|
139
|
+
// to the turned footprint, with rotated safe-area insets and its own Portal.Host. The Dialog and the
|
|
140
|
+
// two ConfirmDialogs below portal into THAT host, so they lay out against the turned window with no
|
|
141
|
+
// transform of their own (hence rotation={0} on the ConfirmDialogs). The frame is pass-through at
|
|
142
|
+
// every level and stays mounted with this component, which keeps the Dialog's close fade; at
|
|
143
|
+
// rotation 0 it is an identity frame.
|
|
129
144
|
return (
|
|
130
|
-
|
|
131
|
-
<
|
|
132
|
-
<Dialog.
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
<
|
|
138
|
-
{
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
{
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
<
|
|
151
|
-
<
|
|
152
|
-
|
|
153
|
-
<
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
</TouchableRipple>
|
|
162
|
-
)}
|
|
163
|
-
|
|
164
|
-
{/* iOS only — mirrors into UserDefaults for @tastic/edge-guard's own config plugin
|
|
165
|
-
swizzle to read. Guards both edges — the top (Notification Center/Control Center)
|
|
166
|
-
and the bottom (home-indicator swipe, incidentally Reachability too), per that
|
|
167
|
-
package's own doc. Off by default: a deliberate opt-in for whoever's actually hit an
|
|
168
|
-
accidental-swipe-near-an-edge problem mid-game and come looking for a fix, not a
|
|
169
|
-
surprise every player gets by default. */}
|
|
170
|
-
{showEdgeGuard && (
|
|
171
|
-
<TouchableRipple onPress={() => onDeferBottomEdgeGestures!(!deferBottomEdgeGestures)} style={styles.toggleButton} accessibilityLabel={`Edge guard ${deferBottomEdgeGestures ? 'on' : 'off'}`}>
|
|
172
|
-
<View style={styles.toggleContent}>
|
|
173
|
-
<SettingIcon source={deferBottomEdgeGestures ? 'shield-check-outline' : 'shield-off-outline'} color={deferBottomEdgeGestures ? colors.secondary : colors.onSurfaceVariant} containerColor={deferBottomEdgeGestures ? colors.secondaryContainer : colors.surfaceVariant} />
|
|
174
|
-
<View style={styles.flexShrink}>
|
|
175
|
-
<Text variant='bodyLarge' style={{ color: colors.onSurface }}>
|
|
176
|
-
Edge Guard
|
|
177
|
-
</Text>
|
|
178
|
-
<Text variant='bodySmall' numberOfLines={1} style={{ color: colors.onSurfaceVariant }}>
|
|
179
|
-
Requires a second swipe
|
|
180
|
-
</Text>
|
|
145
|
+
<Portal>
|
|
146
|
+
<RotatedFrame rotation={rotation} rotatedSafeArea portalHost passThrough>
|
|
147
|
+
<Dialog visible={visible} onDismiss={onDismiss} style={styles.dialog}>
|
|
148
|
+
<Dialog.Title>Settings</Dialog.Title>
|
|
149
|
+
{/* react-native-paper's Dialog.ScrollArea has a fixed 24px marginBottom baked in (meant to
|
|
150
|
+
reserve room for a Dialog.Actions row below it) — overridden to 0 since this dialog has
|
|
151
|
+
no actions row, and that gap otherwise reads as unexplained empty footer space. */}
|
|
152
|
+
<Dialog.ScrollArea style={styles.scrollArea}>
|
|
153
|
+
<ScrollView contentContainerStyle={styles.content} showsVerticalScrollIndicator={false}>
|
|
154
|
+
{/* Grouped tightly together (styles.toggleGroup's small internal gap, not the 24px gap
|
|
155
|
+
between sections below) — same bare-row shape for all of them, no segmented control
|
|
156
|
+
and no section heading (each row's own label already says what it is). Haptics only
|
|
157
|
+
joins on native — there's nothing for it to control on web. */}
|
|
158
|
+
<View style={styles.toggleGroup}>
|
|
159
|
+
{/* Omitted entirely for a portrait-only game (no lockOrientation/onLockOrientationChange
|
|
160
|
+
passed at all — nothing to lock), and hidden (not just disabled) on a desktop/laptop
|
|
161
|
+
browser even when they are — see isTouchPrimary's own doc above. Still shown on
|
|
162
|
+
native and on a touch-capable mobile browser, where a rotated window really does
|
|
163
|
+
reflect a rotated device. */}
|
|
164
|
+
{showLockOrientation && (
|
|
165
|
+
<TouchableRipple onPress={() => onLockOrientationChange!(!lockOrientation)} style={styles.toggleButton} accessibilityLabel={`Lock orientation ${lockOrientation ? 'on' : 'off'}`}>
|
|
166
|
+
<View style={styles.toggleContent}>
|
|
167
|
+
<SettingIcon source={lockOrientation ? 'lock' : 'lock-open-variant-outline'} color={lockOrientation ? colors.secondary : colors.onSurfaceVariant} containerColor={lockOrientation ? colors.secondaryContainer : colors.surfaceVariant} />
|
|
168
|
+
<View style={styles.flexShrink}>
|
|
169
|
+
<Text variant='bodyLarge' style={{ color: colors.onSurface }}>
|
|
170
|
+
Lock Orientation
|
|
171
|
+
</Text>
|
|
172
|
+
<Text variant='bodySmall' numberOfLines={1} style={{ color: colors.onSurfaceVariant }}>
|
|
173
|
+
Pins the current layout
|
|
174
|
+
</Text>
|
|
175
|
+
</View>
|
|
181
176
|
</View>
|
|
182
|
-
</
|
|
183
|
-
|
|
184
|
-
)}
|
|
177
|
+
</TouchableRipple>
|
|
178
|
+
)}
|
|
185
179
|
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
soundDisabled
|
|
198
|
-
onPress={() => {
|
|
199
|
-
const enabled = !soundSettings.enabled
|
|
200
|
-
setSoundSettings({ enabled })
|
|
201
|
-
if (enabled) sound.selection?.()
|
|
202
|
-
}}
|
|
203
|
-
style={styles.toggleButtonInRow}
|
|
204
|
-
accessibilityLabel={`Sound ${soundSettings.enabled ? 'on' : 'off'}`}
|
|
205
|
-
>
|
|
206
|
-
<View style={styles.toggleContent}>
|
|
207
|
-
<SettingIcon source={soundSettings.enabled ? 'volume-high' : 'volume-off'} color={soundSettings.enabled ? colors.tertiary : colors.onSurfaceVariant} containerColor={soundSettings.enabled ? colors.tertiaryContainer : colors.surfaceVariant} />
|
|
180
|
+
{/* iOS only — mirrors into UserDefaults for @tastic/edge-guard's own config plugin
|
|
181
|
+
swizzle to read. Guards both edges — the top (Notification Center/Control Center)
|
|
182
|
+
and the bottom (home-indicator swipe, incidentally Reachability too), per that
|
|
183
|
+
package's own doc. Off by default: a deliberate opt-in for whoever's actually hit an
|
|
184
|
+
accidental-swipe-near-an-edge problem mid-game and come looking for a fix, not a
|
|
185
|
+
surprise every player gets by default. */}
|
|
186
|
+
{showEdgeGuard && (
|
|
187
|
+
<TouchableRipple onPress={() => onDeferBottomEdgeGestures!(!deferBottomEdgeGestures)} style={styles.toggleButton} accessibilityLabel={`Edge guard ${deferBottomEdgeGestures ? 'on' : 'off'}`}>
|
|
188
|
+
<View style={styles.toggleContent}>
|
|
189
|
+
<SettingIcon source={deferBottomEdgeGestures ? 'shield-check-outline' : 'shield-off-outline'} color={deferBottomEdgeGestures ? colors.secondary : colors.onSurfaceVariant} containerColor={deferBottomEdgeGestures ? colors.secondaryContainer : colors.surfaceVariant} />
|
|
190
|
+
<View style={styles.flexShrink}>
|
|
208
191
|
<Text variant='bodyLarge' style={{ color: colors.onSurface }}>
|
|
209
|
-
|
|
192
|
+
Edge Guard
|
|
193
|
+
</Text>
|
|
194
|
+
<Text variant='bodySmall' numberOfLines={1} style={{ color: colors.onSurfaceVariant }}>
|
|
195
|
+
Requires a second swipe
|
|
210
196
|
</Text>
|
|
211
197
|
</View>
|
|
212
|
-
</
|
|
213
|
-
|
|
198
|
+
</View>
|
|
199
|
+
</TouchableRipple>
|
|
200
|
+
)}
|
|
214
201
|
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
202
|
+
{/* Side by side, not stacked — neither needs a full row's width (single-line label,
|
|
203
|
+
no description under it the way Lock Orientation has), so sharing one row reads
|
|
204
|
+
just as clearly and takes half the vertical space. toggleRow carries the same
|
|
205
|
+
edge-alignment negative margin toggleButton normally carries itself; the buttons
|
|
206
|
+
inside it use toggleButtonInRow (flex: 1) instead so the two don't double up on it.
|
|
207
|
+
The row itself only renders when at least one of the two will — an empty flex row
|
|
208
|
+
still eats its own gap/margin otherwise. */}
|
|
209
|
+
{(showSound || showHaptics) && (
|
|
210
|
+
<View style={styles.toggleRow}>
|
|
211
|
+
{showSound && (
|
|
212
|
+
<TouchableRipple
|
|
213
|
+
soundDisabled
|
|
214
|
+
onPress={() => {
|
|
215
|
+
const enabled = !soundSettings.enabled
|
|
216
|
+
setSoundSettings({ enabled })
|
|
217
|
+
if (enabled) sound.selection?.()
|
|
218
|
+
}}
|
|
219
|
+
style={styles.toggleButtonInRow}
|
|
220
|
+
accessibilityLabel={`Sound ${soundSettings.enabled ? 'on' : 'off'}`}
|
|
221
|
+
>
|
|
222
|
+
<View style={styles.toggleContent}>
|
|
223
|
+
<SettingIcon source={soundSettings.enabled ? 'volume-high' : 'volume-off'} color={soundSettings.enabled ? colors.tertiary : colors.onSurfaceVariant} containerColor={soundSettings.enabled ? colors.tertiaryContainer : colors.surfaceVariant} />
|
|
224
|
+
<Text variant='bodyLarge' style={{ color: colors.onSurface }}>
|
|
225
|
+
Sound
|
|
226
|
+
</Text>
|
|
227
|
+
</View>
|
|
228
|
+
</TouchableRipple>
|
|
229
|
+
)}
|
|
230
|
+
|
|
231
|
+
{/* Already native-only (see showHaptics's own definition) — hideHaptics can hide
|
|
232
|
+
it there too, for a game that's deliberately silent on vibration. */}
|
|
233
|
+
{showHaptics && (
|
|
234
|
+
<TouchableRipple
|
|
235
|
+
hapticDisabled
|
|
236
|
+
onPress={() => {
|
|
237
|
+
const vibrate = !hapticSettings.vibrate
|
|
238
|
+
setHapticSettings({ vibrate })
|
|
239
|
+
if (vibrate) forceHapticFeedback()
|
|
240
|
+
}}
|
|
241
|
+
style={styles.toggleButtonInRow}
|
|
242
|
+
accessibilityLabel={`Haptics ${hapticSettings.vibrate ? 'on' : 'off'}`}
|
|
243
|
+
>
|
|
244
|
+
<View style={styles.toggleContent}>
|
|
245
|
+
<SettingIcon source={hapticSettings.vibrate ? 'vibrate' : 'vibrate-off'} color={hapticSettings.vibrate ? colors.tertiary : colors.onSurfaceVariant} containerColor={hapticSettings.vibrate ? colors.tertiaryContainer : colors.surfaceVariant} />
|
|
246
|
+
<Text variant='bodyLarge' style={{ color: colors.onSurface }}>
|
|
247
|
+
Haptics
|
|
248
|
+
</Text>
|
|
249
|
+
</View>
|
|
250
|
+
</TouchableRipple>
|
|
251
|
+
)}
|
|
252
|
+
</View>
|
|
253
|
+
)}
|
|
254
|
+
|
|
255
|
+
{/* Not a toggle, but it reads like one of the rows above and belongs with them rather than
|
|
256
|
+
down in `children` (which sits below Appearance) — this is the "replay the intro"
|
|
257
|
+
entry point every game's own how-to-play flow shares. Dismisses this dialog first so
|
|
258
|
+
the guide's own overlay never opens on top of a still-visible Settings card. */}
|
|
259
|
+
{onShowHowToPlay && (
|
|
260
|
+
<TouchableRipple
|
|
261
|
+
onPress={() => {
|
|
262
|
+
onDismiss()
|
|
263
|
+
onShowHowToPlay()
|
|
264
|
+
}}
|
|
265
|
+
style={styles.toggleButton}
|
|
266
|
+
accessibilityLabel='How to Play'
|
|
267
|
+
>
|
|
268
|
+
<View style={styles.toggleContent}>
|
|
269
|
+
<SettingIcon source='help-circle-outline' color={colors.secondary} containerColor={colors.secondaryContainer} />
|
|
270
|
+
<View style={styles.flexShrink}>
|
|
230
271
|
<Text variant='bodyLarge' style={{ color: colors.onSurface }}>
|
|
231
|
-
|
|
272
|
+
How to Play
|
|
273
|
+
</Text>
|
|
274
|
+
<Text variant='bodySmall' numberOfLines={1} style={{ color: colors.onSurfaceVariant }}>
|
|
275
|
+
Replay the quick intro
|
|
232
276
|
</Text>
|
|
233
277
|
</View>
|
|
234
|
-
</
|
|
235
|
-
|
|
278
|
+
</View>
|
|
279
|
+
</TouchableRipple>
|
|
280
|
+
)}
|
|
281
|
+
</View>
|
|
282
|
+
|
|
283
|
+
{showAppearance && (
|
|
284
|
+
<View style={styles.section}>
|
|
285
|
+
<Text variant='labelMedium' style={[styles.sectionLabel, { color: colors.onSurfaceVariant }]}>
|
|
286
|
+
APPEARANCE
|
|
287
|
+
</Text>
|
|
288
|
+
<AutoAppearancePicker showLabels={false} />
|
|
236
289
|
</View>
|
|
237
290
|
)}
|
|
238
|
-
</View>
|
|
239
|
-
|
|
240
|
-
{showAppearance && (
|
|
241
|
-
<View style={styles.section}>
|
|
242
|
-
<Text variant='labelMedium' style={[styles.sectionLabel, { color: colors.onSurfaceVariant }]}>
|
|
243
|
-
APPEARANCE
|
|
244
|
-
</Text>
|
|
245
|
-
<AutoAppearancePicker showLabels={false} />
|
|
246
|
-
</View>
|
|
247
|
-
)}
|
|
248
291
|
|
|
249
|
-
|
|
292
|
+
{children}
|
|
250
293
|
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
294
|
+
{/* Native only (a web build has no OTA update concept at all, desktop or mobile browser
|
|
295
|
+
alike) — hideUpdateCheck can hide it there too, for a native app that isn't wired up
|
|
296
|
+
for EAS/OTA updates at all. */}
|
|
297
|
+
{showUpdateCheck && (
|
|
298
|
+
<View style={styles.section}>
|
|
299
|
+
<Text variant='labelSmall' style={[styles.sectionLabel, { color: colors.onSurfaceVariant }]}>
|
|
300
|
+
VERSION {version}
|
|
301
|
+
{updateReady ? ' · UPDATE READY' : ''}
|
|
302
|
+
</Text>
|
|
303
|
+
<Button mode='outlined' onPress={check} loading={checking} disabled={checking}>
|
|
304
|
+
Check for Updates
|
|
305
|
+
</Button>
|
|
306
|
+
</View>
|
|
307
|
+
)}
|
|
308
|
+
</ScrollView>
|
|
309
|
+
</Dialog.ScrollArea>
|
|
310
|
+
</Dialog>
|
|
311
|
+
<ConfirmDialog visible={!!updateManifest} title='Update Available' message={updateManifestDate ? `Released ${updateManifestDate.toLocaleDateString()} at ${updateManifestDate.toLocaleTimeString()}` : ''} confirmLabel='Restart' cancelLabel='Later' icon='update' rotation={0} onConfirm={() => respondToUpdateConfirm(true)} onCancel={() => respondToUpdateConfirm(false)} />
|
|
312
|
+
<ConfirmDialog visible={!!infoMessage} title={infoMessage?.title ?? ''} message={infoMessage?.message ?? ''} confirmLabel='OK' icon='information-outline' rotation={0} onConfirm={() => setInfoMessage(null)} />
|
|
313
|
+
</RotatedFrame>
|
|
314
|
+
</Portal>
|
|
271
315
|
)
|
|
272
316
|
}
|
|
273
317
|
|
package/src/ConfirmDialog.tsx
CHANGED
|
@@ -5,7 +5,7 @@ import { type ReactNode } from 'react'
|
|
|
5
5
|
import { StyleSheet, View } from 'react-native'
|
|
6
6
|
import { Icon, Portal, Text } from 'react-native-paper'
|
|
7
7
|
|
|
8
|
-
import { getOverlayCardColors, overlayStyles } from './overlayCard'
|
|
8
|
+
import { getOverlayCardColors, overlayActionStyles, overlayStyles } from './overlayCard'
|
|
9
9
|
|
|
10
10
|
export interface ConfirmDialogProps {
|
|
11
11
|
visible: boolean
|
|
@@ -55,13 +55,13 @@ export function ConfirmDialog({ visible, title, message, confirmLabel, cancelLab
|
|
|
55
55
|
<Text variant='bodyLarge' style={[styles.body, { color: fg }]}>
|
|
56
56
|
{message}
|
|
57
57
|
</Text>
|
|
58
|
-
<View style={
|
|
58
|
+
<View style={overlayActionStyles.actions}>
|
|
59
59
|
{showCancel && (
|
|
60
|
-
<Button mode='outlined' onPress={onCancel} style={
|
|
60
|
+
<Button mode='outlined' onPress={onCancel} style={overlayActionStyles.actionButton}>
|
|
61
61
|
{cancelLabel}
|
|
62
62
|
</Button>
|
|
63
63
|
)}
|
|
64
|
-
<Button mode='contained' onPress={onConfirm} style={showCancel ?
|
|
64
|
+
<Button mode='contained' onPress={onConfirm} style={showCancel ? overlayActionStyles.actionButton : overlayActionStyles.actionButtonSingle} buttonColor={accent} textColor={onAccent}>
|
|
65
65
|
{confirmLabel}
|
|
66
66
|
</Button>
|
|
67
67
|
</View>
|
|
@@ -72,45 +72,6 @@ export function ConfirmDialog({ visible, title, message, confirmLabel, cancelLab
|
|
|
72
72
|
}
|
|
73
73
|
|
|
74
74
|
const styles = StyleSheet.create({
|
|
75
|
-
// minWidth (not flexBasis) is each button's real floor - whenever both fit side by side, flexGrow
|
|
76
|
-
// still splits any leftover width evenly beyond that floor, same visual result as a plain flex: 1
|
|
77
|
-
// on a roomy screen, but a button can never be squeezed narrower than 128, only wrap to its own
|
|
78
|
-
// row instead (see actions' flexWrap below). This used to be flexBasis: 128, which measured
|
|
79
|
-
// correctly on web but rendered every button roughly SQUARE on iOS - confirmed live (see the
|
|
80
|
-
// "Quit Match?" Cancel/Quit buttons and the single-button "OK" dialogs both coming out ~128pt
|
|
81
|
-
// tall instead of wide). Root cause: react-native-paper's Button renders its Surface as two
|
|
82
|
-
// nested views on iOS only (Surface.tsx's SurfaceIOS, for shadow rendering) - only a curated
|
|
83
|
-
// allowlist of style keys (flex/flexGrow/flexShrink/width/height/position/... - notably NOT
|
|
84
|
-
// flexBasis) reaches the outer view, which is the real flex item inside this row; every other
|
|
85
|
-
// key, flexBasis included, falls through to an inner view whose own unspecified flexDirection
|
|
86
|
-
// defaults to column. flexGrow: 1 landed on the (row-context) outer view and grew width
|
|
87
|
-
// correctly; flexBasis: 128 landed on the (column-context) inner view instead, where a flexBasis
|
|
88
|
-
// sets HEIGHT, not width. minWidth isn't axis-relative like flexBasis, so it constrains width
|
|
89
|
-
// correctly regardless of which of the two views it ends up on. react-native-paper's Button also
|
|
90
|
-
// hardcodes numberOfLines={1} on its label with no override, so a button squeezed under its
|
|
91
|
-
// label's natural width silently ellipsizes ("Cancel" -> "Can...") instead of wrapping - assumed
|
|
92
|
-
// fixable by just giving the row more width, until confirmed live that these two-word labels
|
|
93
|
-
// (Cancel/Reset/New Game) can still lose the truncation fight even with the extra room card's own
|
|
94
|
-
// width fix below provides, on a narrow enough screen.
|
|
95
|
-
actionButton: {
|
|
96
|
-
flexGrow: 1,
|
|
97
|
-
minWidth: 128
|
|
98
|
-
},
|
|
99
|
-
// A lone confirm button (no cancel - BaseSettingsDialog's info-only "OK" dialogs, e.g. the
|
|
100
|
-
// update-check "no update found" message) has nothing left to share actions' width with, so
|
|
101
|
-
// actionButton's own flexGrow: 1 would stretch it across the entire row instead of reading as a
|
|
102
|
-
// normal button. Same 128 floor, no flexGrow, so it stays at that natural size instead of filling
|
|
103
|
-
// the card - centered by actions' own justifyContent below.
|
|
104
|
-
actionButtonSingle: {
|
|
105
|
-
minWidth: 128
|
|
106
|
-
},
|
|
107
|
-
actions: {
|
|
108
|
-
flexDirection: 'row',
|
|
109
|
-
flexWrap: 'wrap',
|
|
110
|
-
gap: 12,
|
|
111
|
-
justifyContent: 'center',
|
|
112
|
-
width: '100%'
|
|
113
|
-
},
|
|
114
75
|
body: {
|
|
115
76
|
textAlign: 'center'
|
|
116
77
|
},
|