@tastic/shell 0.3.1 → 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 +27 -15
- package/dist/index.d.mts +10 -14
- package/dist/index.d.ts +10 -14
- package/dist/index.js +203 -167
- package/dist/index.mjs +161 -125
- 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/index.ts +1 -1
- package/src/redux/appSettingsSlice.ts +0 -11
- package/src/screens/HomeScreen.tsx +98 -30
- 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 +24 -21
|
@@ -1,12 +1,11 @@
|
|
|
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'
|
|
7
|
-
import { type ReactNode, useCallback, useEffect, useMemo, useState } from 'react'
|
|
8
|
-
import { AppState, type AppStateStatus, StyleSheet, View } from 'react-native'
|
|
9
|
-
import { Icon, Portal, Text } from 'react-native-paper'
|
|
6
|
+
import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|
7
|
+
import { AppState, type AppStateStatus, type LayoutChangeEvent, type NativeScrollEvent, type NativeSyntheticEvent, type ScrollView, StyleSheet, View } from 'react-native'
|
|
8
|
+
import { Icon, Portal, SegmentedButtons, Text } from 'react-native-paper'
|
|
10
9
|
|
|
11
10
|
import { useGameGuide } from '../components/GuideHost'
|
|
12
11
|
import { SettingsDialog } from '../components/SettingsDialog'
|
|
@@ -17,14 +16,15 @@ import { useProfiles } from '../hooks/useProfiles'
|
|
|
17
16
|
import { useSeatColors } from '../hooks/useSeatColors'
|
|
18
17
|
import { usePack } from '../PackContext'
|
|
19
18
|
import { loadoutHref, playHref, PROFILES_HREF, STATS_HREF } from '../routes'
|
|
20
|
-
import type { PackGameDefinition } from '../types'
|
|
19
|
+
import type { HomeTab, PackGameDefinition } from '../types'
|
|
21
20
|
import { HOME_GRID_GAP, HOME_LEFT_PANE_PICKER_GAP, HOME_TILE_PADDING, HOME_TOP_BAND, HOME_WIDE_PANE_GAP, PREVIEW_ASPECT, ROW_PREVIEW_WIDTH, useHomeLayout } from './homeLayout'
|
|
22
21
|
|
|
23
22
|
// Home: who's playing (seat 1, the same seat 1 every loadout starts with), then every game in the
|
|
24
23
|
// pack as a row in a fixed order, Solitaire's list: each game on its own well, its small app icon
|
|
25
24
|
// (or its preview, GameDefinition.Preview) beside its name and blurb. A tap opens that game's
|
|
26
25
|
// loadout, or its play screen in a game without one, after its How to Play cards the first time it's
|
|
27
|
-
// picked. Seat 2 is chosen in the loadout, from seat 2's own button.
|
|
26
|
+
// picked. Seat 2 is chosen in the loadout, from seat 2's own button. A pack with tabs
|
|
27
|
+
// (PackDefinition.homeTabs) shows one tab's games at a time, from a switch over the rows.
|
|
28
28
|
//
|
|
29
29
|
// A cold launch that finds the player left the app inside a resumable game's play screen
|
|
30
30
|
// (GameDefinition.resumable) goes straight back there instead, before the menu ever paints. The
|
|
@@ -52,7 +52,6 @@ export function HomeScreen() {
|
|
|
52
52
|
|
|
53
53
|
function HomeMenu() {
|
|
54
54
|
const pack = usePack()
|
|
55
|
-
const rotation = useRotation()
|
|
56
55
|
const layout = useHomeLayout()
|
|
57
56
|
const { insets } = layout
|
|
58
57
|
const { dark, colors } = useAutoPaperTheme()
|
|
@@ -78,7 +77,7 @@ function HomeMenu() {
|
|
|
78
77
|
|
|
79
78
|
// Tiles: while any game in the pack has a preview, a wide screen lays the games out as a grid beside
|
|
80
79
|
// the hero (homeLayout.ts). Home stays mounted beneath every screen the stack pushes and keeps
|
|
81
|
-
// following the
|
|
80
|
+
// following the window, so while it's covered the layout is held where it was (see
|
|
82
81
|
// useFrozenWhileHidden); it catches up when Home is shown again. Only the choice of layout is held:
|
|
83
82
|
// the panes' numbers follow the window, unseen under the screen covering Home.
|
|
84
83
|
const tiled = pack.games.some((game) => game.Preview)
|
|
@@ -94,13 +93,13 @@ function HomeMenu() {
|
|
|
94
93
|
return () => cancelAnimationFrame(id)
|
|
95
94
|
}, [tiled])
|
|
96
95
|
// A popover is placed once, as it opens, from measurements of the frame it opens in. Turning the
|
|
97
|
-
//
|
|
96
|
+
// device (or any resize) moves the frame under it, and switching between the column and the two panes
|
|
98
97
|
// unmounts the very button it hangs off, which would leave the press-away overlay up with nothing to
|
|
99
98
|
// dismiss. So every layout change closes it.
|
|
100
99
|
const closePicker = pickerHost.close
|
|
101
100
|
useEffect(() => {
|
|
102
101
|
closePicker()
|
|
103
|
-
}, [isWide,
|
|
102
|
+
}, [isWide, layout.windowWidth, layout.windowHeight, closePicker])
|
|
104
103
|
|
|
105
104
|
// Over the pack's Backdrop Home draws nothing of its own behind the menu.
|
|
106
105
|
const bg = pack.Backdrop ? 'transparent' : dark ? '#000000' : '#FFFFFF'
|
|
@@ -111,6 +110,18 @@ function HomeMenu() {
|
|
|
111
110
|
const p1Drawn = previewColor ? previewColor(p1Color, dark) : p1Color
|
|
112
111
|
const triggerStyle = useMemo(() => (pack.Backdrop ? [styles.pill, { backgroundColor: colors.surface }] : undefined), [pack.Backdrop, colors.surface])
|
|
113
112
|
|
|
113
|
+
// The pack's tabs (PackDefinition.homeTabs): one tab's games at a time, the same switch as the stats
|
|
114
|
+
// screen's tabs (on the theme's surface over a backdrop). The other tabs' games stay mounted, hidden, so
|
|
115
|
+
// a switch draws nothing new (their previews are already there). The tab is Home's own state, kept while
|
|
116
|
+
// Home is covered, since Home stays mounted under every screen it opens.
|
|
117
|
+
const tabs = pack.homeTabs && pack.homeTabs.length > 1 ? pack.homeTabs : null
|
|
118
|
+
const [tabLabel, setTabLabel] = useState(() => tabs?.[0].label ?? null)
|
|
119
|
+
const tab = tabs ? (tabs.find((t) => t.label === tabLabel) ?? tabs[0]) : null
|
|
120
|
+
const tabButtons = useMemo(() => tabs?.map(({ label }) => ({ value: label, label })) ?? [], [tabs])
|
|
121
|
+
const tabBar = tab && <SegmentedButtons value={tab.label} onValueChange={setTabLabel} buttons={tabButtons} style={[styles.tabs, pack.Backdrop && { backgroundColor: colors.surface }]} />
|
|
122
|
+
const columnClamp = useClampOnShrink()
|
|
123
|
+
const gridClamp = useClampOnShrink()
|
|
124
|
+
|
|
114
125
|
// A game with nothing to set up (no useLoadout) opens straight to its play screen.
|
|
115
126
|
const openGame = useCallback((game: PackGameDefinition) => openFirstTime(game.id, () => router.push(game.useLoadout ? loadoutHref(game.id) : playHref(game.id))), [openFirstTime])
|
|
116
127
|
|
|
@@ -137,10 +148,9 @@ function HomeMenu() {
|
|
|
137
148
|
|
|
138
149
|
return (
|
|
139
150
|
<View style={[styles.container, { backgroundColor: bg }]}>
|
|
140
|
-
{/*
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
<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}>
|
|
144
154
|
<RulesHost enabled={pack.games.some((game) => game.Rules)}>
|
|
145
155
|
{isWide ? (
|
|
146
156
|
<>
|
|
@@ -149,10 +159,11 @@ function HomeMenu() {
|
|
|
149
159
|
to the frame's right edge and the cog's gutter is its content's padding, so a web scrollbar
|
|
150
160
|
sits at the window's edge. Both scrollers fade out at an edge while there is more past it
|
|
151
161
|
(auto-paper's FadingScrollView, a mask, so whatever is behind shows through). */}
|
|
152
|
-
<FadingScrollView style={[styles.wideRightPane, { left: layout.paddingLeft + layout.leftPaneWidth + HOME_WIDE_PANE_GAP }]} contentContainerStyle={{ paddingTop: 16 + insets.top, paddingBottom: 16 + insets.bottom, paddingRight: layout.paddingRight }}>
|
|
162
|
+
<FadingScrollView {...(tab && gridClamp)} style={[styles.wideRightPane, { left: layout.paddingLeft + layout.leftPaneWidth + HOME_WIDE_PANE_GAP }]} contentContainerStyle={{ paddingTop: 16 + insets.top, paddingBottom: 16 + insets.bottom, paddingRight: layout.paddingRight }}>
|
|
163
|
+
{tabBar && <View style={[styles.wideTabs, { width: layout.rightPaneWidth }]}>{tabBar}</View>}
|
|
153
164
|
<View style={[styles.grid, { width: layout.rightPaneWidth }]}>
|
|
154
165
|
{pack.games.map((game) => (
|
|
155
|
-
<GameTile key={game.id} game={game} width={layout.tileWidth} previewWidth={layout.previewWidth} previewRevealed={previewsRevealed} dark={dark} p1Color={p1Color} p2Color={p2Color} onPress={() => openGame(game)} onRules={hasRules(game) ? () => openRules(game) : undefined} />
|
|
166
|
+
<GameTile key={game.id} game={game} hidden={isHidden(tab, game)} width={layout.tileWidth} previewWidth={layout.previewWidth} previewRevealed={previewsRevealed} dark={dark} p1Color={p1Color} p2Color={p2Color} onPress={() => openGame(game)} onRules={hasRules(game) ? () => openRules(game) : undefined} />
|
|
156
167
|
))}
|
|
157
168
|
</View>
|
|
158
169
|
</FadingScrollView>
|
|
@@ -165,7 +176,7 @@ function HomeMenu() {
|
|
|
165
176
|
</View>
|
|
166
177
|
</>
|
|
167
178
|
) : (
|
|
168
|
-
<FadingScrollView style={styles.scroll} contentContainerStyle={[styles.content, { paddingTop: 56 + insets.top, paddingBottom: 24 + insets.bottom, paddingLeft: 16 + insets.left, paddingRight: 16 + insets.right }]}>
|
|
179
|
+
<FadingScrollView {...(tab && columnClamp)} style={styles.scroll} contentContainerStyle={[styles.content, { paddingTop: 56 + insets.top, paddingBottom: 24 + insets.bottom, paddingLeft: 16 + insets.left, paddingRight: 16 + insets.right }]}>
|
|
169
180
|
{pressAway}
|
|
170
181
|
<pack.HeroTitle words={pack.wordmark} color={fg} p1Color={p1Color} p2Color={p2Color} active={heroActive} />
|
|
171
182
|
|
|
@@ -173,9 +184,11 @@ function HomeMenu() {
|
|
|
173
184
|
tiles while its menu is open, so the menu paints over them. */}
|
|
174
185
|
<View style={[styles.picker, pickerHost.openId !== null && styles.pickerOpen]}>{picker}</View>
|
|
175
186
|
|
|
187
|
+
{tabBar && <View style={styles.listWidth}>{tabBar}</View>}
|
|
188
|
+
|
|
176
189
|
<View style={styles.list}>
|
|
177
190
|
{pack.games.map((game) => (
|
|
178
|
-
<GameRow key={game.id} game={game} dark={dark} p1Color={p1Color} p2Color={p2Color} previewRevealed={previewsRevealed} onPress={() => openGame(game)} onRules={hasRules(game) ? () => openRules(game) : undefined} />
|
|
191
|
+
<GameRow key={game.id} game={game} hidden={isHidden(tab, game)} dark={dark} p1Color={p1Color} p2Color={p2Color} previewRevealed={previewsRevealed} onPress={() => openGame(game)} onRules={hasRules(game) ? () => openRules(game) : undefined} />
|
|
179
192
|
))}
|
|
180
193
|
</View>
|
|
181
194
|
</FadingScrollView>
|
|
@@ -185,11 +198,11 @@ function HomeMenu() {
|
|
|
185
198
|
<IconButton icon='cog' iconColor={fg} size={24} style={[styles.corner, { top: 8 + insets.top, right: 8 + insets.right }]} onPress={() => setSettingsOpen(true)} accessibilityLabel='Settings' />
|
|
186
199
|
{RulesSheet && <RulesSheet visible onDismiss={() => setRulesGame(null)} />}
|
|
187
200
|
</RulesHost>
|
|
188
|
-
</
|
|
201
|
+
</View>
|
|
189
202
|
|
|
190
203
|
{/* Home's How to Play row replays the pack's own cards (PackDefinition.guide); without them each
|
|
191
204
|
tile's rules button shows its game's. */}
|
|
192
|
-
<SettingsDialog visible={settingsOpen} onDismiss={() => setSettingsOpen(false)}
|
|
205
|
+
<SettingsDialog visible={settingsOpen} onDismiss={() => setSettingsOpen(false)} onShowHowToPlay={pack.guide ? openPack : undefined} />
|
|
193
206
|
</View>
|
|
194
207
|
)
|
|
195
208
|
}
|
|
@@ -200,20 +213,58 @@ const SEAT_ONE_PICKER = 'home-p1'
|
|
|
200
213
|
// No placement of the pack's own: hud places the menu itself.
|
|
201
214
|
const noPickerAlign = () => undefined
|
|
202
215
|
|
|
216
|
+
// A game the tab picked doesn't include: kept mounted, out of the layout and out of a screen reader's way.
|
|
217
|
+
function isHidden(tab: HomeTab | null, game: PackGameDefinition): boolean {
|
|
218
|
+
return !!tab && !tab.includes(game)
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// iOS leaves a scroller's offset past the end when its content shrinks (React Native's own TODO, T190695447
|
|
222
|
+
// in RCTScrollViewComponentView.mm), so a switch to a shorter tab while scrolled down would leave blank space
|
|
223
|
+
// under the last game until the next drag. This pulls it back in, as Android and the web do by themselves.
|
|
224
|
+
// Spread onto one scroller (its ref and three events) only in a pack with tabs, the one way Home's content
|
|
225
|
+
// shrinks. The ref is a callback so a scroller mounted afresh (a switch between the column and the grid)
|
|
226
|
+
// starts from its real offset, 0, not the last one's.
|
|
227
|
+
function useClampOnShrink() {
|
|
228
|
+
const view = useRef<ScrollView | null>(null)
|
|
229
|
+
const sizes = useRef({ viewport: 0, offset: 0 })
|
|
230
|
+
return useMemo(
|
|
231
|
+
() => ({
|
|
232
|
+
ref: (instance: ScrollView | null) => {
|
|
233
|
+
view.current = instance
|
|
234
|
+
sizes.current.offset = 0
|
|
235
|
+
},
|
|
236
|
+
onLayout: (e: LayoutChangeEvent) => {
|
|
237
|
+
sizes.current.viewport = e.nativeEvent.layout.height
|
|
238
|
+
},
|
|
239
|
+
onScroll: (e: NativeSyntheticEvent<NativeScrollEvent>) => {
|
|
240
|
+
sizes.current.offset = e.nativeEvent.contentOffset.y
|
|
241
|
+
},
|
|
242
|
+
onContentSizeChange: (_width: number, height: number) => {
|
|
243
|
+
const end = Math.max(0, height - sizes.current.viewport)
|
|
244
|
+
if (sizes.current.offset <= end) return
|
|
245
|
+
sizes.current.offset = end
|
|
246
|
+
view.current?.scrollTo({ y: end, animated: false })
|
|
247
|
+
}
|
|
248
|
+
}),
|
|
249
|
+
[]
|
|
250
|
+
)
|
|
251
|
+
}
|
|
252
|
+
|
|
203
253
|
// Whether a tile has a rules button: a rules sheet or How to Play cards to show.
|
|
204
254
|
function hasRules(game: PackGameDefinition): boolean {
|
|
205
255
|
return !!(game.Rules || game.guide)
|
|
206
256
|
}
|
|
207
257
|
|
|
208
|
-
// A game's rules sheet (GameDefinition.Rules) portals into a Paper host inside Home's
|
|
209
|
-
//
|
|
210
|
-
// 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.
|
|
211
260
|
function RulesHost({ enabled, children }: { enabled: boolean; children: ReactNode }) {
|
|
212
261
|
return enabled ? <Portal.Host>{children}</Portal.Host> : <>{children}</>
|
|
213
262
|
}
|
|
214
263
|
|
|
215
264
|
interface GameRowProps {
|
|
216
265
|
game: PackGameDefinition
|
|
266
|
+
// Not in the tab picked (PackDefinition.homeTabs).
|
|
267
|
+
hidden: boolean
|
|
217
268
|
dark: boolean
|
|
218
269
|
p1Color: string
|
|
219
270
|
p2Color: string
|
|
@@ -227,13 +278,13 @@ interface GameRowProps {
|
|
|
227
278
|
// One game on its own well: its icon at app-icon size (the art was drawn as an app icon), or its
|
|
228
279
|
// preview where it has one, its name and one-line blurb, and a rules button at the end that shows its
|
|
229
280
|
// How to Play cards without opening the game.
|
|
230
|
-
function GameRow({ game, dark, p1Color, p2Color, previewRevealed, onPress, onRules }: GameRowProps) {
|
|
281
|
+
function GameRow({ game, hidden, dark, p1Color, p2Color, previewRevealed, onPress, onRules }: GameRowProps) {
|
|
231
282
|
const well = useWellColors()
|
|
232
283
|
const GameIcon = game.icon
|
|
233
284
|
return (
|
|
234
285
|
// The rules button sits beside the row's own button, laid over its end, not inside it: a button
|
|
235
286
|
// inside a button is invalid on the web.
|
|
236
|
-
<View>
|
|
287
|
+
<View style={hidden ? styles.hidden : undefined}>
|
|
237
288
|
<Pressable onPress={onPress} style={[styles.row, { backgroundColor: well.bg }]} accessibilityRole='button' accessibilityLabel={game.title}>
|
|
238
289
|
{game.Preview ? <PreviewBox game={game} width={ROW_PREVIEW_WIDTH} revealed={previewRevealed} dark={dark} p1Color={p1Color} p2Color={p2Color} /> : <View style={[styles.rowIcon, { backgroundColor: dark ? ICON_DARK_BG : ICON_LIGHT_BG }]}>{GameIcon && <GameIcon size={ROW_ICON_SIZE} p1Color={p1Color} p2Color={p2Color} dark={dark} />}</View>}
|
|
239
290
|
<View style={styles.rowText}>
|
|
@@ -262,10 +313,10 @@ interface GameTileProps extends GameRowProps {
|
|
|
262
313
|
// One game in the wide layout's grid: the row turned on its side, its preview (or icon) on top and its
|
|
263
314
|
// name and blurb under it, since a row needs ~290pt, more than a grid column beside the hero has. The
|
|
264
315
|
// rules button floats over the preview's corner, beside the tile's button rather than inside it.
|
|
265
|
-
function GameTile({ game, width, previewWidth, dark, p1Color, p2Color, previewRevealed, onPress, onRules }: GameTileProps) {
|
|
316
|
+
function GameTile({ game, hidden, width, previewWidth, dark, p1Color, p2Color, previewRevealed, onPress, onRules }: GameTileProps) {
|
|
266
317
|
const well = useWellColors()
|
|
267
318
|
return (
|
|
268
|
-
<View style={{ width }}>
|
|
319
|
+
<View style={[{ width }, hidden && styles.hidden]}>
|
|
269
320
|
<Pressable onPress={onPress} style={[styles.tile, { backgroundColor: well.bg }]} accessibilityRole='button' accessibilityLabel={game.title}>
|
|
270
321
|
<PreviewBox game={game} width={previewWidth} revealed={previewRevealed} dark={dark} p1Color={p1Color} p2Color={p2Color} />
|
|
271
322
|
<Text variant='titleMedium' style={[styles.rowTitle, { color: well.fg }]} numberOfLines={1}>
|
|
@@ -331,18 +382,30 @@ const styles = StyleSheet.create({
|
|
|
331
382
|
corner: {
|
|
332
383
|
position: 'absolute'
|
|
333
384
|
},
|
|
385
|
+
frame: {
|
|
386
|
+
flex: 1
|
|
387
|
+
},
|
|
334
388
|
// The wide layout's tiles: wrapped rows of tiles the layout sizes to fill the pane's width.
|
|
335
389
|
grid: {
|
|
336
390
|
flexDirection: 'row',
|
|
337
391
|
flexWrap: 'wrap',
|
|
338
392
|
gap: HOME_GRID_GAP
|
|
339
393
|
},
|
|
394
|
+
// Out of the layout (a grid closes up around it) and hidden from screen readers.
|
|
395
|
+
hidden: {
|
|
396
|
+
display: 'none'
|
|
397
|
+
},
|
|
340
398
|
// Solitaire's list: one column, never wider than a phone's, centred on a wide screen.
|
|
341
399
|
list: {
|
|
342
400
|
gap: 14,
|
|
343
401
|
maxWidth: 480,
|
|
344
402
|
width: '100%'
|
|
345
403
|
},
|
|
404
|
+
// The list's width, for the tabs over it.
|
|
405
|
+
listWidth: {
|
|
406
|
+
maxWidth: 480,
|
|
407
|
+
width: '100%'
|
|
408
|
+
},
|
|
346
409
|
picker: {
|
|
347
410
|
alignItems: 'center'
|
|
348
411
|
},
|
|
@@ -368,9 +431,6 @@ const styles = StyleSheet.create({
|
|
|
368
431
|
previewIcon: {
|
|
369
432
|
overflow: 'hidden'
|
|
370
433
|
},
|
|
371
|
-
rotatable: {
|
|
372
|
-
flex: 1
|
|
373
|
-
},
|
|
374
434
|
// A well (@tastic/hud's wellSurface): the theme's surface on MD3's level-1 shadow, the same surface
|
|
375
435
|
// as Solitaire's game rows, the stats screen's sections and every dialog.
|
|
376
436
|
row: {
|
|
@@ -405,6 +465,10 @@ const styles = StyleSheet.create({
|
|
|
405
465
|
scroll: {
|
|
406
466
|
flex: 1
|
|
407
467
|
},
|
|
468
|
+
// SegmentedButtons' own rounding, so a surface behind it is the same pill (the stats screen's tabs).
|
|
469
|
+
tabs: {
|
|
470
|
+
borderRadius: 20
|
|
471
|
+
},
|
|
408
472
|
// The same well as a row, turned on its side.
|
|
409
473
|
tile: {
|
|
410
474
|
borderRadius: 12,
|
|
@@ -443,5 +507,9 @@ const styles = StyleSheet.create({
|
|
|
443
507
|
position: 'absolute',
|
|
444
508
|
right: 0,
|
|
445
509
|
top: 0
|
|
510
|
+
},
|
|
511
|
+
// The tabs over the grid, a little further from it than the tiles are from each other.
|
|
512
|
+
wideTabs: {
|
|
513
|
+
marginBottom: 16
|
|
446
514
|
}
|
|
447
515
|
})
|
|
@@ -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
|
|
@@ -34,7 +34,7 @@ const ALL_GAMES = '__all__'
|
|
|
34
34
|
const PLAYER_PICKER_ID = 'stats-player'
|
|
35
35
|
const GAME_PICKER_ID = 'stats-game'
|
|
36
36
|
|
|
37
|
-
// Widest the column gets in the wide layout (a phone
|
|
37
|
+
// Widest the column gets in the wide layout (a landscape phone, a desktop window): a list of stat
|
|
38
38
|
// rows stretched across an 874pt-wide screen reads badly. Upright it's never capped.
|
|
39
39
|
const STATS_MAX_WIDTH = 640
|
|
40
40
|
|
|
@@ -45,9 +45,8 @@ const STATS_MAX_WIDTH = 640
|
|
|
45
45
|
// can't be told apart. A pack for one player at a time (PackDefinition.guestStats) keeps a guest's
|
|
46
46
|
// stats under GUEST_PLAYER_ID, offers Guest in the picker and opens on whoever is in seat 1.
|
|
47
47
|
//
|
|
48
|
-
//
|
|
49
|
-
//
|
|
50
|
-
// capped in the wide layout.
|
|
48
|
+
// In hud's RotatedFrame, which nothing turns since @tastic/core 0.15: it hands the screen the safe-area
|
|
49
|
+
// insets, hosts the reset confirm's portal inside the frame, and caps the column in the wide layout.
|
|
51
50
|
export function StatsScreen() {
|
|
52
51
|
const pickerHost = usePopoverHost()
|
|
53
52
|
return (
|
|
@@ -81,7 +80,7 @@ const noPickerAlign = () => undefined
|
|
|
81
80
|
function StatsContent({ pickerHost }: { pickerHost: PopoverHost }) {
|
|
82
81
|
const pack = usePack()
|
|
83
82
|
const { dark, colors } = useAutoPaperTheme()
|
|
84
|
-
//
|
|
83
|
+
// Handed over by RotatedFrame's rotatedSafeArea.
|
|
85
84
|
const insets = useSafeAreaInsets()
|
|
86
85
|
const { profiles, lastSelected } = useProfiles()
|
|
87
86
|
const dispatch = useDispatch()
|
|
@@ -1,20 +1,19 @@
|
|
|
1
|
-
import { clamp
|
|
1
|
+
import { clamp } from '@tastic/core'
|
|
2
|
+
import { useWindowDimensions } from 'react-native'
|
|
2
3
|
import { type EdgeInsets, useSafeAreaInsets } from 'react-native-safe-area-context'
|
|
3
4
|
|
|
4
5
|
// Home's geometry for a pack whose games have previews (GameDefinition.Preview), ported from
|
|
5
6
|
// Solitaire's components/homeLayout.ts. Upright, Home is the one scrolling column every pack has: the
|
|
6
|
-
// hero, seat 1's button, then a row per game, each row's preview at its start.
|
|
7
|
+
// hero, seat 1's button, then a row per game, each row's preview at its start. A landscape phone
|
|
7
8
|
// is only ~400pt tall, and that column would spend the whole height on the hero before showing a
|
|
8
|
-
// single game, so a "wide" layout (any window
|
|
9
|
-
//
|
|
9
|
+
// single game, so a "wide" layout (any window wider than tall: a landscape phone, a landscape iPad, a
|
|
10
|
+
// desktop browser) splits it into two panes: the hero and seat 1's button on the
|
|
10
11
|
// left, and a scrolling grid of game tiles on the right. The trophy (top-left) and cog (top-right)
|
|
11
12
|
// corner buttons stay where they are; the right pane stops short of the cog so tiles never scroll
|
|
12
13
|
// underneath it. A pack without previews keeps the column at every size.
|
|
13
14
|
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
// frame's edges (rotateInsets). The rotated top/bottom insets are ~0 under a portrait-locked OS (a
|
|
17
|
-
// phone held sideways only has left/right insets), so nothing here leans on them for breathing room.
|
|
15
|
+
// A landscape phone's top and bottom insets are ~0 (only the sides have them), so nothing here leans
|
|
16
|
+
// on them for breathing room.
|
|
18
17
|
|
|
19
18
|
// Every preview's width / height: Solitaire's preview canvas (360 x 200), so its deals fit as drawn.
|
|
20
19
|
export const PREVIEW_ASPECT = 360 / 200
|
|
@@ -52,15 +51,15 @@ const HOME_LEFT_PANE_BOTTOM_PADDING = 16
|
|
|
52
51
|
const HOME_HERO_SIDE_ROOM = 16
|
|
53
52
|
|
|
54
53
|
export interface HomeLayout {
|
|
55
|
-
// The
|
|
54
|
+
// The window is landscape: the two-pane layout is in effect (for a pack with previews).
|
|
56
55
|
isWide: boolean
|
|
57
|
-
// The window's
|
|
56
|
+
// The window's size.
|
|
58
57
|
windowWidth: number
|
|
59
58
|
windowHeight: number
|
|
60
|
-
//
|
|
59
|
+
// The safe-area insets.
|
|
61
60
|
insets: EdgeInsets
|
|
62
|
-
// Wide layout: padding on the two panes' row. Each side hugs ITS OWN inset (a phone
|
|
63
|
-
//
|
|
61
|
+
// Wide layout: padding on the two panes' row. Each side hugs ITS OWN inset (a landscape phone has a
|
|
62
|
+
// large inset only on the island side), the trailing one also reserves the cog's gutter, and
|
|
64
63
|
// both carry the equal margin left over once the content hits its cap. 0 upright.
|
|
65
64
|
paddingLeft: number
|
|
66
65
|
paddingRight: number
|
|
@@ -118,12 +117,9 @@ export function computeHomeLayout(windowWidth: number, windowHeight: number, ins
|
|
|
118
117
|
}
|
|
119
118
|
}
|
|
120
119
|
|
|
121
|
-
// The live layout for the current window
|
|
122
|
-
// rotateInsets() rather than raw useWindowDimensions()/useSafeAreaInsets(), so it describes the
|
|
123
|
-
// footprint a rotated Home actually renders into (both are exact identities at 0deg).
|
|
120
|
+
// The live layout for the current window.
|
|
124
121
|
export function useHomeLayout(): HomeLayout {
|
|
125
|
-
const { width, height } =
|
|
126
|
-
const
|
|
127
|
-
const insets = rotateInsets(useSafeAreaInsets(), rotation)
|
|
122
|
+
const { width, height } = useWindowDimensions()
|
|
123
|
+
const insets = useSafeAreaInsets()
|
|
128
124
|
return computeHomeLayout(width, height, insets)
|
|
129
125
|
}
|