@tastic/shell 0.4.0 → 0.6.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 +41 -15
- package/dist/index.d.mts +29 -14
- package/dist/index.d.ts +29 -14
- package/dist/index.js +208 -182
- package/dist/index.mjs +139 -116
- package/package.json +16 -18
- package/src/RootLayout.tsx +27 -66
- package/src/components/GuideHost.tsx +4 -6
- package/src/components/MatchChips.tsx +72 -0
- package/src/components/SettingsDialog.tsx +4 -10
- package/src/components/Theme.tsx +3 -2
- package/src/components/UpdateDialog.tsx +1 -3
- package/src/hooks/useFrozenWhileHidden.ts +2 -2
- package/src/index.ts +1 -0
- package/src/redux/appSettingsSlice.ts +0 -11
- package/src/screens/HomeScreen.tsx +13 -17
- package/src/screens/LoadoutScreen.tsx +35 -53
- package/src/screens/ProfilesRoute.tsx +5 -5
- package/src/screens/StatsScreen.tsx +4 -5
- package/src/screens/homeLayout.ts +15 -19
- package/src/types.ts +13 -21
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tastic/shell",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"description": "The app around a pack of React Native games, two-seat or for one player: Home, the loadout, the stats screen, Profiles and Settings, the pack's Redux store and its root providers, driven by one PackDefinition",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"react-native",
|
|
@@ -66,7 +66,7 @@
|
|
|
66
66
|
},
|
|
67
67
|
"prettier": "@infinitetoken/eslint-config/prettier",
|
|
68
68
|
"devDependencies": {
|
|
69
|
-
"@babel/core": "^7.29.
|
|
69
|
+
"@babel/core": "^7.29.13",
|
|
70
70
|
"@expo/vector-icons": "^15.1.1",
|
|
71
71
|
"@infinitetoken/eslint-config": "^0.2.1",
|
|
72
72
|
"@infinitetoken/jest-config": "^0.5.2",
|
|
@@ -74,7 +74,7 @@
|
|
|
74
74
|
"@react-native-async-storage/async-storage": "2.2.0",
|
|
75
75
|
"@react-native-masked-view/masked-view": "0.3.2",
|
|
76
76
|
"@react-native/jest-preset": "^0.88.0",
|
|
77
|
-
"@reduxjs/toolkit": "^2.
|
|
77
|
+
"@reduxjs/toolkit": "^2.14.0",
|
|
78
78
|
"@rific/auto-paper": "^0.13.4",
|
|
79
79
|
"@rific/core": "^0.5.0",
|
|
80
80
|
"@rific/feedback-press": "^0.13.1",
|
|
@@ -84,7 +84,7 @@
|
|
|
84
84
|
"@rific/toaster": "^0.12.0",
|
|
85
85
|
"@rific/updater": "^0.8.0",
|
|
86
86
|
"@tastic/achievements": "^0.6.0",
|
|
87
|
-
"@tastic/core": "^0.
|
|
87
|
+
"@tastic/core": "^0.15.0",
|
|
88
88
|
"@tastic/edge-guard": "^0.1.5",
|
|
89
89
|
"@tastic/hud": "^0.27.1",
|
|
90
90
|
"@tastic/profile": "^0.17.0",
|
|
@@ -95,34 +95,33 @@
|
|
|
95
95
|
"eslint": "^9.39.5",
|
|
96
96
|
"eslint-plugin-react-hooks": "^7.1.1",
|
|
97
97
|
"eslint-plugin-react-native": "^5.0.0",
|
|
98
|
-
"expo": "~58.0.
|
|
99
|
-
"expo-audio": "~58.0.
|
|
98
|
+
"expo": "~58.0.23",
|
|
99
|
+
"expo-audio": "~58.0.11",
|
|
100
100
|
"expo-blur": "~58.0.5",
|
|
101
|
-
"expo-constants": "~58.0.
|
|
101
|
+
"expo-constants": "~58.0.15",
|
|
102
102
|
"expo-font": "~58.0.10",
|
|
103
103
|
"expo-haptics": "~58.0.7",
|
|
104
104
|
"expo-keep-awake": "~58.0.3",
|
|
105
|
-
"expo-router": "~58.0.
|
|
106
|
-
"expo-
|
|
107
|
-
"expo-splash-screen": "~58.0.11",
|
|
105
|
+
"expo-router": "~58.0.30",
|
|
106
|
+
"expo-splash-screen": "~58.0.13",
|
|
108
107
|
"expo-system-ui": "~58.0.6",
|
|
109
|
-
"expo-updates": "~58.0.
|
|
108
|
+
"expo-updates": "~58.0.21",
|
|
110
109
|
"jest": "~30.5.2",
|
|
111
|
-
"jest-expo": "~58.0.
|
|
110
|
+
"jest-expo": "~58.0.14",
|
|
112
111
|
"prettier": "^3.8.3",
|
|
113
112
|
"react": "19.3.0",
|
|
114
|
-
"react-native": "0.88.
|
|
113
|
+
"react-native": "^0.88.1",
|
|
115
114
|
"react-native-gesture-handler": "~3.3.0",
|
|
116
|
-
"react-native-paper": "^5.15.
|
|
115
|
+
"react-native-paper": "^5.15.4",
|
|
117
116
|
"react-native-reanimated": "4.7.1",
|
|
118
|
-
"react-native-safe-area-context": "~5.
|
|
117
|
+
"react-native-safe-area-context": "~5.11.1",
|
|
119
118
|
"react-native-screens": "~4.30.0",
|
|
120
119
|
"react-native-worklets": "0.13.0",
|
|
121
120
|
"react-redux": "^9.3.0",
|
|
122
121
|
"redux": "^5.0.1",
|
|
123
122
|
"redux-persist": "^6.0.0",
|
|
124
123
|
"test-renderer": "^1.3.1",
|
|
125
|
-
"tsup": "^
|
|
124
|
+
"tsup": "^9.0.0",
|
|
126
125
|
"typescript": "~6.0.3"
|
|
127
126
|
},
|
|
128
127
|
"peerDependencies": {
|
|
@@ -136,7 +135,7 @@
|
|
|
136
135
|
"@rific/splash-gate": ">=0.2.3",
|
|
137
136
|
"@rific/toaster": ">=0.10.1",
|
|
138
137
|
"@tastic/achievements": ">=0.6.0",
|
|
139
|
-
"@tastic/core": ">=0.
|
|
138
|
+
"@tastic/core": ">=0.15.0",
|
|
140
139
|
"@tastic/edge-guard": ">=0.1.5",
|
|
141
140
|
"@tastic/hud": ">=0.27.1",
|
|
142
141
|
"@tastic/profile": ">=0.16.2",
|
|
@@ -147,7 +146,6 @@
|
|
|
147
146
|
"expo-haptics": ">=58.0.7",
|
|
148
147
|
"expo-keep-awake": ">=58.0.3",
|
|
149
148
|
"expo-router": ">=58.0.23",
|
|
150
|
-
"expo-sensors": ">=58.0.4",
|
|
151
149
|
"expo-splash-screen": ">=58.0.10",
|
|
152
150
|
"react": ">=19.3.0",
|
|
153
151
|
"react-native": ">=0.88.0",
|
package/src/RootLayout.tsx
CHANGED
|
@@ -1,12 +1,11 @@
|
|
|
1
1
|
import { useAutoPaperTheme } from '@rific/auto-paper'
|
|
2
2
|
import { useHapticSettings } from '@rific/feedback-press'
|
|
3
3
|
import { Toaster, ToastProvider } from '@rific/toaster'
|
|
4
|
-
import {
|
|
4
|
+
import { useThemedRootBackground } from '@tastic/core'
|
|
5
5
|
import { RotatedFrame } from '@tastic/hud/chrome'
|
|
6
6
|
import { ProfileColorProvider } from '@tastic/profile'
|
|
7
7
|
import * as Haptics from 'expo-haptics'
|
|
8
8
|
import { DarkTheme, DefaultTheme, Stack, ThemeProvider as NavigationThemeProvider } from 'expo-router'
|
|
9
|
-
import { DeviceMotion } from 'expo-sensors'
|
|
10
9
|
import { ReactNode, useCallback, useMemo } from 'react'
|
|
11
10
|
import { Platform, StyleSheet, View } from 'react-native'
|
|
12
11
|
import { GestureHandlerRootView } from 'react-native-gesture-handler'
|
|
@@ -19,7 +18,6 @@ import { GuideHost } from './components/GuideHost'
|
|
|
19
18
|
import { Providers } from './components/Providers'
|
|
20
19
|
import { Theme } from './components/Theme'
|
|
21
20
|
import { UpdateDialog } from './components/UpdateDialog'
|
|
22
|
-
import { useAppSettings } from './hooks/useAppSettings'
|
|
23
21
|
import { LastActiveGameProvider } from './hooks/useLastActiveGame'
|
|
24
22
|
import { ProfilesProvider, useProfiles } from './hooks/useProfiles'
|
|
25
23
|
import { PackProvider, usePack } from './PackContext'
|
|
@@ -38,15 +36,13 @@ import type { PackDefinition, PackGameDefinition } from './types'
|
|
|
38
36
|
// a play screen and wins (confirmed on-device in LightCycles, where it kicked the player back to the title
|
|
39
37
|
// screen mid-round instead of turning their cycle). Web has no such OS-level gesture.
|
|
40
38
|
//
|
|
41
|
-
//
|
|
42
|
-
//
|
|
43
|
-
//
|
|
44
|
-
//
|
|
45
|
-
//
|
|
46
|
-
//
|
|
47
|
-
//
|
|
48
|
-
// options win over screenOptions); index is listed first so it stays the stack's first route.
|
|
49
|
-
// A game with keepStatusBar (Solitaire's) keeps the screen options' rotation-aware bar instead.
|
|
39
|
+
// The status bar is set through screen options because that is the only way left: an app linked
|
|
40
|
+
// against the iOS 27 SDK can't set the status bar app-wide, which is all React Native's StatusBar (and
|
|
41
|
+
// expo-status-bar) does on iOS, and with UIViewControllerBasedStatusBarAppearance on (each app's
|
|
42
|
+
// ios.infoPlist), every mounted StatusBar logs an error. So nothing in the app mounts one (Theme passes
|
|
43
|
+
// auto-paper's Provider statusBar={false}). A play screen hides the bar for the whole round (a screen's
|
|
44
|
+
// own options win over screenOptions); index is listed first so it stays the stack's first route. A
|
|
45
|
+
// game with keepStatusBar (Solitaire's) keeps the stack's bar instead.
|
|
50
46
|
const PLAY_OPTIONS = { statusBarHidden: true }
|
|
51
47
|
const KEEP_STATUS_BAR_OPTIONS = {}
|
|
52
48
|
|
|
@@ -76,7 +72,7 @@ function AppStack() {
|
|
|
76
72
|
const rootBackground = useThemedRootBackground(darkRoot, false)
|
|
77
73
|
// The status bar's text follows the app's theme, not the phone's appearance (react-native-screens'
|
|
78
74
|
// default `auto`), which drew dark text on a dark theme's black Home with the phone set to light.
|
|
79
|
-
const
|
|
75
|
+
const statusBarStyle = darkRoot ? ('light' as const) : ('dark' as const)
|
|
80
76
|
const packOptions = (pack.useScreenOptions ?? noScreenOptions)()
|
|
81
77
|
// Over a backdrop every screen is transparent, and Android crossfades: its default push and pop hold
|
|
82
78
|
// the outgoing screen opaque for ~450ms while the new one slides over it, which would show Home
|
|
@@ -96,7 +92,7 @@ function AppStack() {
|
|
|
96
92
|
}, [darkRoot, navigationBackground])
|
|
97
93
|
const stack = (
|
|
98
94
|
<NavigationThemeProvider value={navigationTheme}>
|
|
99
|
-
<Stack screenOptions={{ ...rootBackground,
|
|
95
|
+
<Stack screenOptions={{ ...rootBackground, statusBarStyle, ...backdropOptions, ...packOptions }}>
|
|
100
96
|
<Stack.Screen name='index' />
|
|
101
97
|
<Stack.Screen name='play/[game]' options={playOptions} />
|
|
102
98
|
</Stack>
|
|
@@ -111,38 +107,6 @@ function AppStack() {
|
|
|
111
107
|
)
|
|
112
108
|
}
|
|
113
109
|
|
|
114
|
-
// Wires the persisted Lock Orientation setting into @tastic/core's OrientationProvider. Its own
|
|
115
|
-
// component because RootLayout renders the Redux store (inside Providers) and so can't call
|
|
116
|
-
// useAppSettings() itself. Providers' PersistGate holds rendering until the store has rehydrated, so
|
|
117
|
-
// lockInitialValue carries the saved lock from the very first render.
|
|
118
|
-
//
|
|
119
|
-
// freezeWhileLocked makes the lock app-wide: while appSettings.lockOrientation is on, the ONE shared
|
|
120
|
-
// orientation reading freezes for every consumer (every screen, every board, hud dialogs, the
|
|
121
|
-
// status bar), including anything that mounts after the lock, so no call site ever takes a `locked`
|
|
122
|
-
// argument of its own (one would freeze on the pre-sensor default at mount). The Settings dialog
|
|
123
|
-
// toggle only writes appSettings.lockOrientation, which reaches the provider as a changed
|
|
124
|
-
// lockInitialValue; the provider reports the angle it froze on through onLockChange (`rotation`,
|
|
125
|
-
// cleared on unlock), persisted as appSettings.lockedRotation and handed back here so a relaunch reopens
|
|
126
|
-
// in the locked orientation instead of portrait.
|
|
127
|
-
//
|
|
128
|
-
// Mounted once, here, rather than per screen: one app-lifetime sensor subscription is what lets the
|
|
129
|
-
// committed orientation survive navigation. deviceMotion is the app's own expo-sensors import, handed
|
|
130
|
-
// in because @tastic/core never imports expo-sensors itself (apps without tilt tracking would
|
|
131
|
-
// otherwise need it installed just to bundle).
|
|
132
|
-
//
|
|
133
|
-
// portraitWhileKeyboard (PackDefinition.portraitWhileKeyboard): the software keyboard can't turn with a
|
|
134
|
-
// faked rotation, so a pack that has the player type sideways (a profile's name) reads portrait while
|
|
135
|
-
// it's up.
|
|
136
|
-
function OrientationBridge({ children }: { children: ReactNode }) {
|
|
137
|
-
const { settings, setSettings } = useAppSettings()
|
|
138
|
-
const { portraitWhileKeyboard } = usePack()
|
|
139
|
-
return (
|
|
140
|
-
<OrientationProvider deviceMotion={DeviceMotion} freezeWhileLocked lockInitialValue={{ locked: settings.lockOrientation, rotation: settings.lockedRotation }} onLockChange={(next) => setSettings({ lockOrientation: next.locked, lockedRotation: next.rotation })} portraitWhileKeyboard={portraitWhileKeyboard}>
|
|
141
|
-
{children}
|
|
142
|
-
</OrientationProvider>
|
|
143
|
-
)
|
|
144
|
-
}
|
|
145
|
-
|
|
146
110
|
// ProfilesProvider reconciles the local roster with the shared App Group one before `loaded` flips,
|
|
147
111
|
// so a screen mounting earlier would read (and could persist) the wrong profiles. This holds
|
|
148
112
|
// everything nested inside it, and the splash screen, until that's done, while ProfilesProvider sits
|
|
@@ -163,7 +127,7 @@ function GatedApp({ children }: { children: ReactNode }) {
|
|
|
163
127
|
// either is ever shown. Must render inside Providers, whose FeedbackBridge provides the setting.
|
|
164
128
|
//
|
|
165
129
|
// A pack with its own Toaster gets no Paper here: with Paper the toaster draws through a Paper Portal,
|
|
166
|
-
// which would take its toasts out of the
|
|
130
|
+
// which would take its toasts out of the frame PackToaster lays them in.
|
|
167
131
|
function HapticGatedToastProvider({ children }: { children: ReactNode }) {
|
|
168
132
|
const { settings } = useHapticSettings()
|
|
169
133
|
const { Toaster: PackToasterComponent } = usePack()
|
|
@@ -186,10 +150,10 @@ function RotatingToaster() {
|
|
|
186
150
|
return <Toaster historyButton={null} clearButton={null} limit={3} renderFrame={renderFrame} />
|
|
187
151
|
}
|
|
188
152
|
|
|
189
|
-
// The pack's own toaster (PackDefinition.Toaster), laid over the whole app in
|
|
190
|
-
//
|
|
191
|
-
//
|
|
192
|
-
//
|
|
153
|
+
// The pack's own toaster (PackDefinition.Toaster), laid over the whole app in hud's RotatedFrame, which
|
|
154
|
+
// nothing turns since @tastic/core 0.15: passThrough, so only a toast claims a touch over the stack, and
|
|
155
|
+
// rotatedSafeArea, so the toaster reads the safe area. The absoluteFill wrapper takes the frame out of
|
|
156
|
+
// the layout beside the stack.
|
|
193
157
|
function PackToaster({ Toaster: PackToasterComponent }: { Toaster: NonNullable<PackDefinition['Toaster']> }) {
|
|
194
158
|
return (
|
|
195
159
|
<View style={StyleSheet.absoluteFill} pointerEvents='box-none'>
|
|
@@ -243,24 +207,21 @@ export function RootLayout({ pack, store, persistor }: RootLayoutProps) {
|
|
|
243
207
|
<GameProviders games={pack.games}>
|
|
244
208
|
<PackRoot pack={pack}>
|
|
245
209
|
<GatedApp>
|
|
246
|
-
<
|
|
247
|
-
|
|
248
|
-
{/* Wraps the stack (rather than sitting beside it like UpdateDialog)
|
|
210
|
+
<HapticGatedToastProvider>
|
|
211
|
+
{/* Wraps the stack (rather than sitting beside it like UpdateDialog)
|
|
249
212
|
because Home's tiles and the loadout's How to Play row reach it through
|
|
250
|
-
context (see GuideHost). Inside the toast provider
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
<
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
{/* historyButton/clearButton off: there's no toast history to open, and a
|
|
213
|
+
context (see GuideHost). Inside the toast provider because the card renders
|
|
214
|
+
through a Portal, and inside the gate so it mounts together with Home rather
|
|
215
|
+
than before it. */}
|
|
216
|
+
<GuideHost>
|
|
217
|
+
<AppStack />
|
|
218
|
+
</GuideHost>
|
|
219
|
+
{/* historyButton/clearButton off: there's no toast history to open, and a
|
|
258
220
|
control row for it above the toast stack would go nowhere. limit pins the
|
|
259
221
|
package's current default (3) so a future toaster release can't change it. */}
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
</OrientationBridge>
|
|
222
|
+
{pack.Toaster ? <PackToaster Toaster={pack.Toaster} /> : <RotatingToaster />}
|
|
223
|
+
<UpdateDialog />
|
|
224
|
+
</HapticGatedToastProvider>
|
|
264
225
|
</GatedApp>
|
|
265
226
|
</PackRoot>
|
|
266
227
|
</GameProviders>
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useIsTouchPrimaryDevice
|
|
1
|
+
import { useIsTouchPrimaryDevice } from '@tastic/core'
|
|
2
2
|
import { GuideProvider, useGuide } from '@tastic/hud/guide'
|
|
3
3
|
import { createContext, ReactNode, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'
|
|
4
4
|
import { shallowEqual, useDispatch, useSelector, useStore } from 'react-redux'
|
|
@@ -38,9 +38,8 @@ interface Props {
|
|
|
38
38
|
// the pack's own (PackDefinition.guide, cards that teach every game at once), or a game's. Each
|
|
39
39
|
// game's cards come from its own registry entry (GameDefinition.guide), built from the live settings
|
|
40
40
|
// and seat 2 so a replay after any loadout change describes the next match. Mounted around the stack
|
|
41
|
-
// in RootLayout, inside the toast provider
|
|
42
|
-
//
|
|
43
|
-
// and inside the profiles gate so it mounts together with Home.
|
|
41
|
+
// in RootLayout, inside the toast provider because the card renders through a Portal, and inside the
|
|
42
|
+
// profiles gate so it mounts together with Home.
|
|
44
43
|
export function GuideHost({ children }: Props) {
|
|
45
44
|
const pack = usePack()
|
|
46
45
|
const [subject, setSubject] = useState(() => (pack.guide ? PACK_GUIDE : (pack.games.find((g) => g.guide)?.id ?? null)))
|
|
@@ -48,7 +47,6 @@ export function GuideHost({ children }: Props) {
|
|
|
48
47
|
const game = subject === PACK_GUIDE ? undefined : pack.games.find((g) => g.id === subject)
|
|
49
48
|
const gameGuide = game?.guide
|
|
50
49
|
|
|
51
|
-
const rotation = useRotation()
|
|
52
50
|
const touch = useIsTouchPrimaryDevice()
|
|
53
51
|
const seatTwo = useSelector((state: ShellState) => state.opponent.seatTwo)
|
|
54
52
|
const input = useSelector((state: ShellState) => (gameGuide ? gameGuide.selectInput(state) : NO_INPUT), shallowEqual)
|
|
@@ -68,7 +66,7 @@ export function GuideHost({ children }: Props) {
|
|
|
68
66
|
)
|
|
69
67
|
|
|
70
68
|
return (
|
|
71
|
-
<GuideProvider steps={steps} currentVersion={(packGuide ?? gameGuide)?.version ?? 0} seenVersion={seenVersion} onSeen={onSeen}
|
|
69
|
+
<GuideProvider steps={steps} currentVersion={(packGuide ?? gameGuide)?.version ?? 0} seenVersion={seenVersion} onSeen={onSeen}>
|
|
72
70
|
<GameGuideBridge onPick={setSubject}>{children}</GameGuideBridge>
|
|
73
71
|
</GuideProvider>
|
|
74
72
|
)
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { useAutoPaperTheme } from '@rific/auto-paper'
|
|
2
|
+
import { IconButton } from '@rific/feedback-press'
|
|
3
|
+
import { useEffect } from 'react'
|
|
4
|
+
import { Platform, StyleSheet, useWindowDimensions } from 'react-native'
|
|
5
|
+
import Animated, { useAnimatedStyle, useSharedValue, withTiming } from 'react-native-reanimated'
|
|
6
|
+
import { useSafeAreaInsets } from 'react-native-safe-area-context'
|
|
7
|
+
|
|
8
|
+
// A web window at least this wide is a computer: the chips stay up in the page's top corners all
|
|
9
|
+
// match. The games' own desktop cutoff, the same number everywhere.
|
|
10
|
+
export const MATCH_CHIPS_DESKTOP_MIN_WIDTH = 768
|
|
11
|
+
// How long the chips take to fade out when play starts, and back in when it stops.
|
|
12
|
+
export const MATCH_CHIPS_FADE_MS = 150
|
|
13
|
+
// The chip's box: a 24 icon in IconButton's 48, with Paper's margin zeroed.
|
|
14
|
+
const CHIP_SIZE = 48
|
|
15
|
+
const EDGE_PAD = 8
|
|
16
|
+
|
|
17
|
+
export interface MatchChipsProps {
|
|
18
|
+
/** Whether play is on right now (a live puck, a shot in flight, a round being ridden). While it is,
|
|
19
|
+
* the chips fade out and can't be pressed, so they never cover play; they come back the moment it
|
|
20
|
+
* stops (between points, a round's card, Game Over). Ignored on desktop web, where they stay up. */
|
|
21
|
+
playing: boolean
|
|
22
|
+
/** Two players at opposite ends of one screen right now (2 Player face to face). The chips then sit
|
|
23
|
+
* on the side edges at the line between the players, rather than in the top corners, which are the
|
|
24
|
+
* far player's end. False for one human (vs CPU, solo) and for side by side. Ignored on desktop
|
|
25
|
+
* web: both players share one upright screen there. */
|
|
26
|
+
faceToFace: boolean
|
|
27
|
+
/** Where that line between the players is, as a y in the window. Defaults to the window's middle;
|
|
28
|
+
* pass it when the board isn't centered (a band of score above it, say). */
|
|
29
|
+
centerLineY?: number
|
|
30
|
+
onBack: () => void
|
|
31
|
+
onSettings: () => void
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** The back and settings chips over a match, in every game of every pack: one rule for where they sit
|
|
35
|
+
* and when they show. The window's top corners (the same spot as hud's CornerActionButtons on every
|
|
36
|
+
* other screen), except 2 Player face to face, which puts them on the side edges at the line between
|
|
37
|
+
* the players; faded out while play is on, except on desktop web. */
|
|
38
|
+
export function MatchChips({ playing, faceToFace, centerLineY, onBack, onSettings }: MatchChipsProps) {
|
|
39
|
+
const { width, height } = useWindowDimensions()
|
|
40
|
+
const insets = useSafeAreaInsets()
|
|
41
|
+
const { dark } = useAutoPaperTheme()
|
|
42
|
+
const desktopWeb = Platform.OS === 'web' && width >= MATCH_CHIPS_DESKTOP_MIN_WIDTH
|
|
43
|
+
const visible = desktopWeb || !playing
|
|
44
|
+
const opacity = useSharedValue(visible ? 1 : 0)
|
|
45
|
+
useEffect(() => {
|
|
46
|
+
opacity.value = withTiming(visible ? 1 : 0, { duration: MATCH_CHIPS_FADE_MS })
|
|
47
|
+
}, [visible]) // eslint-disable-line react-hooks/exhaustive-deps
|
|
48
|
+
const fadeStyle = useAnimatedStyle(() => ({ opacity: opacity.value }))
|
|
49
|
+
|
|
50
|
+
const iconColor = dark ? '#FFFFFF' : '#000000'
|
|
51
|
+
const containerColor = dark ? 'rgba(0,0,0,0.55)' : 'rgba(255,255,255,0.55)'
|
|
52
|
+
const between = faceToFace && !desktopWeb
|
|
53
|
+
const top = between ? (centerLineY ?? height / 2) - CHIP_SIZE / 2 : EDGE_PAD + insets.top
|
|
54
|
+
const backSlot = { top, left: EDGE_PAD + insets.left }
|
|
55
|
+
const settingsSlot = { top, right: EDGE_PAD + insets.right }
|
|
56
|
+
const pointerEvents = visible ? 'box-none' : 'none'
|
|
57
|
+
return (
|
|
58
|
+
<>
|
|
59
|
+
<Animated.View pointerEvents={pointerEvents} style={[styles.slot, backSlot, fadeStyle]} testID='match-chips-back'>
|
|
60
|
+
<IconButton icon='arrow-left' iconColor={iconColor} containerColor={containerColor} style={styles.chip} size={24} onPress={onBack} accessibilityLabel='Back' />
|
|
61
|
+
</Animated.View>
|
|
62
|
+
<Animated.View pointerEvents={pointerEvents} style={[styles.slot, settingsSlot, fadeStyle]} testID='match-chips-settings'>
|
|
63
|
+
<IconButton icon='cog' iconColor={iconColor} containerColor={containerColor} style={styles.chip} size={24} onPress={onSettings} accessibilityLabel='Settings' />
|
|
64
|
+
</Animated.View>
|
|
65
|
+
</>
|
|
66
|
+
)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const styles = StyleSheet.create({
|
|
70
|
+
chip: { margin: 0 },
|
|
71
|
+
slot: { position: 'absolute' }
|
|
72
|
+
})
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { useUpdateErrorToast } from '@rific/toaster'
|
|
2
|
-
import { ViewRotation } from '@tastic/core'
|
|
3
2
|
import { BaseSettingsDialog } from '@tastic/hud/chrome'
|
|
4
3
|
import type { ReactNode } from 'react'
|
|
5
4
|
|
|
@@ -9,11 +8,6 @@ import { usePack } from '../PackContext'
|
|
|
9
8
|
export interface SettingsDialogProps {
|
|
10
9
|
visible: boolean
|
|
11
10
|
onDismiss: () => void
|
|
12
|
-
// Live physical-hold rotation (see @tastic/core's useRotation). BaseSettingsDialog lays the sheet
|
|
13
|
-
// out in its own frame turned by this (see @tastic/hud's RotatedFrame), so it reads right-side-up
|
|
14
|
-
// and fits the short edge however the phone is held. Defaults to 0 for a call site that doesn't
|
|
15
|
-
// track one.
|
|
16
|
-
rotation?: ViewRotation
|
|
17
11
|
// Shows the How to Play row, which opens this game's cards (a loadout passes its game's opener).
|
|
18
12
|
// Left out on Home, where each tile's rules button does it, and in a match, where opening
|
|
19
13
|
// Settings pauses play.
|
|
@@ -23,12 +17,12 @@ export interface SettingsDialogProps {
|
|
|
23
17
|
children?: ReactNode
|
|
24
18
|
}
|
|
25
19
|
|
|
26
|
-
// The pack-wide Settings dialog: everything the shell puts in it applies to every game (
|
|
27
|
-
//
|
|
20
|
+
// The pack-wide Settings dialog: everything the shell puts in it applies to every game (Edge Guard,
|
|
21
|
+
// theme, sound, haptics, Keep Awake), so it reads and writes the app
|
|
28
22
|
// settings itself. Under those, a play screen's own rows and then the pack's own sections
|
|
29
23
|
// (PackDefinition.SettingsSections), in every Settings dialog the pack opens. A pack with edgeGuard
|
|
30
24
|
// false gets no Edge Guard row: hud draws it on iOS whenever its props are passed.
|
|
31
|
-
export function SettingsDialog({ visible, onDismiss,
|
|
25
|
+
export function SettingsDialog({ visible, onDismiss, onShowHowToPlay, children }: SettingsDialogProps) {
|
|
32
26
|
const onUpdateError = useUpdateErrorToast()
|
|
33
27
|
const { settings, setSettings } = useAppSettings()
|
|
34
28
|
// The pack's otaVersion (src/constants/release.ts, which the OTA tooling bumps).
|
|
@@ -36,7 +30,7 @@ export function SettingsDialog({ visible, onDismiss, rotation = 0, onShowHowToPl
|
|
|
36
30
|
const edgeGuardProps = edgeGuard ? { deferBottomEdgeGestures: settings.deferBottomEdgeGestures, onDeferBottomEdgeGestures: (value: boolean) => setSettings({ deferBottomEdgeGestures: value }) } : {}
|
|
37
31
|
|
|
38
32
|
return (
|
|
39
|
-
<BaseSettingsDialog visible={visible} onDismiss={onDismiss}
|
|
33
|
+
<BaseSettingsDialog visible={visible} onDismiss={onDismiss} version={otaVersion} {...edgeGuardProps} onUpdateError={onUpdateError} onShowHowToPlay={onShowHowToPlay}>
|
|
40
34
|
{children}
|
|
41
35
|
{SettingsSections && <SettingsSections />}
|
|
42
36
|
</BaseSettingsDialog>
|
package/src/components/Theme.tsx
CHANGED
|
@@ -61,8 +61,9 @@ export function Theme({ children }: Props) {
|
|
|
61
61
|
// iOS/Android (the Settings sheet's and Rules' rows above Done, @tastic/profile's dropdown lists
|
|
62
62
|
// above Manage); without it they just scroll with a hard edge. Web needs nothing.
|
|
63
63
|
// statusBar={false}: auto-paper renders no React Native StatusBar (here or in Appbar.Header). The
|
|
64
|
-
// status bar is set
|
|
65
|
-
//
|
|
64
|
+
// status bar is set through the stack's screen options (RootLayout's AppStack), and with
|
|
65
|
+
// UIViewControllerBasedStatusBarAppearance on (each app's ios.infoPlist), a mounted StatusBar logs an
|
|
66
|
+
// error (a red box in a dev build).
|
|
66
67
|
// reanimated is what makes Dialog's own animatedStyle prop do anything (Metro won't pick up a
|
|
67
68
|
// require()-in-try/catch from this package's ESM build, so the module is handed in); nothing else in
|
|
68
69
|
// auto-paper reads it. fontFamily is the pack's (PackDefinition.fontFamily): set, it goes on every
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { useUpdateErrorToast } from '@rific/toaster'
|
|
2
|
-
import { useRotation } from '@tastic/core'
|
|
3
2
|
import { UpdateDialog as SharedUpdateDialog } from '@tastic/hud/chrome'
|
|
4
3
|
|
|
5
4
|
// Thin bridge onto the fleet-shared @tastic/hud UpdateDialog, which now owns the useUpdater()
|
|
@@ -8,6 +7,5 @@ import { UpdateDialog as SharedUpdateDialog } from '@tastic/hud/chrome'
|
|
|
8
7
|
// directly in _layout.tsx) purely so this app's own onError -> toast wiring has one place to live.
|
|
9
8
|
export function UpdateDialog() {
|
|
10
9
|
const onError = useUpdateErrorToast()
|
|
11
|
-
|
|
12
|
-
return <SharedUpdateDialog onError={onError} rotation={rotation} />
|
|
10
|
+
return <SharedUpdateDialog onError={onError} />
|
|
13
11
|
}
|
|
@@ -5,9 +5,9 @@ import { useState } from 'react'
|
|
|
5
5
|
// Solitaire's components/useFrozenWhileHidden.ts as it was.
|
|
6
6
|
//
|
|
7
7
|
// Home holds its layout with it while another screen covers it (screens/HomeScreen.tsx): Home stays
|
|
8
|
-
// mounted beneath every screen the stack pushes and keeps following the
|
|
8
|
+
// mounted beneath every screen the stack pushes and keeps following the window, and crossing the
|
|
9
9
|
// upright/wide threshold swaps its whole tree (every tile, the hero, seat 1's button), invisible work
|
|
10
|
-
// landing in the same commit as the covering screen's own
|
|
10
|
+
// landing in the same commit as the covering screen's own update for the new window. While covered the layout is
|
|
11
11
|
// held where it was; it catches up (one swap, visible) when Home is shown again.
|
|
12
12
|
export function useFrozenWhileHidden<T>(value: T, hidden: boolean): T {
|
|
13
13
|
// React's own sanctioned "adjust state during render" shape (see react.dev's guide of the same
|
package/src/index.ts
CHANGED
|
@@ -15,6 +15,7 @@ export { StatsScreen } from './screens/StatsScreen'
|
|
|
15
15
|
// What a game's own screens use.
|
|
16
16
|
export { useGameGuide } from './components/GuideHost'
|
|
17
17
|
export { imageGameIcon } from './components/imageGameIcon'
|
|
18
|
+
export { MATCH_CHIPS_DESKTOP_MIN_WIDTH, MATCH_CHIPS_FADE_MS, MatchChips, type MatchChipsProps } from './components/MatchChips'
|
|
18
19
|
export { SettingsDialog, type SettingsDialogProps } from './components/SettingsDialog'
|
|
19
20
|
export { useActiveRound } from './hooks/useActiveRound'
|
|
20
21
|
export { useAppSettings } from './hooks/useAppSettings'
|
|
@@ -1,24 +1,13 @@
|
|
|
1
1
|
import { createSettingsSlice } from '@rific/core'
|
|
2
|
-
import type { ViewRotation } from '@tastic/core'
|
|
3
2
|
|
|
4
3
|
// Settings that apply to the whole pack rather than one game. A game's own rules (board, score to
|
|
5
4
|
// win, controls) live in that game's own settings slice.
|
|
6
5
|
export interface AppSettings {
|
|
7
|
-
// Lock Orientation: RootLayout's OrientationBridge hands it to @tastic/core's OrientationProvider
|
|
8
|
-
// (freezeWhileLocked), which freezes the one shared orientation reading for every screen, every
|
|
9
|
-
// board, dialogs and the status bar alike. Off by default: all of those just follow however the
|
|
10
|
-
// device is actually being held.
|
|
11
|
-
lockOrientation: boolean
|
|
12
|
-
// The angle the lock is holding. Written by OrientationProvider's onLockChange (recorded on lock,
|
|
13
|
-
// cleared on unlock) and handed back as lockInitialValue, so a relaunch reopens in the locked
|
|
14
|
-
// orientation instead of portrait. Never set by the UI directly; absent while unlocked.
|
|
15
|
-
lockedRotation?: ViewRotation
|
|
16
6
|
// Edge Guard's opt-in (see Providers.tsx's EdgeGuardBridge).
|
|
17
7
|
deferBottomEdgeGestures: boolean
|
|
18
8
|
}
|
|
19
9
|
|
|
20
10
|
export const DEFAULT_APP_SETTINGS: AppSettings = {
|
|
21
|
-
lockOrientation: false,
|
|
22
11
|
deferBottomEdgeGestures: false
|
|
23
12
|
}
|
|
24
13
|
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { defaultColors, FadingScrollView, useAutoPaperTheme } from '@rific/auto-paper'
|
|
2
2
|
import { IconButton, Pressable } from '@rific/feedback-press'
|
|
3
|
-
import { FakeLandscapeView, useRotation } from '@tastic/core'
|
|
4
3
|
import { PressAwayOverlay, usePopoverHost, useWellColors, WELL_SHADOW } from '@tastic/hud/chrome'
|
|
5
4
|
import { getProfilePickerContentSize, type Profile, ProfilePicker, type ProfilePickerColorSection } from '@tastic/profile'
|
|
6
5
|
import { Redirect, router, useIsFocused } from 'expo-router'
|
|
@@ -53,7 +52,6 @@ export function HomeScreen() {
|
|
|
53
52
|
|
|
54
53
|
function HomeMenu() {
|
|
55
54
|
const pack = usePack()
|
|
56
|
-
const rotation = useRotation()
|
|
57
55
|
const layout = useHomeLayout()
|
|
58
56
|
const { insets } = layout
|
|
59
57
|
const { dark, colors } = useAutoPaperTheme()
|
|
@@ -79,7 +77,7 @@ function HomeMenu() {
|
|
|
79
77
|
|
|
80
78
|
// Tiles: while any game in the pack has a preview, a wide screen lays the games out as a grid beside
|
|
81
79
|
// the hero (homeLayout.ts). Home stays mounted beneath every screen the stack pushes and keeps
|
|
82
|
-
// following the
|
|
80
|
+
// following the window, so while it's covered the layout is held where it was (see
|
|
83
81
|
// useFrozenWhileHidden); it catches up when Home is shown again. Only the choice of layout is held:
|
|
84
82
|
// the panes' numbers follow the window, unseen under the screen covering Home.
|
|
85
83
|
const tiled = pack.games.some((game) => game.Preview)
|
|
@@ -95,13 +93,13 @@ function HomeMenu() {
|
|
|
95
93
|
return () => cancelAnimationFrame(id)
|
|
96
94
|
}, [tiled])
|
|
97
95
|
// A popover is placed once, as it opens, from measurements of the frame it opens in. Turning the
|
|
98
|
-
//
|
|
96
|
+
// device (or any resize) moves the frame under it, and switching between the column and the two panes
|
|
99
97
|
// unmounts the very button it hangs off, which would leave the press-away overlay up with nothing to
|
|
100
98
|
// dismiss. So every layout change closes it.
|
|
101
99
|
const closePicker = pickerHost.close
|
|
102
100
|
useEffect(() => {
|
|
103
101
|
closePicker()
|
|
104
|
-
}, [isWide,
|
|
102
|
+
}, [isWide, layout.windowWidth, layout.windowHeight, closePicker])
|
|
105
103
|
|
|
106
104
|
// Over the pack's Backdrop Home draws nothing of its own behind the menu.
|
|
107
105
|
const bg = pack.Backdrop ? 'transparent' : dark ? '#000000' : '#FFFFFF'
|
|
@@ -150,10 +148,9 @@ function HomeMenu() {
|
|
|
150
148
|
|
|
151
149
|
return (
|
|
152
150
|
<View style={[styles.container, { backgroundColor: bg }]}>
|
|
153
|
-
{/*
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
<FakeLandscapeView style={styles.rotatable}>
|
|
151
|
+
{/* The Settings dialog stays outside this frame: it renders through a Portal. A game's rules
|
|
152
|
+
sheet renders inside it instead (see RulesHost). */}
|
|
153
|
+
<View style={styles.frame}>
|
|
157
154
|
<RulesHost enabled={pack.games.some((game) => game.Rules)}>
|
|
158
155
|
{isWide ? (
|
|
159
156
|
<>
|
|
@@ -201,11 +198,11 @@ function HomeMenu() {
|
|
|
201
198
|
<IconButton icon='cog' iconColor={fg} size={24} style={[styles.corner, { top: 8 + insets.top, right: 8 + insets.right }]} onPress={() => setSettingsOpen(true)} accessibilityLabel='Settings' />
|
|
202
199
|
{RulesSheet && <RulesSheet visible onDismiss={() => setRulesGame(null)} />}
|
|
203
200
|
</RulesHost>
|
|
204
|
-
</
|
|
201
|
+
</View>
|
|
205
202
|
|
|
206
203
|
{/* Home's How to Play row replays the pack's own cards (PackDefinition.guide); without them each
|
|
207
204
|
tile's rules button shows its game's. */}
|
|
208
|
-
<SettingsDialog visible={settingsOpen} onDismiss={() => setSettingsOpen(false)}
|
|
205
|
+
<SettingsDialog visible={settingsOpen} onDismiss={() => setSettingsOpen(false)} onShowHowToPlay={pack.guide ? openPack : undefined} />
|
|
209
206
|
</View>
|
|
210
207
|
)
|
|
211
208
|
}
|
|
@@ -258,9 +255,8 @@ function hasRules(game: PackGameDefinition): boolean {
|
|
|
258
255
|
return !!(game.Rules || game.guide)
|
|
259
256
|
}
|
|
260
257
|
|
|
261
|
-
// A game's rules sheet (GameDefinition.Rules) portals into a Paper host inside Home's
|
|
262
|
-
//
|
|
263
|
-
// off a phone held sideways). Only in a pack with one: a pack without keeps the frame as it was.
|
|
258
|
+
// A game's rules sheet (GameDefinition.Rules) portals into a Paper host inside Home's frame, so it's
|
|
259
|
+
// laid out against Home's safe area. Only in a pack with one: a pack without keeps the frame as it was.
|
|
264
260
|
function RulesHost({ enabled, children }: { enabled: boolean; children: ReactNode }) {
|
|
265
261
|
return enabled ? <Portal.Host>{children}</Portal.Host> : <>{children}</>
|
|
266
262
|
}
|
|
@@ -386,6 +382,9 @@ const styles = StyleSheet.create({
|
|
|
386
382
|
corner: {
|
|
387
383
|
position: 'absolute'
|
|
388
384
|
},
|
|
385
|
+
frame: {
|
|
386
|
+
flex: 1
|
|
387
|
+
},
|
|
389
388
|
// The wide layout's tiles: wrapped rows of tiles the layout sizes to fill the pane's width.
|
|
390
389
|
grid: {
|
|
391
390
|
flexDirection: 'row',
|
|
@@ -432,9 +431,6 @@ const styles = StyleSheet.create({
|
|
|
432
431
|
previewIcon: {
|
|
433
432
|
overflow: 'hidden'
|
|
434
433
|
},
|
|
435
|
-
rotatable: {
|
|
436
|
-
flex: 1
|
|
437
|
-
},
|
|
438
434
|
// A well (@tastic/hud's wellSurface): the theme's surface on MD3's level-1 shadow, the same surface
|
|
439
435
|
// as Solitaire's game rows, the stats screen's sections and every dialog.
|
|
440
436
|
row: {
|