@tastic/hud 0.13.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 +39 -2
- package/dist/index.d.mts +35 -1
- package/dist/index.d.ts +35 -1
- package/dist/index.js +293 -261
- package/dist/index.mjs +214 -183
- package/package.json +5 -5
- package/src/BaseSettingsDialog.tsx +174 -161
- package/src/RotatedFrame.tsx +71 -0
- package/src/index.ts +1 -0
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",
|
|
@@ -80,13 +80,13 @@
|
|
|
80
80
|
"@infinitetoken/eslint-config": "^0.2.0",
|
|
81
81
|
"@infinitetoken/jest-config": "^0.4.0",
|
|
82
82
|
"@infinitetoken/tsconfig": "^0.5.0",
|
|
83
|
-
"@rific/auto-paper": "^0.12.
|
|
83
|
+
"@rific/auto-paper": "^0.12.3",
|
|
84
84
|
"@rific/core": "^0.2.0",
|
|
85
85
|
"@rific/feedback-press": "^0.12.0",
|
|
86
86
|
"@rific/updater": "^0.6.0",
|
|
87
87
|
"@shopify/react-native-skia": "^2.6.2",
|
|
88
88
|
"@tastic/achievements": "^0.2.0",
|
|
89
|
-
"@tastic/core": "^0.
|
|
89
|
+
"@tastic/core": "^0.12.0",
|
|
90
90
|
"@tastic/profile": "^0.7.0",
|
|
91
91
|
"@tastic/split-screen": "^0.5.0",
|
|
92
92
|
"@testing-library/dom": "^10.4.1",
|
|
@@ -109,12 +109,12 @@
|
|
|
109
109
|
"typescript": "^6.0.3"
|
|
110
110
|
},
|
|
111
111
|
"peerDependencies": {
|
|
112
|
-
"@rific/auto-paper": ">=0.
|
|
112
|
+
"@rific/auto-paper": ">=0.12.2",
|
|
113
113
|
"@rific/feedback-press": ">=0.10.2",
|
|
114
114
|
"@rific/updater": ">=0.4.0",
|
|
115
115
|
"@shopify/react-native-skia": ">=1.5.0",
|
|
116
116
|
"@tastic/achievements": ">=0.2.0",
|
|
117
|
-
"@tastic/core": ">=0.
|
|
117
|
+
"@tastic/core": ">=0.12.0",
|
|
118
118
|
"@tastic/profile": ">=0.4.0",
|
|
119
119
|
"@tastic/split-screen": ">=0.5.0",
|
|
120
120
|
"react": ">=19.0.0",
|
|
@@ -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
|
|
@@ -130,175 +131,187 @@ export function BaseSettingsDialog({ visible, onDismiss, rotation: rotationOverr
|
|
|
130
131
|
const showAppearance = !hideAppearance
|
|
131
132
|
const showUpdateCheck = Platform.OS !== 'web' && !hideUpdateCheck
|
|
132
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.
|
|
133
144
|
return (
|
|
134
|
-
|
|
135
|
-
<
|
|
136
|
-
<Dialog.
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
<
|
|
142
|
-
{
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
{
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
<
|
|
155
|
-
<
|
|
156
|
-
|
|
157
|
-
<
|
|
158
|
-
Lock Orientation
|
|
159
|
-
</Text>
|
|
160
|
-
<Text variant='bodySmall' numberOfLines={1} style={{ color: colors.onSurfaceVariant }}>
|
|
161
|
-
Pins the current layout
|
|
162
|
-
</Text>
|
|
163
|
-
</View>
|
|
164
|
-
</View>
|
|
165
|
-
</TouchableRipple>
|
|
166
|
-
)}
|
|
167
|
-
|
|
168
|
-
{/* iOS only — mirrors into UserDefaults for @tastic/edge-guard's own config plugin
|
|
169
|
-
swizzle to read. Guards both edges — the top (Notification Center/Control Center)
|
|
170
|
-
and the bottom (home-indicator swipe, incidentally Reachability too), per that
|
|
171
|
-
package's own doc. Off by default: a deliberate opt-in for whoever's actually hit an
|
|
172
|
-
accidental-swipe-near-an-edge problem mid-game and come looking for a fix, not a
|
|
173
|
-
surprise every player gets by default. */}
|
|
174
|
-
{showEdgeGuard && (
|
|
175
|
-
<TouchableRipple onPress={() => onDeferBottomEdgeGestures!(!deferBottomEdgeGestures)} style={styles.toggleButton} accessibilityLabel={`Edge guard ${deferBottomEdgeGestures ? 'on' : 'off'}`}>
|
|
176
|
-
<View style={styles.toggleContent}>
|
|
177
|
-
<SettingIcon source={deferBottomEdgeGestures ? 'shield-check-outline' : 'shield-off-outline'} color={deferBottomEdgeGestures ? colors.secondary : colors.onSurfaceVariant} containerColor={deferBottomEdgeGestures ? colors.secondaryContainer : colors.surfaceVariant} />
|
|
178
|
-
<View style={styles.flexShrink}>
|
|
179
|
-
<Text variant='bodyLarge' style={{ color: colors.onSurface }}>
|
|
180
|
-
Edge Guard
|
|
181
|
-
</Text>
|
|
182
|
-
<Text variant='bodySmall' numberOfLines={1} style={{ color: colors.onSurfaceVariant }}>
|
|
183
|
-
Requires a second swipe
|
|
184
|
-
</Text>
|
|
185
|
-
</View>
|
|
186
|
-
</View>
|
|
187
|
-
</TouchableRipple>
|
|
188
|
-
)}
|
|
189
|
-
|
|
190
|
-
{/* Side by side, not stacked — neither needs a full row's width (single-line label,
|
|
191
|
-
no description under it the way Lock Orientation has), so sharing one row reads
|
|
192
|
-
just as clearly and takes half the vertical space. toggleRow carries the same
|
|
193
|
-
edge-alignment negative margin toggleButton normally carries itself; the buttons
|
|
194
|
-
inside it use toggleButtonInRow (flex: 1) instead so the two don't double up on it.
|
|
195
|
-
The row itself only renders when at least one of the two will — an empty flex row
|
|
196
|
-
still eats its own gap/margin otherwise. */}
|
|
197
|
-
{(showSound || showHaptics) && (
|
|
198
|
-
<View style={styles.toggleRow}>
|
|
199
|
-
{showSound && (
|
|
200
|
-
<TouchableRipple
|
|
201
|
-
soundDisabled
|
|
202
|
-
onPress={() => {
|
|
203
|
-
const enabled = !soundSettings.enabled
|
|
204
|
-
setSoundSettings({ enabled })
|
|
205
|
-
if (enabled) sound.selection?.()
|
|
206
|
-
}}
|
|
207
|
-
style={styles.toggleButtonInRow}
|
|
208
|
-
accessibilityLabel={`Sound ${soundSettings.enabled ? 'on' : 'off'}`}
|
|
209
|
-
>
|
|
210
|
-
<View style={styles.toggleContent}>
|
|
211
|
-
<SettingIcon source={soundSettings.enabled ? 'volume-high' : 'volume-off'} color={soundSettings.enabled ? colors.tertiary : colors.onSurfaceVariant} containerColor={soundSettings.enabled ? colors.tertiaryContainer : colors.surfaceVariant} />
|
|
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}>
|
|
212
169
|
<Text variant='bodyLarge' style={{ color: colors.onSurface }}>
|
|
213
|
-
|
|
170
|
+
Lock Orientation
|
|
171
|
+
</Text>
|
|
172
|
+
<Text variant='bodySmall' numberOfLines={1} style={{ color: colors.onSurfaceVariant }}>
|
|
173
|
+
Pins the current layout
|
|
214
174
|
</Text>
|
|
215
175
|
</View>
|
|
216
|
-
</
|
|
217
|
-
|
|
176
|
+
</View>
|
|
177
|
+
</TouchableRipple>
|
|
178
|
+
)}
|
|
218
179
|
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
}}
|
|
229
|
-
style={styles.
|
|
230
|
-
accessibilityLabel={`Haptics ${hapticSettings.vibrate ? 'on' : 'off'}`}
|
|
231
|
-
>
|
|
232
|
-
<View style={styles.toggleContent}>
|
|
233
|
-
<SettingIcon source={hapticSettings.vibrate ? 'vibrate' : 'vibrate-off'} color={hapticSettings.vibrate ? colors.tertiary : colors.onSurfaceVariant} containerColor={hapticSettings.vibrate ? 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}>
|
|
234
191
|
<Text variant='bodyLarge' style={{ color: colors.onSurface }}>
|
|
235
|
-
|
|
192
|
+
Edge Guard
|
|
193
|
+
</Text>
|
|
194
|
+
<Text variant='bodySmall' numberOfLines={1} style={{ color: colors.onSurfaceVariant }}>
|
|
195
|
+
Requires a second swipe
|
|
236
196
|
</Text>
|
|
237
197
|
</View>
|
|
238
|
-
</TouchableRipple>
|
|
239
|
-
)}
|
|
240
|
-
</View>
|
|
241
|
-
)}
|
|
242
|
-
|
|
243
|
-
{/* Not a toggle, but it reads like one of the rows above and belongs with them rather than
|
|
244
|
-
down in `children` (which sits below Appearance) — this is the "replay the intro"
|
|
245
|
-
entry point every game's own how-to-play flow shares. Dismisses this dialog first so
|
|
246
|
-
the guide's own overlay never opens on top of a still-visible Settings card. */}
|
|
247
|
-
{onShowHowToPlay && (
|
|
248
|
-
<TouchableRipple
|
|
249
|
-
onPress={() => {
|
|
250
|
-
onDismiss()
|
|
251
|
-
onShowHowToPlay()
|
|
252
|
-
}}
|
|
253
|
-
style={styles.toggleButton}
|
|
254
|
-
accessibilityLabel='How to Play'
|
|
255
|
-
>
|
|
256
|
-
<View style={styles.toggleContent}>
|
|
257
|
-
<SettingIcon source='help-circle-outline' color={colors.secondary} containerColor={colors.secondaryContainer} />
|
|
258
|
-
<View style={styles.flexShrink}>
|
|
259
|
-
<Text variant='bodyLarge' style={{ color: colors.onSurface }}>
|
|
260
|
-
How to Play
|
|
261
|
-
</Text>
|
|
262
|
-
<Text variant='bodySmall' numberOfLines={1} style={{ color: colors.onSurfaceVariant }}>
|
|
263
|
-
Replay the quick intro
|
|
264
|
-
</Text>
|
|
265
198
|
</View>
|
|
199
|
+
</TouchableRipple>
|
|
200
|
+
)}
|
|
201
|
+
|
|
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
|
+
)}
|
|
266
252
|
</View>
|
|
267
|
-
|
|
268
|
-
)}
|
|
269
|
-
</View>
|
|
253
|
+
)}
|
|
270
254
|
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
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}>
|
|
271
|
+
<Text variant='bodyLarge' style={{ color: colors.onSurface }}>
|
|
272
|
+
How to Play
|
|
273
|
+
</Text>
|
|
274
|
+
<Text variant='bodySmall' numberOfLines={1} style={{ color: colors.onSurfaceVariant }}>
|
|
275
|
+
Replay the quick intro
|
|
276
|
+
</Text>
|
|
277
|
+
</View>
|
|
278
|
+
</View>
|
|
279
|
+
</TouchableRipple>
|
|
280
|
+
)}
|
|
277
281
|
</View>
|
|
278
|
-
)}
|
|
279
282
|
|
|
280
|
-
|
|
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} />
|
|
289
|
+
</View>
|
|
290
|
+
)}
|
|
281
291
|
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
292
|
+
{children}
|
|
293
|
+
|
|
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>
|
|
302
315
|
)
|
|
303
316
|
}
|
|
304
317
|
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { FakeLandscapeView, rotateDimensions, rotateInsets, useRotation, type ViewRotation } from '@tastic/core'
|
|
2
|
+
import { type ReactNode, useMemo } from 'react'
|
|
3
|
+
import { StyleSheet, useWindowDimensions, View } from 'react-native'
|
|
4
|
+
import { Portal } from 'react-native-paper'
|
|
5
|
+
import { SafeAreaInsetsContext, useSafeAreaInsets } from 'react-native-safe-area-context'
|
|
6
|
+
|
|
7
|
+
export interface RotatedFrameProps {
|
|
8
|
+
children: ReactNode
|
|
9
|
+
/** The angle to turn the frame by. Defaults to the live physical hold (@tastic/core's useRotation); pass it
|
|
10
|
+
* when the caller already has the exact angle it wants, e.g. a dialog handed its caller's rotation, or 0 for
|
|
11
|
+
* one that already sits inside a turned frame. */
|
|
12
|
+
rotation?: ViewRotation
|
|
13
|
+
/** Give everything inside the frame the safe-area insets already remapped onto the frame's own edges (see
|
|
14
|
+
* @tastic/core's rotateInsets), so a component that reads `useSafeAreaInsets()` itself (a library screen,
|
|
15
|
+
* Paper's Modal, a toaster) lands on the right edge when the frame is turned. Leave it off for content that
|
|
16
|
+
* rotates the insets itself, which would otherwise rotate them twice. */
|
|
17
|
+
rotatedSafeArea?: boolean
|
|
18
|
+
/** Mount a Paper Portal.Host INSIDE the frame, so a dialog (Paper's Portal otherwise mounts at the app root,
|
|
19
|
+
* outside the frame) is laid out against the frame's footprint and turned with it. The dialog must then be
|
|
20
|
+
* told `rotation={0}`. */
|
|
21
|
+
portalHost?: boolean
|
|
22
|
+
/** Let touches fall through the frame to whatever is beneath it, so only its children claim them. For a frame
|
|
23
|
+
* laid OVER other content (a dialog layer, a root-level overlay); a screen that owns the window leaves it off. */
|
|
24
|
+
passThrough?: boolean
|
|
25
|
+
/** Cap on the content column's width in the WIDE layout only (the rotated footprint is landscape), centred: a
|
|
26
|
+
* stats list or a roster stretched across an 874pt-wide landscape screen reads badly. Portrait is never capped,
|
|
27
|
+
* so a portrait iPad or desktop window keeps its full width. Omit for no cap. */
|
|
28
|
+
maxContentWidth?: number
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** A frame turned to follow the phone while the OS stays portrait-locked (see @tastic/core's FakeLandscapeView),
|
|
32
|
+
* sized to the turned footprint so its content lays out against the short edge at +/-90. The tree is IDENTICAL
|
|
33
|
+
* at every angle, so a rotation never remounts what is inside: `FakeLandscapeView > SafeArea provider >
|
|
34
|
+
* [Portal.Host] > column`. `rotation`, `rotatedSafeArea`, `passThrough` and `maxContentWidth` only change styles
|
|
35
|
+
* and values, but `portalHost` adds or removes the Portal.Host element, so it must be STATIC per call site
|
|
36
|
+
* (flipping it at runtime would remount everything inside).
|
|
37
|
+
*
|
|
38
|
+
* Safe for tap-driven content only: react-native-gesture-handler's translation deltas are untransformed, so
|
|
39
|
+
* never put a gesture-tracked game board inside it. */
|
|
40
|
+
export function RotatedFrame({ children, rotation: rotationOverride, rotatedSafeArea = false, portalHost = false, passThrough = false, maxContentWidth }: RotatedFrameProps) {
|
|
41
|
+
const ambientRotation = useRotation()
|
|
42
|
+
const rotation = rotationOverride ?? ambientRotation
|
|
43
|
+
const window = useWindowDimensions()
|
|
44
|
+
const { width, height } = rotateDimensions(window.width, window.height, rotation)
|
|
45
|
+
const rawInsets = useSafeAreaInsets()
|
|
46
|
+
const insets = useMemo(() => (rotatedSafeArea ? rotateInsets(rawInsets, rotation) : rawInsets), [rotatedSafeArea, rawInsets, rotation])
|
|
47
|
+
const cappedWidth = maxContentWidth !== undefined && width > height ? maxContentWidth : undefined
|
|
48
|
+
const column = (
|
|
49
|
+
<View style={[styles.column, cappedWidth !== undefined && { maxWidth: cappedWidth }]} pointerEvents={passThrough ? 'box-none' : undefined}>
|
|
50
|
+
{children}
|
|
51
|
+
</View>
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
return (
|
|
55
|
+
<FakeLandscapeView rotation={rotation} passThrough={passThrough} style={styles.root}>
|
|
56
|
+
<SafeAreaInsetsContext.Provider value={insets}>{portalHost ? <Portal.Host>{column}</Portal.Host> : column}</SafeAreaInsetsContext.Provider>
|
|
57
|
+
</FakeLandscapeView>
|
|
58
|
+
)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const styles = StyleSheet.create({
|
|
62
|
+
// A centred column: full width up to `maxWidth`, so a capped frame sits in the middle of a wider window.
|
|
63
|
+
column: {
|
|
64
|
+
alignSelf: 'center',
|
|
65
|
+
flex: 1,
|
|
66
|
+
width: '100%'
|
|
67
|
+
},
|
|
68
|
+
root: {
|
|
69
|
+
flex: 1
|
|
70
|
+
}
|
|
71
|
+
})
|
package/src/index.ts
CHANGED
|
@@ -18,6 +18,7 @@ export { getColorPopoverId, getCpuDifficultyPopoverId, PlayerSetupPanel, type Pl
|
|
|
18
18
|
export { PopoverBody } from './PopoverBody'
|
|
19
19
|
export { PressAwayOverlay } from './PressAwayOverlay'
|
|
20
20
|
export { ReadyButton } from './ReadyButton'
|
|
21
|
+
export { RotatedFrame, type RotatedFrameProps } from './RotatedFrame'
|
|
21
22
|
export { type MenuOption, type MenuSection, type MultiSelectSection, SectionedDropdown, type SingleSelectSection } from './SectionedDropdown'
|
|
22
23
|
export { SharedActionBand } from './SharedActionBand'
|
|
23
24
|
export { SkiaGate, type SkiaGateProps } from './SkiaGate'
|