@tastic/shell 0.4.0 → 0.5.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 +26 -15
- package/dist/index.d.mts +5 -14
- package/dist/index.d.ts +5 -14
- package/dist/index.js +146 -166
- package/dist/index.mjs +94 -114
- package/package.json +16 -18
- package/src/RootLayout.tsx +27 -66
- package/src/components/GuideHost.tsx +4 -6
- 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/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/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
|
)
|
|
@@ -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
|
|
@@ -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: {
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import { defaultColors, getRgb, useAutoPaperTheme } from '@rific/auto-paper'
|
|
2
|
-
import {
|
|
2
|
+
import { useOrientationState } from '@tastic/core'
|
|
3
3
|
import { CornerActionButtons, PressAwayOverlay, usePopoverHost } from '@tastic/hud/chrome'
|
|
4
4
|
import { PlayerSetupPanel, ReadyButton, SharedActionBand } from '@tastic/hud/lobby'
|
|
5
5
|
import type { Profile } from '@tastic/profile'
|
|
6
6
|
import { DualZoneLayout, needsSharedNeutralZone, useDualZoneLayout } from '@tastic/split-screen'
|
|
7
7
|
import { router, useFocusEffect } from 'expo-router'
|
|
8
8
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|
9
|
-
import {
|
|
9
|
+
import { type StyleProp, StyleSheet, View, type ViewStyle } from 'react-native'
|
|
10
10
|
import Animated from 'react-native-reanimated'
|
|
11
11
|
import { useSafeAreaInsets } from 'react-native-safe-area-context'
|
|
12
12
|
import { useDispatch } from 'react-redux'
|
|
@@ -24,11 +24,11 @@ import { liveplayActions } from '../redux/liveplaySlice'
|
|
|
24
24
|
import { playHref, PROFILES_HREF } from '../routes'
|
|
25
25
|
import { humanSeatsFor, type PackGameDefinition, roundModeFor, type Seat, type SeatTwo, seatTwoFor } from '../types'
|
|
26
26
|
|
|
27
|
-
// The player-panel area: whenever
|
|
28
|
-
//
|
|
29
|
-
// back in - masking what would otherwise be an
|
|
30
|
-
// Short and symmetric on purpose: this is masking a reflow that
|
|
31
|
-
// pace, not a deliberate reveal.
|
|
27
|
+
// The player-panel area: whenever the window changes shape (a tablet turning, a browser window
|
|
28
|
+
// resized) and the panels go from face to face to side by side or back, they fade out, the layout
|
|
29
|
+
// underneath swaps while invisible, then they fade back in - masking what would otherwise be an
|
|
30
|
+
// instant jump-cut as panels reflow. Short and symmetric on purpose: this is masking a reflow that
|
|
31
|
+
// already happened at the OS's own pace, not a deliberate reveal.
|
|
32
32
|
const PANEL_SWAP_FADE_MS = 180
|
|
33
33
|
|
|
34
34
|
// A game that has a loadout (GameDefinition.useLoadout): the loadout route sends any other straight
|
|
@@ -53,32 +53,24 @@ export function LoadoutScreen({ game }: { game: LoadoutGame }) {
|
|
|
53
53
|
const { openFor } = useGameGuide()
|
|
54
54
|
|
|
55
55
|
const { profiles, lastSelected, selectProfile } = useProfiles()
|
|
56
|
-
//
|
|
57
|
-
//
|
|
58
|
-
|
|
59
|
-
// the shared reading itself (RootLayout's OrientationBridge), so this read takes no `locked`
|
|
60
|
-
// argument. The OS is portrait-locked; FakeLandscapeView below fakes the rest in JS.
|
|
61
|
-
const { orientationMode, p1OnRight, upsideDown, resolved: p1OnRightResolved } = useOrientationState()
|
|
56
|
+
// The window's shape (@tastic/core's useOrientationState): side by side in a window wider than tall,
|
|
57
|
+
// face to face otherwise. The OS turns the interface; a portrait-locked phone is always face to face.
|
|
58
|
+
const { orientationMode } = useOrientationState()
|
|
62
59
|
|
|
63
|
-
// The panel area's own layout lags one fade behind the
|
|
64
|
-
//
|
|
65
|
-
//
|
|
66
|
-
//
|
|
67
|
-
//
|
|
68
|
-
const
|
|
69
|
-
const
|
|
70
|
-
const { panelLayout, panelFadeStyle } = useDualZoneLayout(fixedSeats ? 'faceToFace' : orientationMode, fixedSeats ? true : p1OnRight, fixedSeats || p1OnRightResolved, fixedSeats ? fixedUpsideDown : upsideDown, PANEL_SWAP_FADE_MS)
|
|
60
|
+
// The panel area's own layout lags one fade behind the window above - see useDualZoneLayout's own
|
|
61
|
+
// comment. 2 Player seats its players the way the game's own board does: face to face (P1 in the
|
|
62
|
+
// bottom half, P2 in the top) in any window, unless the game seats them side by side in a wide one
|
|
63
|
+
// (GameDefinition.sideBySideWhenWide), P1 on the left, so each player gets the same half here and
|
|
64
|
+
// in the match. With one person the panels follow the window.
|
|
65
|
+
const panelMode = p2IsHuman && !game.sideBySideWhenWide ? 'faceToFace' : orientationMode
|
|
66
|
+
const { panelLayout, panelFadeStyle } = useDualZoneLayout(panelMode, false, true, false, PANEL_SWAP_FADE_MS)
|
|
71
67
|
// split-screen types the fade as a style for any component (useAnimatedStyle's DefaultStyle). An app
|
|
72
68
|
// whose types widen Text's style (Expo's react-native-web types, which a generated expo-env.d.ts pulls
|
|
73
69
|
// in, make its cursor any string) can't hand that to Animated.View. It only ever sets opacity.
|
|
74
70
|
const playersRowFade = panelFadeStyle as unknown as StyleProp<ViewStyle>
|
|
75
71
|
|
|
76
72
|
const [settingsOpen, setSettingsOpen] = useState(false)
|
|
77
|
-
|
|
78
|
-
// onto whichever edge they land on once FakeLandscapeView turns the content, so `top`/`left`/
|
|
79
|
-
// `right` below always mean the screen's real, visual edges.
|
|
80
|
-
const rotation = getViewRotation(panelLayout.orientationMode, panelLayout.p1OnRight, panelLayout.upsideDown)
|
|
81
|
-
const insets = rotateInsets(useSafeAreaInsets(), rotation)
|
|
73
|
+
const insets = useSafeAreaInsets()
|
|
82
74
|
const { colors: themeColors, dark } = useAutoPaperTheme()
|
|
83
75
|
const { p1Color, p2Color, handleP1ColorChange, handleP2ColorChange } = useSeatColors(p2IsHuman)
|
|
84
76
|
const dispatch = useDispatch<Dispatch<Action>>()
|
|
@@ -142,9 +134,9 @@ export function LoadoutScreen({ game }: { game: LoadoutGame }) {
|
|
|
142
134
|
const showMetaInSharedBand = needsSharedNeutralZone(panelLayout.orientationMode, humanSeats.length)
|
|
143
135
|
|
|
144
136
|
// Where P2's own press-away zone lives on screen - matches panelLayout (what's actually painted
|
|
145
|
-
// right now, mid-fade included). Portrait puts P2's panel in the top half; landscape on
|
|
146
|
-
//
|
|
147
|
-
const p2ZoneStyle = p2IsHuman ? (isPortrait ? styles.p2ZoneTop :
|
|
137
|
+
// right now, mid-fade included). Portrait puts P2's panel in the top half; landscape on the right
|
|
138
|
+
// (P1 sits on the left). Only in 2 Player - otherwise seat 2 shares seat 1's host (see p2Host below).
|
|
139
|
+
const p2ZoneStyle = p2IsHuman ? (isPortrait ? styles.p2ZoneTop : styles.p2ZoneRight) : null
|
|
148
140
|
|
|
149
141
|
// With one human, the one Ready button sits under both panels instead of in each.
|
|
150
142
|
const showReadyButton = p2IsHuman
|
|
@@ -167,7 +159,6 @@ export function LoadoutScreen({ game }: { game: LoadoutGame }) {
|
|
|
167
159
|
|
|
168
160
|
const optionsRow = loadout.options({
|
|
169
161
|
host: controlsHost,
|
|
170
|
-
rotation,
|
|
171
162
|
accentColor: themeColors.tertiary,
|
|
172
163
|
mutedColor: fgMuted,
|
|
173
164
|
onAccentColor: themeColors.onTertiary,
|
|
@@ -186,7 +177,7 @@ export function LoadoutScreen({ game }: { game: LoadoutGame }) {
|
|
|
186
177
|
optionsRow
|
|
187
178
|
)
|
|
188
179
|
|
|
189
|
-
const controlLook = { accentColor: themeColors.tertiary, mutedColor: fgMuted, dark
|
|
180
|
+
const controlLook = { accentColor: themeColors.tertiary, mutedColor: fgMuted, dark }
|
|
190
181
|
const p1Control = loadout.seatControl(1, controlsHost, { ...controlLook, seatColor: p1Drawn })
|
|
191
182
|
const p1Panel = <PlayerSetupPanel key={`p1-${layoutKey}`} idPrefix='p1' host={controlsHost} color={p1Color} onColorChange={handleP1ColorChange} swatches={defaultColors} takenColor={p2Exists ? p2Color : undefined} allowSwapTaken={mode === 'vsCpu'} colorCaption={pack.colorCaption} isHuman ready={ready[1]} onToggleReady={() => setReady((r) => ({ ...r, 1: !r[1] }))} dark={dark} showReadyButton={showReadyButton} secondPicker={p1Control} secondPickerId={p1Control ? 'p1-control' : undefined} profiles={profiles} selectedProfileId={lastSelected[1]} takenProfileId={p2IsHuman ? lastSelected[2] : null} guestLabel={pack.guestLabel ?? 'P1'} nullLabel={pack.nullLabel} onProfileSelect={handleP1ProfileSelect} onManageProfiles={() => router.push(PROFILES_HREF)} previewValue={seatPreview} triggerStyle={triggerStyle} useSeatPickerAlign={seatPickerAlign} />
|
|
192
183
|
const p2Control = p2IsHuman ? loadout.seatControl(2, p2Host, { ...controlLook, seatColor: p2Drawn }) : undefined
|
|
@@ -195,10 +186,8 @@ export function LoadoutScreen({ game }: { game: LoadoutGame }) {
|
|
|
195
186
|
|
|
196
187
|
return (
|
|
197
188
|
<View style={[styles.container, { backgroundColor: bg }]}>
|
|
198
|
-
{/*
|
|
199
|
-
|
|
200
|
-
dialog stays outside: it renders through a Portal, turned by its own `rotation`. */}
|
|
201
|
-
<FakeLandscapeView orientationMode={panelLayout.orientationMode} p1OnRight={panelLayout.p1OnRight} upsideDown={panelLayout.upsideDown} style={styles.rotatable}>
|
|
189
|
+
{/* The Settings dialog stays outside this frame: it renders through a Portal. */}
|
|
190
|
+
<View style={styles.frame}>
|
|
202
191
|
{/* Press-away overlays for the loadout's own menus. P1's covers the whole screen (their
|
|
203
192
|
panel plus the match options read as theirs), while P2's covers only their half and, as a
|
|
204
193
|
later sibling, takes priority there. P2's mounts whenever either host has a menu open, so it
|
|
@@ -225,15 +214,15 @@ export function LoadoutScreen({ game }: { game: LoadoutGame }) {
|
|
|
225
214
|
<View style={styles.stackedZone}>
|
|
226
215
|
{sharedControls}
|
|
227
216
|
<Animated.View style={[styles.playersRow, anyPlayerPickerOpen && styles.playersRowOpen, playersRowFade]}>
|
|
228
|
-
{
|
|
229
|
-
{
|
|
217
|
+
{p1Panel}
|
|
218
|
+
{p2Panel}
|
|
230
219
|
</Animated.View>
|
|
231
220
|
<ReadyButton color={p1Drawn} ready={ready[1]} onToggleReady={() => setReady((r) => ({ ...r, 1: !r[1] }))} style={anyPlayerPickerOpen && styles.readyButtonHidden} />
|
|
232
221
|
</View>
|
|
233
222
|
)}
|
|
234
|
-
</
|
|
223
|
+
</View>
|
|
235
224
|
|
|
236
|
-
<SettingsDialog visible={settingsOpen} onDismiss={() => setSettingsOpen(false)}
|
|
225
|
+
<SettingsDialog visible={settingsOpen} onDismiss={() => setSettingsOpen(false)} onShowHowToPlay={() => openFor(game.id)} />
|
|
237
226
|
</View>
|
|
238
227
|
)
|
|
239
228
|
}
|
|
@@ -249,12 +238,13 @@ const styles = StyleSheet.create({
|
|
|
249
238
|
container: {
|
|
250
239
|
flex: 1
|
|
251
240
|
},
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
241
|
+
// A real full-bleed box to size the absolutely-positioned children against.
|
|
242
|
+
frame: {
|
|
243
|
+
alignItems: 'center',
|
|
244
|
+
flex: 1,
|
|
245
|
+
gap: 32,
|
|
246
|
+
justifyContent: 'center',
|
|
247
|
+
paddingHorizontal: 16
|
|
258
248
|
},
|
|
259
249
|
p2ZoneRight: {
|
|
260
250
|
bottom: 0,
|
|
@@ -289,14 +279,6 @@ const styles = StyleSheet.create({
|
|
|
289
279
|
opacity: 0,
|
|
290
280
|
pointerEvents: 'none'
|
|
291
281
|
},
|
|
292
|
-
// A real full-bleed box for FakeLandscapeView to size its absolutely-positioned children against.
|
|
293
|
-
rotatable: {
|
|
294
|
-
alignItems: 'center',
|
|
295
|
-
flex: 1,
|
|
296
|
-
gap: 32,
|
|
297
|
-
justifyContent: 'center',
|
|
298
|
-
paddingHorizontal: 16
|
|
299
|
-
},
|
|
300
282
|
stackedZone: {
|
|
301
283
|
alignItems: 'center',
|
|
302
284
|
gap: 28
|
|
@@ -6,7 +6,7 @@ import { useProfiles } from '../hooks/useProfiles'
|
|
|
6
6
|
import { safeBack } from '../navigation'
|
|
7
7
|
import { usePack } from '../PackContext'
|
|
8
8
|
|
|
9
|
-
// Widest the roster gets in the wide layout (a phone
|
|
9
|
+
// Widest the roster gets in the wide layout (a landscape phone, a desktop window). Upright it's
|
|
10
10
|
// never capped.
|
|
11
11
|
const ROSTER_MAX_WIDTH = 640
|
|
12
12
|
|
|
@@ -14,10 +14,10 @@ const ROSTER_MAX_WIDTH = 640
|
|
|
14
14
|
// stats and achievements in every game: each game's reducers listen for the removal (see
|
|
15
15
|
// onProfileRemoved.ts).
|
|
16
16
|
//
|
|
17
|
-
//
|
|
18
|
-
// safe-area insets itself (the header's padding),
|
|
19
|
-
//
|
|
20
|
-
//
|
|
17
|
+
// In hud's RotatedFrame, which nothing turns since @tastic/core 0.15: ProfilesManager reads the
|
|
18
|
+
// safe-area insets itself (the header's padding), handed over by rotatedSafeArea; its delete confirm is a
|
|
19
|
+
// Paper Portal, which lands in a host inside the frame (portalHost); and the roster's column is capped in
|
|
20
|
+
// the wide layout. Over a
|
|
21
21
|
// pack's Backdrop the screen is transparent, drawn in the pack's foreground.
|
|
22
22
|
// The pack's own placement for a profile's color swatch (PackDefinition.usePickerAlign), kept inside the
|
|
23
23
|
// roster's capped column: the roster scrolls in it, which clips the menu at its edges. Handed to
|