@tastic/shell 0.3.0 → 0.4.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 +6 -3
- package/dist/index.d.mts +6 -1
- package/dist/index.d.ts +6 -1
- package/dist/index.js +75 -14
- package/dist/index.mjs +80 -19
- package/package.json +1 -1
- package/src/RootLayout.tsx +31 -19
- package/src/index.ts +1 -1
- package/src/screens/HomeScreen.tsx +85 -13
- package/src/types.ts +11 -0
package/README.md
CHANGED
|
@@ -18,9 +18,11 @@ package ecosystem, on [`@tastic/hud`](https://github.com/jayrdeaton/react-native
|
|
|
18
18
|
|
|
19
19
|
- **`RootLayout`**: the root of the app (gesture root, safe area, fonts, Redux and its persist gate,
|
|
20
20
|
the theme, profiles, every game's own provider, the splash gates, the shared orientation reading,
|
|
21
|
-
toasts, How to Play, the OTA update dialog) around expo-router's stack.
|
|
21
|
+
toasts, How to Play, the OTA update dialog) around expo-router's stack. The stack follows the app's
|
|
22
|
+
own theme, not the phone's light or dark appearance: the status bar's text, the root window and the
|
|
23
|
+
navigation theme's background (what shows in a sliding screen's corners during a push or pop).
|
|
22
24
|
- **Screens** for the routes: `HomeScreen` (the pack's hero, seat 1's button, a row per game, or a
|
|
23
|
-
grid of preview tiles on a wide screen), `LoadoutRoute` and `PlayRoute` (`loadout/[game]`,
|
|
25
|
+
grid of preview tiles on a wide screen, split into tabs where the pack has them), `LoadoutRoute` and `PlayRoute` (`loadout/[game]`,
|
|
24
26
|
`play/[game]`), `ProfilesRoute` and `StatsScreen`.
|
|
25
27
|
- **The store**: `createPackStore(pack, { keyPrefix, migrate, blacklist, serializableIgnoredPaths,
|
|
26
28
|
middleware })` builds one persisted store from the shell's keys (theme, haptics, sound, Keep Awake,
|
|
@@ -112,7 +114,7 @@ PuckTastic and DuelTastic draw what they always did:
|
|
|
112
114
|
| `Backdrop?` | A table drawn once behind the whole stack (Solitaire's felt). The stack's two layers go transparent (the screens' `contentStyle` and the navigation theme's `colors.background`, which React Navigation paints around every screen), the root window stays dark so light mode doesn't flash white on a push, Android's push and pop crossfade (its default holds the outgoing screen opaque), and Home, each loadout, the stats screen and Profiles draw transparent, with their buttons and tabs on the theme's surface. |
|
|
113
115
|
| `foreground?` | The color of what those screens draw straight on the background (the hero, the corner buttons, headers), and at half strength a loadout's muted options. Unset is black or white with the theme. |
|
|
114
116
|
| `Provider?` | Mounted once around How to Play and the stack, inside the store, the theme, profiles and every game's `Provider` (one stats context across the pack's games). |
|
|
115
|
-
| `useScreenOptions?` | A hook returning stack screen options (`StackScreenOptions`), merged after the shell's own (a
|
|
117
|
+
| `useScreenOptions?` | A hook returning stack screen options (`StackScreenOptions`), merged after the shell's own (a status bar style of the pack's own, Reduce Motion's crossfade). The shell's already follow the app's theme, and stay dark over a `Backdrop`. |
|
|
116
118
|
| `useFeedbackSounds?` | A hook returning the two sounds every button plays (`FeedbackSounds`: `selection`, `notification`), in place of the shell's two wavs. |
|
|
117
119
|
| `Toaster?` | The pack's own toaster, drawn in a frame turned with the phone (with the safe-area insets turned onto its edges) in place of the shell's. The toast provider is given no Paper, so nothing portals it out of the frame. |
|
|
118
120
|
| `portraitWhileKeyboard?` | Every screen reads upright while the software keyboard is up (`@tastic/core`'s `OrientationProvider`), for naming a profile sideways. |
|
|
@@ -128,6 +130,7 @@ PuckTastic and DuelTastic draw what they always did:
|
|
|
128
130
|
| `statsGameTitles?` | `false` leaves each game's name off its sections on All Games, where the sections name it. |
|
|
129
131
|
| `newProfileColor?` | A new profile's starting color on Profiles. Unset is auto-paper's first swatch. |
|
|
130
132
|
| `usePickerAlign?` | `(open, contentWidth, contentHeight, options?) => AlignResult` (`PickerAlign`), a hook: where seat 1's menu on Home, both seat buttons' menus in a loadout's one-human row (2 Player keeps each menu in its own player's half), the stats screen's two pickers and a profile's color swatch open. hud's own placement measures a turned button against the upright window, so with the phone held sideways it can run a menu off the screen (seen on the iOS simulator, 2026-10-29); a pack whose screens turn hands in a frame-aware one (Solitaire's `useFrameAutoAlign`). Profiles passes `{ maxContentWidth: 640 }`, its roster's column. Unset is hud's placement. |
|
|
133
|
+
| `homeTabs?` | Tabs splitting Home's games (`HomeTab`: a `label`, and `includes(game)`, which of the pack's games it shows, in the pack's order): a switch under seat 1's button, or over the grid on a wide screen, on the theme's surface over a `Backdrop`, showing one tab's games at a time. It opens on the first tab and keeps the one picked while Home is covered or turns. The other tabs' games stay mounted, hidden, so a switch draws nothing new. A game no tab includes isn't on Home. Unset, or a single tab, shows every game with no switch. Solitaire's: Solitaire (no `seatTwo`) and Two Player. |
|
|
131
134
|
|
|
132
135
|
### `GameDefinition`
|
|
133
136
|
|
package/dist/index.d.mts
CHANGED
|
@@ -252,6 +252,10 @@ interface PackStatsSectionsProps {
|
|
|
252
252
|
profileId: string | null;
|
|
253
253
|
gameId: string | null;
|
|
254
254
|
}
|
|
255
|
+
interface HomeTab {
|
|
256
|
+
label: string;
|
|
257
|
+
includes: (game: PackGameDefinition) => boolean;
|
|
258
|
+
}
|
|
255
259
|
type StackScreenOptions = Exclude<NonNullable<ComponentProps<typeof Stack>['screenOptions']>, (...args: never[]) => unknown>;
|
|
256
260
|
interface FeedbackSounds {
|
|
257
261
|
selection: () => void;
|
|
@@ -275,6 +279,7 @@ interface PackDefinition {
|
|
|
275
279
|
wordmark: readonly [string, string];
|
|
276
280
|
colorCaption: string;
|
|
277
281
|
games: readonly PackGameDefinition[];
|
|
282
|
+
homeTabs?: readonly HomeTab[];
|
|
278
283
|
seatColors: Record<Seat, string>;
|
|
279
284
|
appearance?: ThemeAppearance;
|
|
280
285
|
HeroTitle: ComponentType<HeroTitleProps>;
|
|
@@ -556,4 +561,4 @@ declare function createPlayerSettings<S extends WithControls<C>, C>({ id, defaul
|
|
|
556
561
|
};
|
|
557
562
|
};
|
|
558
563
|
|
|
559
|
-
export { type ActiveRound, type AppSettings, DEFAULT_APP_SETTINGS, DEFAULT_OPPONENT, type FeedbackSounds, GUEST_PLAYER_ID, type GameDefinition, type GameGuide, type GameGuideState, type GameIconProps, type GameLoadout, type GamePreviewProps, type GameRulesProps, type GuidelessGameDefinition, type HeroTitleProps, HomeScreen, type LiveplaySliceState, type LoadoutOptionsProps, LoadoutRoute, type OpponentState, PREVIEW_ASPECT, type PackDefinition, type PackGameDefinition, type PackGuide, PackProvider, type PackStatsReset, type PackStatsSectionsProps, type PackStatsTab, type PackStore, type PackStoreConfig, type PackStoreSource, type PickerAlign, PlayRoute, type PlayerControlsState, type PlayerSettingsState, type ProfileSelectionState, ProfilesProvider, ProfilesRoute, type RecordResult, RootLayout, type RoundMode, type Seat, type SeatColorsState, type SeatControlLook, type SeatTwo, SettingsDialog, type SettingsDialogProps, type ShellState, type StackScreenOptions, StatsScreen, appSettingsActions, reducer$1 as appSettingsReducer, createGameGuideSlice, createGameStats, createPackStore, createPlayerSettings, createSeatColorsReducer, createShellReducers, defaultProfileSelectionState, defaultSeatColorsState, forgetProfileAchievements, humanSeatsFor, imageGameIcon, liveplayActions, reducer$3 as liveplayReducer, onProfileRemoved, opponentActions, reducer$2 as opponentReducer, profileSelectionActions, reducer as profileSelectionReducer, roundModeFor, seatColorsActions, seatPlayerKey, seatTwoFor, useActiveRound, useAppSettings, useGameGuide, useOpponent, usePack, usePackGame, useProfiles, useSeatColors };
|
|
564
|
+
export { type ActiveRound, type AppSettings, DEFAULT_APP_SETTINGS, DEFAULT_OPPONENT, type FeedbackSounds, GUEST_PLAYER_ID, type GameDefinition, type GameGuide, type GameGuideState, type GameIconProps, type GameLoadout, type GamePreviewProps, type GameRulesProps, type GuidelessGameDefinition, type HeroTitleProps, HomeScreen, type HomeTab, type LiveplaySliceState, type LoadoutOptionsProps, LoadoutRoute, type OpponentState, PREVIEW_ASPECT, type PackDefinition, type PackGameDefinition, type PackGuide, PackProvider, type PackStatsReset, type PackStatsSectionsProps, type PackStatsTab, type PackStore, type PackStoreConfig, type PackStoreSource, type PickerAlign, PlayRoute, type PlayerControlsState, type PlayerSettingsState, type ProfileSelectionState, ProfilesProvider, ProfilesRoute, type RecordResult, RootLayout, type RoundMode, type Seat, type SeatColorsState, type SeatControlLook, type SeatTwo, SettingsDialog, type SettingsDialogProps, type ShellState, type StackScreenOptions, StatsScreen, appSettingsActions, reducer$1 as appSettingsReducer, createGameGuideSlice, createGameStats, createPackStore, createPlayerSettings, createSeatColorsReducer, createShellReducers, defaultProfileSelectionState, defaultSeatColorsState, forgetProfileAchievements, humanSeatsFor, imageGameIcon, liveplayActions, reducer$3 as liveplayReducer, onProfileRemoved, opponentActions, reducer$2 as opponentReducer, profileSelectionActions, reducer as profileSelectionReducer, roundModeFor, seatColorsActions, seatPlayerKey, seatTwoFor, useActiveRound, useAppSettings, useGameGuide, useOpponent, usePack, usePackGame, useProfiles, useSeatColors };
|
package/dist/index.d.ts
CHANGED
|
@@ -252,6 +252,10 @@ interface PackStatsSectionsProps {
|
|
|
252
252
|
profileId: string | null;
|
|
253
253
|
gameId: string | null;
|
|
254
254
|
}
|
|
255
|
+
interface HomeTab {
|
|
256
|
+
label: string;
|
|
257
|
+
includes: (game: PackGameDefinition) => boolean;
|
|
258
|
+
}
|
|
255
259
|
type StackScreenOptions = Exclude<NonNullable<ComponentProps<typeof Stack>['screenOptions']>, (...args: never[]) => unknown>;
|
|
256
260
|
interface FeedbackSounds {
|
|
257
261
|
selection: () => void;
|
|
@@ -275,6 +279,7 @@ interface PackDefinition {
|
|
|
275
279
|
wordmark: readonly [string, string];
|
|
276
280
|
colorCaption: string;
|
|
277
281
|
games: readonly PackGameDefinition[];
|
|
282
|
+
homeTabs?: readonly HomeTab[];
|
|
278
283
|
seatColors: Record<Seat, string>;
|
|
279
284
|
appearance?: ThemeAppearance;
|
|
280
285
|
HeroTitle: ComponentType<HeroTitleProps>;
|
|
@@ -556,4 +561,4 @@ declare function createPlayerSettings<S extends WithControls<C>, C>({ id, defaul
|
|
|
556
561
|
};
|
|
557
562
|
};
|
|
558
563
|
|
|
559
|
-
export { type ActiveRound, type AppSettings, DEFAULT_APP_SETTINGS, DEFAULT_OPPONENT, type FeedbackSounds, GUEST_PLAYER_ID, type GameDefinition, type GameGuide, type GameGuideState, type GameIconProps, type GameLoadout, type GamePreviewProps, type GameRulesProps, type GuidelessGameDefinition, type HeroTitleProps, HomeScreen, type LiveplaySliceState, type LoadoutOptionsProps, LoadoutRoute, type OpponentState, PREVIEW_ASPECT, type PackDefinition, type PackGameDefinition, type PackGuide, PackProvider, type PackStatsReset, type PackStatsSectionsProps, type PackStatsTab, type PackStore, type PackStoreConfig, type PackStoreSource, type PickerAlign, PlayRoute, type PlayerControlsState, type PlayerSettingsState, type ProfileSelectionState, ProfilesProvider, ProfilesRoute, type RecordResult, RootLayout, type RoundMode, type Seat, type SeatColorsState, type SeatControlLook, type SeatTwo, SettingsDialog, type SettingsDialogProps, type ShellState, type StackScreenOptions, StatsScreen, appSettingsActions, reducer$1 as appSettingsReducer, createGameGuideSlice, createGameStats, createPackStore, createPlayerSettings, createSeatColorsReducer, createShellReducers, defaultProfileSelectionState, defaultSeatColorsState, forgetProfileAchievements, humanSeatsFor, imageGameIcon, liveplayActions, reducer$3 as liveplayReducer, onProfileRemoved, opponentActions, reducer$2 as opponentReducer, profileSelectionActions, reducer as profileSelectionReducer, roundModeFor, seatColorsActions, seatPlayerKey, seatTwoFor, useActiveRound, useAppSettings, useGameGuide, useOpponent, usePack, usePackGame, useProfiles, useSeatColors };
|
|
564
|
+
export { type ActiveRound, type AppSettings, DEFAULT_APP_SETTINGS, DEFAULT_OPPONENT, type FeedbackSounds, GUEST_PLAYER_ID, type GameDefinition, type GameGuide, type GameGuideState, type GameIconProps, type GameLoadout, type GamePreviewProps, type GameRulesProps, type GuidelessGameDefinition, type HeroTitleProps, HomeScreen, type HomeTab, type LiveplaySliceState, type LoadoutOptionsProps, LoadoutRoute, type OpponentState, PREVIEW_ASPECT, type PackDefinition, type PackGameDefinition, type PackGuide, PackProvider, type PackStatsReset, type PackStatsSectionsProps, type PackStatsTab, type PackStore, type PackStoreConfig, type PackStoreSource, type PickerAlign, PlayRoute, type PlayerControlsState, type PlayerSettingsState, type ProfileSelectionState, ProfilesProvider, ProfilesRoute, type RecordResult, RootLayout, type RoundMode, type Seat, type SeatColorsState, type SeatControlLook, type SeatTwo, SettingsDialog, type SettingsDialogProps, type ShellState, type StackScreenOptions, StatsScreen, appSettingsActions, reducer$1 as appSettingsReducer, createGameGuideSlice, createGameStats, createPackStore, createPlayerSettings, createSeatColorsReducer, createShellReducers, defaultProfileSelectionState, defaultSeatColorsState, forgetProfileAchievements, humanSeatsFor, imageGameIcon, liveplayActions, reducer$3 as liveplayReducer, onProfileRemoved, opponentActions, reducer$2 as opponentReducer, profileSelectionActions, reducer as profileSelectionReducer, roundModeFor, seatColorsActions, seatPlayerKey, seatTwoFor, useActiveRound, useAppSettings, useGameGuide, useOpponent, usePack, usePackGame, useProfiles, useSeatColors };
|
package/dist/index.js
CHANGED
|
@@ -484,8 +484,9 @@ function AppStack() {
|
|
|
484
484
|
const pack = usePack();
|
|
485
485
|
const { Backdrop } = pack;
|
|
486
486
|
const { dark } = (0, import_auto_paper2.useAutoPaperTheme)();
|
|
487
|
-
const
|
|
488
|
-
const
|
|
487
|
+
const darkRoot = Backdrop ? true : dark;
|
|
488
|
+
const rootBackground = (0, import_core6.useThemedRootBackground)(darkRoot, false);
|
|
489
|
+
const statusBar = (0, import_core6.useRotationAwareStatusBar)({ style: darkRoot ? "light" : "dark" });
|
|
489
490
|
const packOptions = (pack.useScreenOptions ?? noScreenOptions)();
|
|
490
491
|
const backdropOptions = Backdrop ? { contentStyle: TRANSPARENT_CONTENT, animation: import_react_native2.Platform.select({ android: "fade" }) } : void 0;
|
|
491
492
|
const playOptions = (0, import_react9.useCallback)(
|
|
@@ -495,15 +496,19 @@ function AppStack() {
|
|
|
495
496
|
},
|
|
496
497
|
[pack.games]
|
|
497
498
|
);
|
|
498
|
-
const
|
|
499
|
-
const
|
|
499
|
+
const navigationBackground = Backdrop ? TRANSPARENT_CONTENT.backgroundColor : rootBackground.contentStyle.backgroundColor;
|
|
500
|
+
const navigationTheme = (0, import_react9.useMemo)(() => {
|
|
501
|
+
const base = darkRoot ? import_expo_router.DarkTheme : import_expo_router.DefaultTheme;
|
|
502
|
+
return { ...base, colors: { ...base.colors, background: navigationBackground } };
|
|
503
|
+
}, [darkRoot, navigationBackground]);
|
|
504
|
+
const stack = /* @__PURE__ */ (0, import_jsx_runtime9.jsx)(import_expo_router.ThemeProvider, { value: navigationTheme, children: /* @__PURE__ */ (0, import_jsx_runtime9.jsxs)(import_expo_router.Stack, { screenOptions: { ...rootBackground, ...statusBar, ...backdropOptions, ...packOptions }, children: [
|
|
500
505
|
/* @__PURE__ */ (0, import_jsx_runtime9.jsx)(import_expo_router.Stack.Screen, { name: "index" }),
|
|
501
506
|
/* @__PURE__ */ (0, import_jsx_runtime9.jsx)(import_expo_router.Stack.Screen, { name: "play/[game]", options: playOptions })
|
|
502
|
-
] });
|
|
503
|
-
if (!Backdrop
|
|
507
|
+
] }) });
|
|
508
|
+
if (!Backdrop) return stack;
|
|
504
509
|
return /* @__PURE__ */ (0, import_jsx_runtime9.jsxs)(import_react_native2.View, { style: styles.root, children: [
|
|
505
510
|
/* @__PURE__ */ (0, import_jsx_runtime9.jsx)(Backdrop, {}),
|
|
506
|
-
|
|
511
|
+
stack
|
|
507
512
|
] });
|
|
508
513
|
}
|
|
509
514
|
function OrientationBridge({ children }) {
|
|
@@ -1172,6 +1177,13 @@ function HomeMenu() {
|
|
|
1172
1177
|
const { previewColor } = pack;
|
|
1173
1178
|
const p1Drawn = previewColor ? previewColor(p1Color, dark) : p1Color;
|
|
1174
1179
|
const triggerStyle = (0, import_react16.useMemo)(() => pack.Backdrop ? [styles4.pill, { backgroundColor: colors.surface }] : void 0, [pack.Backdrop, colors.surface]);
|
|
1180
|
+
const tabs = pack.homeTabs && pack.homeTabs.length > 1 ? pack.homeTabs : null;
|
|
1181
|
+
const [tabLabel, setTabLabel] = (0, import_react16.useState)(() => tabs?.[0].label ?? null);
|
|
1182
|
+
const tab = tabs ? tabs.find((t) => t.label === tabLabel) ?? tabs[0] : null;
|
|
1183
|
+
const tabButtons = (0, import_react16.useMemo)(() => tabs?.map(({ label }) => ({ value: label, label })) ?? [], [tabs]);
|
|
1184
|
+
const tabBar = tab && /* @__PURE__ */ (0, import_jsx_runtime14.jsx)(import_react_native_paper.SegmentedButtons, { value: tab.label, onValueChange: setTabLabel, buttons: tabButtons, style: [styles4.tabs, pack.Backdrop && { backgroundColor: colors.surface }] });
|
|
1185
|
+
const columnClamp = useClampOnShrink();
|
|
1186
|
+
const gridClamp = useClampOnShrink();
|
|
1175
1187
|
const openGame = (0, import_react16.useCallback)((game) => openFirstTime(game.id, () => import_expo_router6.router.push(game.useLoadout ? loadoutHref(game.id) : playHref(game.id))), [openFirstTime]);
|
|
1176
1188
|
const openRules = (0, import_react16.useCallback)((game) => game.Rules ? setRulesGame(game) : openFor(game.id), [openFor]);
|
|
1177
1189
|
const colorSection = { value: p1Color, onChange: handleP1ColorChange, swatches: import_auto_paper5.defaultColors, caption: selectedProfile ? pack.colorCaption : void 0, previewValue: previewColor ? (hex) => previewColor(hex, dark) : void 0 };
|
|
@@ -1185,16 +1197,20 @@ function HomeMenu() {
|
|
|
1185
1197
|
/* @__PURE__ */ (0, import_jsx_runtime14.jsx)(import_core12.FakeLandscapeView, { style: styles4.rotatable, children: /* @__PURE__ */ (0, import_jsx_runtime14.jsxs)(RulesHost, { enabled: pack.games.some((game) => game.Rules), children: [
|
|
1186
1198
|
isWide ? /* @__PURE__ */ (0, import_jsx_runtime14.jsxs)(import_jsx_runtime14.Fragment, { children: [
|
|
1187
1199
|
pressAway,
|
|
1188
|
-
/* @__PURE__ */ (0, import_jsx_runtime14.
|
|
1200
|
+
/* @__PURE__ */ (0, import_jsx_runtime14.jsxs)(import_auto_paper5.FadingScrollView, { ...tab && gridClamp, style: [styles4.wideRightPane, { left: layout.paddingLeft + layout.leftPaneWidth + HOME_WIDE_PANE_GAP }], contentContainerStyle: { paddingTop: 16 + insets.top, paddingBottom: 16 + insets.bottom, paddingRight: layout.paddingRight }, children: [
|
|
1201
|
+
tabBar && /* @__PURE__ */ (0, import_jsx_runtime14.jsx)(import_react_native6.View, { style: [styles4.wideTabs, { width: layout.rightPaneWidth }], children: tabBar }),
|
|
1202
|
+
/* @__PURE__ */ (0, import_jsx_runtime14.jsx)(import_react_native6.View, { style: [styles4.grid, { width: layout.rightPaneWidth }], children: pack.games.map((game) => /* @__PURE__ */ (0, import_jsx_runtime14.jsx)(GameTile, { game, hidden: isHidden(tab, game), width: layout.tileWidth, previewWidth: layout.previewWidth, previewRevealed: previewsRevealed, dark, p1Color, p2Color, onPress: () => openGame(game), onRules: hasRules(game) ? () => openRules(game) : void 0 }, game.id)) })
|
|
1203
|
+
] }),
|
|
1189
1204
|
/* @__PURE__ */ (0, import_jsx_runtime14.jsxs)(import_react_native6.View, { style: [styles4.wideLeftPane, { left: layout.paddingLeft, width: layout.leftPaneWidth, paddingTop: HOME_TOP_BAND + insets.top, paddingBottom: 16 + insets.bottom }, pickerHost.openId !== null && styles4.pickerOpen], children: [
|
|
1190
1205
|
/* @__PURE__ */ (0, import_jsx_runtime14.jsx)(pack.HeroTitle, { words: pack.wordmark, color: fg, p1Color, p2Color, active: heroActive, fit: layout.heroFit }),
|
|
1191
1206
|
/* @__PURE__ */ (0, import_jsx_runtime14.jsx)(import_react_native6.View, { style: styles4.widePicker, children: picker })
|
|
1192
1207
|
] })
|
|
1193
|
-
] }) : /* @__PURE__ */ (0, import_jsx_runtime14.jsxs)(import_auto_paper5.FadingScrollView, { style: styles4.scroll, contentContainerStyle: [styles4.content, { paddingTop: 56 + insets.top, paddingBottom: 24 + insets.bottom, paddingLeft: 16 + insets.left, paddingRight: 16 + insets.right }], children: [
|
|
1208
|
+
] }) : /* @__PURE__ */ (0, import_jsx_runtime14.jsxs)(import_auto_paper5.FadingScrollView, { ...tab && columnClamp, style: styles4.scroll, contentContainerStyle: [styles4.content, { paddingTop: 56 + insets.top, paddingBottom: 24 + insets.bottom, paddingLeft: 16 + insets.left, paddingRight: 16 + insets.right }], children: [
|
|
1194
1209
|
pressAway,
|
|
1195
1210
|
/* @__PURE__ */ (0, import_jsx_runtime14.jsx)(pack.HeroTitle, { words: pack.wordmark, color: fg, p1Color, p2Color, active: heroActive }),
|
|
1196
1211
|
/* @__PURE__ */ (0, import_jsx_runtime14.jsx)(import_react_native6.View, { style: [styles4.picker, pickerHost.openId !== null && styles4.pickerOpen], children: picker }),
|
|
1197
|
-
/* @__PURE__ */ (0, import_jsx_runtime14.jsx)(import_react_native6.View, { style: styles4.
|
|
1212
|
+
tabBar && /* @__PURE__ */ (0, import_jsx_runtime14.jsx)(import_react_native6.View, { style: styles4.listWidth, children: tabBar }),
|
|
1213
|
+
/* @__PURE__ */ (0, import_jsx_runtime14.jsx)(import_react_native6.View, { style: styles4.list, children: pack.games.map((game) => /* @__PURE__ */ (0, import_jsx_runtime14.jsx)(GameRow, { game, hidden: isHidden(tab, game), dark, p1Color, p2Color, previewRevealed: previewsRevealed, onPress: () => openGame(game), onRules: hasRules(game) ? () => openRules(game) : void 0 }, game.id)) })
|
|
1198
1214
|
] }),
|
|
1199
1215
|
/* @__PURE__ */ (0, import_jsx_runtime14.jsx)(import_feedback_press3.IconButton, { icon: "trophy", iconColor: fg, size: 24, style: [styles4.corner, { top: 8 + insets.top, left: 8 + insets.left }], onPress: () => import_expo_router6.router.push(STATS_HREF), accessibilityLabel: "Stats & Achievements" }),
|
|
1200
1216
|
/* @__PURE__ */ (0, import_jsx_runtime14.jsx)(import_feedback_press3.IconButton, { icon: "cog", iconColor: fg, size: 24, style: [styles4.corner, { top: 8 + insets.top, right: 8 + insets.right }], onPress: () => setSettingsOpen(true), accessibilityLabel: "Settings" }),
|
|
@@ -1205,19 +1221,47 @@ function HomeMenu() {
|
|
|
1205
1221
|
}
|
|
1206
1222
|
var SEAT_ONE_PICKER = "home-p1";
|
|
1207
1223
|
var noPickerAlign = () => void 0;
|
|
1224
|
+
function isHidden(tab, game) {
|
|
1225
|
+
return !!tab && !tab.includes(game);
|
|
1226
|
+
}
|
|
1227
|
+
function useClampOnShrink() {
|
|
1228
|
+
const view = (0, import_react16.useRef)(null);
|
|
1229
|
+
const sizes = (0, import_react16.useRef)({ viewport: 0, offset: 0 });
|
|
1230
|
+
return (0, import_react16.useMemo)(
|
|
1231
|
+
() => ({
|
|
1232
|
+
ref: (instance) => {
|
|
1233
|
+
view.current = instance;
|
|
1234
|
+
sizes.current.offset = 0;
|
|
1235
|
+
},
|
|
1236
|
+
onLayout: (e) => {
|
|
1237
|
+
sizes.current.viewport = e.nativeEvent.layout.height;
|
|
1238
|
+
},
|
|
1239
|
+
onScroll: (e) => {
|
|
1240
|
+
sizes.current.offset = e.nativeEvent.contentOffset.y;
|
|
1241
|
+
},
|
|
1242
|
+
onContentSizeChange: (_width, height) => {
|
|
1243
|
+
const end = Math.max(0, height - sizes.current.viewport);
|
|
1244
|
+
if (sizes.current.offset <= end) return;
|
|
1245
|
+
sizes.current.offset = end;
|
|
1246
|
+
view.current?.scrollTo({ y: end, animated: false });
|
|
1247
|
+
}
|
|
1248
|
+
}),
|
|
1249
|
+
[]
|
|
1250
|
+
);
|
|
1251
|
+
}
|
|
1208
1252
|
function hasRules(game) {
|
|
1209
1253
|
return !!(game.Rules || game.guide);
|
|
1210
1254
|
}
|
|
1211
1255
|
function RulesHost({ enabled, children }) {
|
|
1212
1256
|
return enabled ? /* @__PURE__ */ (0, import_jsx_runtime14.jsx)(import_react_native_paper.Portal.Host, { children }) : /* @__PURE__ */ (0, import_jsx_runtime14.jsx)(import_jsx_runtime14.Fragment, { children });
|
|
1213
1257
|
}
|
|
1214
|
-
function GameRow({ game, dark, p1Color, p2Color, previewRevealed, onPress, onRules }) {
|
|
1258
|
+
function GameRow({ game, hidden, dark, p1Color, p2Color, previewRevealed, onPress, onRules }) {
|
|
1215
1259
|
const well = (0, import_chrome5.useWellColors)();
|
|
1216
1260
|
const GameIcon = game.icon;
|
|
1217
1261
|
return (
|
|
1218
1262
|
// The rules button sits beside the row's own button, laid over its end, not inside it: a button
|
|
1219
1263
|
// inside a button is invalid on the web.
|
|
1220
|
-
/* @__PURE__ */ (0, import_jsx_runtime14.jsxs)(import_react_native6.View, { children: [
|
|
1264
|
+
/* @__PURE__ */ (0, import_jsx_runtime14.jsxs)(import_react_native6.View, { style: hidden ? styles4.hidden : void 0, children: [
|
|
1221
1265
|
/* @__PURE__ */ (0, import_jsx_runtime14.jsxs)(import_feedback_press3.Pressable, { onPress, style: [styles4.row, { backgroundColor: well.bg }], accessibilityRole: "button", accessibilityLabel: game.title, children: [
|
|
1222
1266
|
game.Preview ? /* @__PURE__ */ (0, import_jsx_runtime14.jsx)(PreviewBox, { game, width: ROW_PREVIEW_WIDTH, revealed: previewRevealed, dark, p1Color, p2Color }) : /* @__PURE__ */ (0, import_jsx_runtime14.jsx)(import_react_native6.View, { style: [styles4.rowIcon, { backgroundColor: dark ? ICON_DARK_BG : ICON_LIGHT_BG }], children: GameIcon && /* @__PURE__ */ (0, import_jsx_runtime14.jsx)(GameIcon, { size: ROW_ICON_SIZE, p1Color, p2Color, dark }) }),
|
|
1223
1267
|
/* @__PURE__ */ (0, import_jsx_runtime14.jsxs)(import_react_native6.View, { style: styles4.rowText, children: [
|
|
@@ -1229,9 +1273,9 @@ function GameRow({ game, dark, p1Color, p2Color, previewRevealed, onPress, onRul
|
|
|
1229
1273
|
] })
|
|
1230
1274
|
);
|
|
1231
1275
|
}
|
|
1232
|
-
function GameTile({ game, width, previewWidth, dark, p1Color, p2Color, previewRevealed, onPress, onRules }) {
|
|
1276
|
+
function GameTile({ game, hidden, width, previewWidth, dark, p1Color, p2Color, previewRevealed, onPress, onRules }) {
|
|
1233
1277
|
const well = (0, import_chrome5.useWellColors)();
|
|
1234
|
-
return /* @__PURE__ */ (0, import_jsx_runtime14.jsxs)(import_react_native6.View, { style: { width }, children: [
|
|
1278
|
+
return /* @__PURE__ */ (0, import_jsx_runtime14.jsxs)(import_react_native6.View, { style: [{ width }, hidden && styles4.hidden], children: [
|
|
1235
1279
|
/* @__PURE__ */ (0, import_jsx_runtime14.jsxs)(import_feedback_press3.Pressable, { onPress, style: [styles4.tile, { backgroundColor: well.bg }], accessibilityRole: "button", accessibilityLabel: game.title, children: [
|
|
1236
1280
|
/* @__PURE__ */ (0, import_jsx_runtime14.jsx)(PreviewBox, { game, width: previewWidth, revealed: previewRevealed, dark, p1Color, p2Color }),
|
|
1237
1281
|
/* @__PURE__ */ (0, import_jsx_runtime14.jsx)(import_react_native_paper.Text, { variant: "titleMedium", style: [styles4.rowTitle, { color: well.fg }], numberOfLines: 1, children: game.title }),
|
|
@@ -1267,12 +1311,21 @@ var styles4 = import_react_native6.StyleSheet.create({
|
|
|
1267
1311
|
flexWrap: "wrap",
|
|
1268
1312
|
gap: HOME_GRID_GAP
|
|
1269
1313
|
},
|
|
1314
|
+
// Out of the layout (a grid closes up around it) and hidden from screen readers.
|
|
1315
|
+
hidden: {
|
|
1316
|
+
display: "none"
|
|
1317
|
+
},
|
|
1270
1318
|
// Solitaire's list: one column, never wider than a phone's, centred on a wide screen.
|
|
1271
1319
|
list: {
|
|
1272
1320
|
gap: 14,
|
|
1273
1321
|
maxWidth: 480,
|
|
1274
1322
|
width: "100%"
|
|
1275
1323
|
},
|
|
1324
|
+
// The list's width, for the tabs over it.
|
|
1325
|
+
listWidth: {
|
|
1326
|
+
maxWidth: 480,
|
|
1327
|
+
width: "100%"
|
|
1328
|
+
},
|
|
1276
1329
|
picker: {
|
|
1277
1330
|
alignItems: "center"
|
|
1278
1331
|
},
|
|
@@ -1335,6 +1388,10 @@ var styles4 = import_react_native6.StyleSheet.create({
|
|
|
1335
1388
|
scroll: {
|
|
1336
1389
|
flex: 1
|
|
1337
1390
|
},
|
|
1391
|
+
// SegmentedButtons' own rounding, so a surface behind it is the same pill (the stats screen's tabs).
|
|
1392
|
+
tabs: {
|
|
1393
|
+
borderRadius: 20
|
|
1394
|
+
},
|
|
1338
1395
|
// The same well as a row, turned on its side.
|
|
1339
1396
|
tile: {
|
|
1340
1397
|
borderRadius: 12,
|
|
@@ -1373,6 +1430,10 @@ var styles4 = import_react_native6.StyleSheet.create({
|
|
|
1373
1430
|
position: "absolute",
|
|
1374
1431
|
right: 0,
|
|
1375
1432
|
top: 0
|
|
1433
|
+
},
|
|
1434
|
+
// The tabs over the grid, a little further from it than the tiles are from each other.
|
|
1435
|
+
wideTabs: {
|
|
1436
|
+
marginBottom: 16
|
|
1376
1437
|
}
|
|
1377
1438
|
});
|
|
1378
1439
|
|
package/dist/index.mjs
CHANGED
|
@@ -29,7 +29,7 @@ import { OrientationProvider, useRotationAwareStatusBar, useThemedRootBackground
|
|
|
29
29
|
import { RotatedFrame } from "@tastic/hud/chrome";
|
|
30
30
|
import { ProfileColorProvider } from "@tastic/profile";
|
|
31
31
|
import * as Haptics from "expo-haptics";
|
|
32
|
-
import { DarkTheme, Stack, ThemeProvider as NavigationThemeProvider } from "expo-router";
|
|
32
|
+
import { DarkTheme, DefaultTheme, Stack, ThemeProvider as NavigationThemeProvider } from "expo-router";
|
|
33
33
|
import { DeviceMotion } from "expo-sensors";
|
|
34
34
|
import { useCallback as useCallback6, useMemo as useMemo3 } from "react";
|
|
35
35
|
import { Platform as Platform2, StyleSheet, View } from "react-native";
|
|
@@ -410,8 +410,9 @@ function AppStack() {
|
|
|
410
410
|
const pack = usePack();
|
|
411
411
|
const { Backdrop } = pack;
|
|
412
412
|
const { dark } = useAutoPaperTheme();
|
|
413
|
-
const
|
|
414
|
-
const
|
|
413
|
+
const darkRoot = Backdrop ? true : dark;
|
|
414
|
+
const rootBackground = useThemedRootBackground(darkRoot, false);
|
|
415
|
+
const statusBar = useRotationAwareStatusBar({ style: darkRoot ? "light" : "dark" });
|
|
415
416
|
const packOptions = (pack.useScreenOptions ?? noScreenOptions)();
|
|
416
417
|
const backdropOptions = Backdrop ? { contentStyle: TRANSPARENT_CONTENT, animation: Platform2.select({ android: "fade" }) } : void 0;
|
|
417
418
|
const playOptions = useCallback6(
|
|
@@ -421,15 +422,19 @@ function AppStack() {
|
|
|
421
422
|
},
|
|
422
423
|
[pack.games]
|
|
423
424
|
);
|
|
424
|
-
const
|
|
425
|
-
const
|
|
425
|
+
const navigationBackground = Backdrop ? TRANSPARENT_CONTENT.backgroundColor : rootBackground.contentStyle.backgroundColor;
|
|
426
|
+
const navigationTheme = useMemo3(() => {
|
|
427
|
+
const base = darkRoot ? DarkTheme : DefaultTheme;
|
|
428
|
+
return { ...base, colors: { ...base.colors, background: navigationBackground } };
|
|
429
|
+
}, [darkRoot, navigationBackground]);
|
|
430
|
+
const stack = /* @__PURE__ */ jsx9(NavigationThemeProvider, { value: navigationTheme, children: /* @__PURE__ */ jsxs2(Stack, { screenOptions: { ...rootBackground, ...statusBar, ...backdropOptions, ...packOptions }, children: [
|
|
426
431
|
/* @__PURE__ */ jsx9(Stack.Screen, { name: "index" }),
|
|
427
432
|
/* @__PURE__ */ jsx9(Stack.Screen, { name: "play/[game]", options: playOptions })
|
|
428
|
-
] });
|
|
429
|
-
if (!Backdrop
|
|
433
|
+
] }) });
|
|
434
|
+
if (!Backdrop) return stack;
|
|
430
435
|
return /* @__PURE__ */ jsxs2(View, { style: styles.root, children: [
|
|
431
436
|
/* @__PURE__ */ jsx9(Backdrop, {}),
|
|
432
|
-
|
|
437
|
+
stack
|
|
433
438
|
] });
|
|
434
439
|
}
|
|
435
440
|
function OrientationBridge({ children }) {
|
|
@@ -963,9 +968,9 @@ import { FakeLandscapeView as FakeLandscapeView2, useRotation as useRotation4 }
|
|
|
963
968
|
import { PressAwayOverlay as PressAwayOverlay2, usePopoverHost as usePopoverHost2, useWellColors, WELL_SHADOW } from "@tastic/hud/chrome";
|
|
964
969
|
import { getProfilePickerContentSize, ProfilePicker } from "@tastic/profile";
|
|
965
970
|
import { Redirect as Redirect2, router as router3, useIsFocused } from "expo-router";
|
|
966
|
-
import { useCallback as useCallback10, useEffect as useEffect6, useMemo as useMemo6, useState as useState6 } from "react";
|
|
971
|
+
import { useCallback as useCallback10, useEffect as useEffect6, useMemo as useMemo6, useRef as useRef5, useState as useState6 } from "react";
|
|
967
972
|
import { AppState as AppState2, StyleSheet as StyleSheet4, View as View4 } from "react-native";
|
|
968
|
-
import { Icon, Portal, Text } from "react-native-paper";
|
|
973
|
+
import { Icon, Portal, SegmentedButtons, Text } from "react-native-paper";
|
|
969
974
|
|
|
970
975
|
// src/hooks/useFrozenWhileHidden.ts
|
|
971
976
|
import { useState as useState5 } from "react";
|
|
@@ -1098,6 +1103,13 @@ function HomeMenu() {
|
|
|
1098
1103
|
const { previewColor } = pack;
|
|
1099
1104
|
const p1Drawn = previewColor ? previewColor(p1Color, dark) : p1Color;
|
|
1100
1105
|
const triggerStyle = useMemo6(() => pack.Backdrop ? [styles4.pill, { backgroundColor: colors.surface }] : void 0, [pack.Backdrop, colors.surface]);
|
|
1106
|
+
const tabs = pack.homeTabs && pack.homeTabs.length > 1 ? pack.homeTabs : null;
|
|
1107
|
+
const [tabLabel, setTabLabel] = useState6(() => tabs?.[0].label ?? null);
|
|
1108
|
+
const tab = tabs ? tabs.find((t) => t.label === tabLabel) ?? tabs[0] : null;
|
|
1109
|
+
const tabButtons = useMemo6(() => tabs?.map(({ label }) => ({ value: label, label })) ?? [], [tabs]);
|
|
1110
|
+
const tabBar = tab && /* @__PURE__ */ jsx14(SegmentedButtons, { value: tab.label, onValueChange: setTabLabel, buttons: tabButtons, style: [styles4.tabs, pack.Backdrop && { backgroundColor: colors.surface }] });
|
|
1111
|
+
const columnClamp = useClampOnShrink();
|
|
1112
|
+
const gridClamp = useClampOnShrink();
|
|
1101
1113
|
const openGame = useCallback10((game) => openFirstTime(game.id, () => router3.push(game.useLoadout ? loadoutHref(game.id) : playHref(game.id))), [openFirstTime]);
|
|
1102
1114
|
const openRules = useCallback10((game) => game.Rules ? setRulesGame(game) : openFor(game.id), [openFor]);
|
|
1103
1115
|
const colorSection = { value: p1Color, onChange: handleP1ColorChange, swatches: defaultColors2, caption: selectedProfile ? pack.colorCaption : void 0, previewValue: previewColor ? (hex) => previewColor(hex, dark) : void 0 };
|
|
@@ -1111,16 +1123,20 @@ function HomeMenu() {
|
|
|
1111
1123
|
/* @__PURE__ */ jsx14(FakeLandscapeView2, { style: styles4.rotatable, children: /* @__PURE__ */ jsxs6(RulesHost, { enabled: pack.games.some((game) => game.Rules), children: [
|
|
1112
1124
|
isWide ? /* @__PURE__ */ jsxs6(Fragment4, { children: [
|
|
1113
1125
|
pressAway,
|
|
1114
|
-
/* @__PURE__ */
|
|
1126
|
+
/* @__PURE__ */ jsxs6(FadingScrollView, { ...tab && gridClamp, style: [styles4.wideRightPane, { left: layout.paddingLeft + layout.leftPaneWidth + HOME_WIDE_PANE_GAP }], contentContainerStyle: { paddingTop: 16 + insets.top, paddingBottom: 16 + insets.bottom, paddingRight: layout.paddingRight }, children: [
|
|
1127
|
+
tabBar && /* @__PURE__ */ jsx14(View4, { style: [styles4.wideTabs, { width: layout.rightPaneWidth }], children: tabBar }),
|
|
1128
|
+
/* @__PURE__ */ jsx14(View4, { style: [styles4.grid, { width: layout.rightPaneWidth }], children: pack.games.map((game) => /* @__PURE__ */ jsx14(GameTile, { game, hidden: isHidden(tab, game), width: layout.tileWidth, previewWidth: layout.previewWidth, previewRevealed: previewsRevealed, dark, p1Color, p2Color, onPress: () => openGame(game), onRules: hasRules(game) ? () => openRules(game) : void 0 }, game.id)) })
|
|
1129
|
+
] }),
|
|
1115
1130
|
/* @__PURE__ */ jsxs6(View4, { style: [styles4.wideLeftPane, { left: layout.paddingLeft, width: layout.leftPaneWidth, paddingTop: HOME_TOP_BAND + insets.top, paddingBottom: 16 + insets.bottom }, pickerHost.openId !== null && styles4.pickerOpen], children: [
|
|
1116
1131
|
/* @__PURE__ */ jsx14(pack.HeroTitle, { words: pack.wordmark, color: fg, p1Color, p2Color, active: heroActive, fit: layout.heroFit }),
|
|
1117
1132
|
/* @__PURE__ */ jsx14(View4, { style: styles4.widePicker, children: picker })
|
|
1118
1133
|
] })
|
|
1119
|
-
] }) : /* @__PURE__ */ jsxs6(FadingScrollView, { style: styles4.scroll, contentContainerStyle: [styles4.content, { paddingTop: 56 + insets.top, paddingBottom: 24 + insets.bottom, paddingLeft: 16 + insets.left, paddingRight: 16 + insets.right }], children: [
|
|
1134
|
+
] }) : /* @__PURE__ */ jsxs6(FadingScrollView, { ...tab && columnClamp, style: styles4.scroll, contentContainerStyle: [styles4.content, { paddingTop: 56 + insets.top, paddingBottom: 24 + insets.bottom, paddingLeft: 16 + insets.left, paddingRight: 16 + insets.right }], children: [
|
|
1120
1135
|
pressAway,
|
|
1121
1136
|
/* @__PURE__ */ jsx14(pack.HeroTitle, { words: pack.wordmark, color: fg, p1Color, p2Color, active: heroActive }),
|
|
1122
1137
|
/* @__PURE__ */ jsx14(View4, { style: [styles4.picker, pickerHost.openId !== null && styles4.pickerOpen], children: picker }),
|
|
1123
|
-
/* @__PURE__ */ jsx14(View4, { style: styles4.
|
|
1138
|
+
tabBar && /* @__PURE__ */ jsx14(View4, { style: styles4.listWidth, children: tabBar }),
|
|
1139
|
+
/* @__PURE__ */ jsx14(View4, { style: styles4.list, children: pack.games.map((game) => /* @__PURE__ */ jsx14(GameRow, { game, hidden: isHidden(tab, game), dark, p1Color, p2Color, previewRevealed: previewsRevealed, onPress: () => openGame(game), onRules: hasRules(game) ? () => openRules(game) : void 0 }, game.id)) })
|
|
1124
1140
|
] }),
|
|
1125
1141
|
/* @__PURE__ */ jsx14(IconButton, { icon: "trophy", iconColor: fg, size: 24, style: [styles4.corner, { top: 8 + insets.top, left: 8 + insets.left }], onPress: () => router3.push(STATS_HREF), accessibilityLabel: "Stats & Achievements" }),
|
|
1126
1142
|
/* @__PURE__ */ jsx14(IconButton, { icon: "cog", iconColor: fg, size: 24, style: [styles4.corner, { top: 8 + insets.top, right: 8 + insets.right }], onPress: () => setSettingsOpen(true), accessibilityLabel: "Settings" }),
|
|
@@ -1131,19 +1147,47 @@ function HomeMenu() {
|
|
|
1131
1147
|
}
|
|
1132
1148
|
var SEAT_ONE_PICKER = "home-p1";
|
|
1133
1149
|
var noPickerAlign = () => void 0;
|
|
1150
|
+
function isHidden(tab, game) {
|
|
1151
|
+
return !!tab && !tab.includes(game);
|
|
1152
|
+
}
|
|
1153
|
+
function useClampOnShrink() {
|
|
1154
|
+
const view = useRef5(null);
|
|
1155
|
+
const sizes = useRef5({ viewport: 0, offset: 0 });
|
|
1156
|
+
return useMemo6(
|
|
1157
|
+
() => ({
|
|
1158
|
+
ref: (instance) => {
|
|
1159
|
+
view.current = instance;
|
|
1160
|
+
sizes.current.offset = 0;
|
|
1161
|
+
},
|
|
1162
|
+
onLayout: (e) => {
|
|
1163
|
+
sizes.current.viewport = e.nativeEvent.layout.height;
|
|
1164
|
+
},
|
|
1165
|
+
onScroll: (e) => {
|
|
1166
|
+
sizes.current.offset = e.nativeEvent.contentOffset.y;
|
|
1167
|
+
},
|
|
1168
|
+
onContentSizeChange: (_width, height) => {
|
|
1169
|
+
const end = Math.max(0, height - sizes.current.viewport);
|
|
1170
|
+
if (sizes.current.offset <= end) return;
|
|
1171
|
+
sizes.current.offset = end;
|
|
1172
|
+
view.current?.scrollTo({ y: end, animated: false });
|
|
1173
|
+
}
|
|
1174
|
+
}),
|
|
1175
|
+
[]
|
|
1176
|
+
);
|
|
1177
|
+
}
|
|
1134
1178
|
function hasRules(game) {
|
|
1135
1179
|
return !!(game.Rules || game.guide);
|
|
1136
1180
|
}
|
|
1137
1181
|
function RulesHost({ enabled, children }) {
|
|
1138
1182
|
return enabled ? /* @__PURE__ */ jsx14(Portal.Host, { children }) : /* @__PURE__ */ jsx14(Fragment4, { children });
|
|
1139
1183
|
}
|
|
1140
|
-
function GameRow({ game, dark, p1Color, p2Color, previewRevealed, onPress, onRules }) {
|
|
1184
|
+
function GameRow({ game, hidden, dark, p1Color, p2Color, previewRevealed, onPress, onRules }) {
|
|
1141
1185
|
const well = useWellColors();
|
|
1142
1186
|
const GameIcon = game.icon;
|
|
1143
1187
|
return (
|
|
1144
1188
|
// The rules button sits beside the row's own button, laid over its end, not inside it: a button
|
|
1145
1189
|
// inside a button is invalid on the web.
|
|
1146
|
-
/* @__PURE__ */ jsxs6(View4, { children: [
|
|
1190
|
+
/* @__PURE__ */ jsxs6(View4, { style: hidden ? styles4.hidden : void 0, children: [
|
|
1147
1191
|
/* @__PURE__ */ jsxs6(Pressable, { onPress, style: [styles4.row, { backgroundColor: well.bg }], accessibilityRole: "button", accessibilityLabel: game.title, children: [
|
|
1148
1192
|
game.Preview ? /* @__PURE__ */ jsx14(PreviewBox, { game, width: ROW_PREVIEW_WIDTH, revealed: previewRevealed, dark, p1Color, p2Color }) : /* @__PURE__ */ jsx14(View4, { style: [styles4.rowIcon, { backgroundColor: dark ? ICON_DARK_BG : ICON_LIGHT_BG }], children: GameIcon && /* @__PURE__ */ jsx14(GameIcon, { size: ROW_ICON_SIZE, p1Color, p2Color, dark }) }),
|
|
1149
1193
|
/* @__PURE__ */ jsxs6(View4, { style: styles4.rowText, children: [
|
|
@@ -1155,9 +1199,9 @@ function GameRow({ game, dark, p1Color, p2Color, previewRevealed, onPress, onRul
|
|
|
1155
1199
|
] })
|
|
1156
1200
|
);
|
|
1157
1201
|
}
|
|
1158
|
-
function GameTile({ game, width, previewWidth, dark, p1Color, p2Color, previewRevealed, onPress, onRules }) {
|
|
1202
|
+
function GameTile({ game, hidden, width, previewWidth, dark, p1Color, p2Color, previewRevealed, onPress, onRules }) {
|
|
1159
1203
|
const well = useWellColors();
|
|
1160
|
-
return /* @__PURE__ */ jsxs6(View4, { style: { width }, children: [
|
|
1204
|
+
return /* @__PURE__ */ jsxs6(View4, { style: [{ width }, hidden && styles4.hidden], children: [
|
|
1161
1205
|
/* @__PURE__ */ jsxs6(Pressable, { onPress, style: [styles4.tile, { backgroundColor: well.bg }], accessibilityRole: "button", accessibilityLabel: game.title, children: [
|
|
1162
1206
|
/* @__PURE__ */ jsx14(PreviewBox, { game, width: previewWidth, revealed: previewRevealed, dark, p1Color, p2Color }),
|
|
1163
1207
|
/* @__PURE__ */ jsx14(Text, { variant: "titleMedium", style: [styles4.rowTitle, { color: well.fg }], numberOfLines: 1, children: game.title }),
|
|
@@ -1193,12 +1237,21 @@ var styles4 = StyleSheet4.create({
|
|
|
1193
1237
|
flexWrap: "wrap",
|
|
1194
1238
|
gap: HOME_GRID_GAP
|
|
1195
1239
|
},
|
|
1240
|
+
// Out of the layout (a grid closes up around it) and hidden from screen readers.
|
|
1241
|
+
hidden: {
|
|
1242
|
+
display: "none"
|
|
1243
|
+
},
|
|
1196
1244
|
// Solitaire's list: one column, never wider than a phone's, centred on a wide screen.
|
|
1197
1245
|
list: {
|
|
1198
1246
|
gap: 14,
|
|
1199
1247
|
maxWidth: 480,
|
|
1200
1248
|
width: "100%"
|
|
1201
1249
|
},
|
|
1250
|
+
// The list's width, for the tabs over it.
|
|
1251
|
+
listWidth: {
|
|
1252
|
+
maxWidth: 480,
|
|
1253
|
+
width: "100%"
|
|
1254
|
+
},
|
|
1202
1255
|
picker: {
|
|
1203
1256
|
alignItems: "center"
|
|
1204
1257
|
},
|
|
@@ -1261,6 +1314,10 @@ var styles4 = StyleSheet4.create({
|
|
|
1261
1314
|
scroll: {
|
|
1262
1315
|
flex: 1
|
|
1263
1316
|
},
|
|
1317
|
+
// SegmentedButtons' own rounding, so a surface behind it is the same pill (the stats screen's tabs).
|
|
1318
|
+
tabs: {
|
|
1319
|
+
borderRadius: 20
|
|
1320
|
+
},
|
|
1264
1321
|
// The same well as a row, turned on its side.
|
|
1265
1322
|
tile: {
|
|
1266
1323
|
borderRadius: 12,
|
|
@@ -1299,6 +1356,10 @@ var styles4 = StyleSheet4.create({
|
|
|
1299
1356
|
position: "absolute",
|
|
1300
1357
|
right: 0,
|
|
1301
1358
|
top: 0
|
|
1359
|
+
},
|
|
1360
|
+
// The tabs over the grid, a little further from it than the tiles are from each other.
|
|
1361
|
+
wideTabs: {
|
|
1362
|
+
marginBottom: 16
|
|
1302
1363
|
}
|
|
1303
1364
|
});
|
|
1304
1365
|
|
|
@@ -1324,7 +1385,7 @@ import { BaseStatsScreen, PressAwayOverlay as PressAwayOverlay3, RotatedFrame as
|
|
|
1324
1385
|
import { getOptionPickerContentSize as getOptionPickerContentSize2, OptionPicker as OptionPicker2 } from "@tastic/profile";
|
|
1325
1386
|
import { useMemo as useMemo7, useState as useState7 } from "react";
|
|
1326
1387
|
import { StyleSheet as StyleSheet5, View as View5 } from "react-native";
|
|
1327
|
-
import { SegmentedButtons, Text as Text2 } from "react-native-paper";
|
|
1388
|
+
import { SegmentedButtons as SegmentedButtons2, Text as Text2 } from "react-native-paper";
|
|
1328
1389
|
import { useSafeAreaInsets as useSafeAreaInsets3 } from "react-native-safe-area-context";
|
|
1329
1390
|
import { useDispatch as useDispatch10 } from "react-redux";
|
|
1330
1391
|
import { jsx as jsx16, jsxs as jsxs7 } from "react/jsx-runtime";
|
|
@@ -1382,7 +1443,7 @@ function StatsContent({ pickerHost }) {
|
|
|
1382
1443
|
const PackTabContent = packTab?.Content;
|
|
1383
1444
|
return /* @__PURE__ */ jsxs7(BaseStatsScreen, { title, onBack: safeBack, insets, onReset: reset.onReset, resetLabel: reset.label, resetConfirmTitle: reset.title, resetConfirmBody: reset.body, fg: pack.foreground, bg: pack.Backdrop ? "transparent" : void 0, rotation: 0, children: [
|
|
1384
1445
|
/* @__PURE__ */ jsx16(PressAwayOverlay3, { active: pickerHost.openId !== null, onPress: pickerHost.close, style: styles5.pressAway }),
|
|
1385
|
-
/* @__PURE__ */ jsx16(
|
|
1446
|
+
/* @__PURE__ */ jsx16(SegmentedButtons2, { value: tab, onValueChange: setTab, buttons: tabButtons, style: [styles5.tabs, surface] }),
|
|
1386
1447
|
/* @__PURE__ */ jsxs7(View5, { style: [styles5.filters, pickerHost.openId !== null && styles5.filtersOpen], onStartShouldSetResponder: () => pickerHost.openId !== null, children: [
|
|
1387
1448
|
profiles.length > 0 && /* @__PURE__ */ jsx16(OptionPicker2, { id: PLAYER_PICKER_ID, host: pickerHost, options: playerOptions, value: profileId ?? EVERYONE, onChange: (value) => setSelectedProfileId(value === EVERYONE ? null : value), color: colors.primary, dark, icon: profileId ? "account" : "account-group", alignOverride: playerAlign, triggerStyle }),
|
|
1388
1449
|
showGamePicker && /* @__PURE__ */ jsx16(OptionPicker2, { id: GAME_PICKER_ID, host: pickerHost, options: gameOptions, value: gameId, onChange: setGameId, color: colors.primary, dark, icon: "view-grid-outline", alignOverride: gameAlign, triggerStyle })
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tastic/shell",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.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",
|
package/src/RootLayout.tsx
CHANGED
|
@@ -5,7 +5,7 @@ import { OrientationProvider, useRotationAwareStatusBar, useThemedRootBackground
|
|
|
5
5
|
import { RotatedFrame } from '@tastic/hud/chrome'
|
|
6
6
|
import { ProfileColorProvider } from '@tastic/profile'
|
|
7
7
|
import * as Haptics from 'expo-haptics'
|
|
8
|
-
import { DarkTheme, Stack, ThemeProvider as NavigationThemeProvider } from 'expo-router'
|
|
8
|
+
import { DarkTheme, DefaultTheme, Stack, ThemeProvider as NavigationThemeProvider } from 'expo-router'
|
|
9
9
|
import { DeviceMotion } from 'expo-sensors'
|
|
10
10
|
import { ReactNode, useCallback, useMemo } from 'react'
|
|
11
11
|
import { Platform, StyleSheet, View } from 'react-native'
|
|
@@ -50,13 +50,16 @@ import type { PackDefinition, PackGameDefinition } from './types'
|
|
|
50
50
|
const PLAY_OPTIONS = { statusBarHidden: true }
|
|
51
51
|
const KEEP_STATUS_BAR_OPTIONS = {}
|
|
52
52
|
|
|
53
|
-
//
|
|
54
|
-
//
|
|
55
|
-
//
|
|
56
|
-
//
|
|
57
|
-
//
|
|
58
|
-
//
|
|
59
|
-
//
|
|
53
|
+
// The navigation theme is the root background too. React Navigation paints its theme's
|
|
54
|
+
// `colors.background` on the layers around every screen (the ScreenStack's container on native, each
|
|
55
|
+
// screen's wrapper on the web), and expo-router hands its container no theme, so that was always
|
|
56
|
+
// DefaultTheme's light grey: on iOS it showed in a sliding screen's rounded corners during every push and
|
|
57
|
+
// pop, whatever the app's theme. So the stack always gets a theme whose background is the root
|
|
58
|
+
// background's own color, and a pack's Backdrop (PackDefinition.Backdrop), drawn once behind the whole
|
|
59
|
+
// stack, gets a transparent one so it shows through both layers (contentStyle alone left an opaque light
|
|
60
|
+
// grey slab over Solitaire's felt on the web). Nothing else reads the navigation theme (headers are
|
|
61
|
+
// hidden; Paper has its own). Built as the stack renders, never at import, where a pack's tests may stand
|
|
62
|
+
// in for expo-router without its themes.
|
|
60
63
|
const TRANSPARENT_CONTENT = { backgroundColor: 'transparent' }
|
|
61
64
|
|
|
62
65
|
// The pack's own screen options (PackDefinition.useScreenOptions), as a hook every pack calls: an
|
|
@@ -67,10 +70,13 @@ function AppStack() {
|
|
|
67
70
|
const pack = usePack()
|
|
68
71
|
const { Backdrop } = pack
|
|
69
72
|
const { dark } = useAutoPaperTheme()
|
|
70
|
-
// Over a backdrop the root
|
|
71
|
-
//
|
|
72
|
-
const
|
|
73
|
-
const
|
|
73
|
+
// Over a backdrop the root stays dark whatever the mode: a backdrop is a dark table (the felt), and a
|
|
74
|
+
// light root window would flash white around it during a push or pop.
|
|
75
|
+
const darkRoot = Backdrop ? true : dark
|
|
76
|
+
const rootBackground = useThemedRootBackground(darkRoot, false)
|
|
77
|
+
// The status bar's text follows the app's theme, not the phone's appearance (react-native-screens'
|
|
78
|
+
// default `auto`), which drew dark text on a dark theme's black Home with the phone set to light.
|
|
79
|
+
const statusBar = useRotationAwareStatusBar({ style: darkRoot ? 'light' : 'dark' })
|
|
74
80
|
const packOptions = (pack.useScreenOptions ?? noScreenOptions)()
|
|
75
81
|
// Over a backdrop every screen is transparent, and Android crossfades: its default push and pop hold
|
|
76
82
|
// the outgoing screen opaque for ~450ms while the new one slides over it, which would show Home
|
|
@@ -83,18 +89,24 @@ function AppStack() {
|
|
|
83
89
|
},
|
|
84
90
|
[pack.games]
|
|
85
91
|
)
|
|
86
|
-
const
|
|
92
|
+
const navigationBackground = Backdrop ? TRANSPARENT_CONTENT.backgroundColor : rootBackground.contentStyle.backgroundColor
|
|
93
|
+
const navigationTheme = useMemo(() => {
|
|
94
|
+
const base = darkRoot ? DarkTheme : DefaultTheme
|
|
95
|
+
return { ...base, colors: { ...base.colors, background: navigationBackground } }
|
|
96
|
+
}, [darkRoot, navigationBackground])
|
|
87
97
|
const stack = (
|
|
88
|
-
<
|
|
89
|
-
<Stack
|
|
90
|
-
|
|
91
|
-
|
|
98
|
+
<NavigationThemeProvider value={navigationTheme}>
|
|
99
|
+
<Stack screenOptions={{ ...rootBackground, ...statusBar, ...backdropOptions, ...packOptions }}>
|
|
100
|
+
<Stack.Screen name='index' />
|
|
101
|
+
<Stack.Screen name='play/[game]' options={playOptions} />
|
|
102
|
+
</Stack>
|
|
103
|
+
</NavigationThemeProvider>
|
|
92
104
|
)
|
|
93
|
-
if (!Backdrop
|
|
105
|
+
if (!Backdrop) return stack
|
|
94
106
|
return (
|
|
95
107
|
<View style={styles.root}>
|
|
96
108
|
<Backdrop />
|
|
97
|
-
|
|
109
|
+
{stack}
|
|
98
110
|
</View>
|
|
99
111
|
)
|
|
100
112
|
}
|
package/src/index.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// and store, and every screen, game and shared piece reads the pack through usePack().
|
|
3
3
|
export { PackProvider, usePack, usePackGame } from './PackContext'
|
|
4
4
|
export { RootLayout } from './RootLayout'
|
|
5
|
-
export { type FeedbackSounds, type GameDefinition, type GameGuide, type GameIconProps, type GameLoadout, type GamePreviewProps, type GameRulesProps, GUEST_PLAYER_ID, type GuidelessGameDefinition, type HeroTitleProps, humanSeatsFor, type LoadoutOptionsProps, type PackDefinition, type PackGameDefinition, type PackGuide, type PackStatsReset, type PackStatsSectionsProps, type PackStatsTab, type PickerAlign, type RoundMode, roundModeFor, type Seat, type SeatControlLook, type SeatTwo, seatTwoFor, type StackScreenOptions } from './types'
|
|
5
|
+
export { type FeedbackSounds, type GameDefinition, type GameGuide, type GameIconProps, type GameLoadout, type GamePreviewProps, type GameRulesProps, GUEST_PLAYER_ID, type GuidelessGameDefinition, type HeroTitleProps, type HomeTab, humanSeatsFor, type LoadoutOptionsProps, type PackDefinition, type PackGameDefinition, type PackGuide, type PackStatsReset, type PackStatsSectionsProps, type PackStatsTab, type PickerAlign, type RoundMode, roundModeFor, type Seat, type SeatControlLook, type SeatTwo, seatTwoFor, type StackScreenOptions } from './types'
|
|
6
6
|
|
|
7
7
|
// The screens behind an app's routes (src/app/ stays in the app: expo-router only reads routes there).
|
|
8
8
|
export { LoadoutRoute, PlayRoute } from './screens/GameRoute'
|
|
@@ -4,9 +4,9 @@ import { FakeLandscapeView, useRotation } from '@tastic/core'
|
|
|
4
4
|
import { PressAwayOverlay, usePopoverHost, useWellColors, WELL_SHADOW } from '@tastic/hud/chrome'
|
|
5
5
|
import { getProfilePickerContentSize, type Profile, ProfilePicker, type ProfilePickerColorSection } from '@tastic/profile'
|
|
6
6
|
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'
|
|
7
|
+
import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|
8
|
+
import { AppState, type AppStateStatus, type LayoutChangeEvent, type NativeScrollEvent, type NativeSyntheticEvent, type ScrollView, StyleSheet, View } from 'react-native'
|
|
9
|
+
import { Icon, Portal, SegmentedButtons, Text } from 'react-native-paper'
|
|
10
10
|
|
|
11
11
|
import { useGameGuide } from '../components/GuideHost'
|
|
12
12
|
import { SettingsDialog } from '../components/SettingsDialog'
|
|
@@ -17,14 +17,15 @@ import { useProfiles } from '../hooks/useProfiles'
|
|
|
17
17
|
import { useSeatColors } from '../hooks/useSeatColors'
|
|
18
18
|
import { usePack } from '../PackContext'
|
|
19
19
|
import { loadoutHref, playHref, PROFILES_HREF, STATS_HREF } from '../routes'
|
|
20
|
-
import type { PackGameDefinition } from '../types'
|
|
20
|
+
import type { HomeTab, PackGameDefinition } from '../types'
|
|
21
21
|
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
22
|
|
|
23
23
|
// Home: who's playing (seat 1, the same seat 1 every loadout starts with), then every game in the
|
|
24
24
|
// pack as a row in a fixed order, Solitaire's list: each game on its own well, its small app icon
|
|
25
25
|
// (or its preview, GameDefinition.Preview) beside its name and blurb. A tap opens that game's
|
|
26
26
|
// 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.
|
|
27
|
+
// picked. Seat 2 is chosen in the loadout, from seat 2's own button. A pack with tabs
|
|
28
|
+
// (PackDefinition.homeTabs) shows one tab's games at a time, from a switch over the rows.
|
|
28
29
|
//
|
|
29
30
|
// A cold launch that finds the player left the app inside a resumable game's play screen
|
|
30
31
|
// (GameDefinition.resumable) goes straight back there instead, before the menu ever paints. The
|
|
@@ -111,6 +112,18 @@ function HomeMenu() {
|
|
|
111
112
|
const p1Drawn = previewColor ? previewColor(p1Color, dark) : p1Color
|
|
112
113
|
const triggerStyle = useMemo(() => (pack.Backdrop ? [styles.pill, { backgroundColor: colors.surface }] : undefined), [pack.Backdrop, colors.surface])
|
|
113
114
|
|
|
115
|
+
// The pack's tabs (PackDefinition.homeTabs): one tab's games at a time, the same switch as the stats
|
|
116
|
+
// screen's tabs (on the theme's surface over a backdrop). The other tabs' games stay mounted, hidden, so
|
|
117
|
+
// a switch draws nothing new (their previews are already there). The tab is Home's own state, kept while
|
|
118
|
+
// Home is covered, since Home stays mounted under every screen it opens.
|
|
119
|
+
const tabs = pack.homeTabs && pack.homeTabs.length > 1 ? pack.homeTabs : null
|
|
120
|
+
const [tabLabel, setTabLabel] = useState(() => tabs?.[0].label ?? null)
|
|
121
|
+
const tab = tabs ? (tabs.find((t) => t.label === tabLabel) ?? tabs[0]) : null
|
|
122
|
+
const tabButtons = useMemo(() => tabs?.map(({ label }) => ({ value: label, label })) ?? [], [tabs])
|
|
123
|
+
const tabBar = tab && <SegmentedButtons value={tab.label} onValueChange={setTabLabel} buttons={tabButtons} style={[styles.tabs, pack.Backdrop && { backgroundColor: colors.surface }]} />
|
|
124
|
+
const columnClamp = useClampOnShrink()
|
|
125
|
+
const gridClamp = useClampOnShrink()
|
|
126
|
+
|
|
114
127
|
// A game with nothing to set up (no useLoadout) opens straight to its play screen.
|
|
115
128
|
const openGame = useCallback((game: PackGameDefinition) => openFirstTime(game.id, () => router.push(game.useLoadout ? loadoutHref(game.id) : playHref(game.id))), [openFirstTime])
|
|
116
129
|
|
|
@@ -149,10 +162,11 @@ function HomeMenu() {
|
|
|
149
162
|
to the frame's right edge and the cog's gutter is its content's padding, so a web scrollbar
|
|
150
163
|
sits at the window's edge. Both scrollers fade out at an edge while there is more past it
|
|
151
164
|
(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 }}>
|
|
165
|
+
<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 }}>
|
|
166
|
+
{tabBar && <View style={[styles.wideTabs, { width: layout.rightPaneWidth }]}>{tabBar}</View>}
|
|
153
167
|
<View style={[styles.grid, { width: layout.rightPaneWidth }]}>
|
|
154
168
|
{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} />
|
|
169
|
+
<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
170
|
))}
|
|
157
171
|
</View>
|
|
158
172
|
</FadingScrollView>
|
|
@@ -165,7 +179,7 @@ function HomeMenu() {
|
|
|
165
179
|
</View>
|
|
166
180
|
</>
|
|
167
181
|
) : (
|
|
168
|
-
<FadingScrollView style={styles.scroll} contentContainerStyle={[styles.content, { paddingTop: 56 + insets.top, paddingBottom: 24 + insets.bottom, paddingLeft: 16 + insets.left, paddingRight: 16 + insets.right }]}>
|
|
182
|
+
<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
183
|
{pressAway}
|
|
170
184
|
<pack.HeroTitle words={pack.wordmark} color={fg} p1Color={p1Color} p2Color={p2Color} active={heroActive} />
|
|
171
185
|
|
|
@@ -173,9 +187,11 @@ function HomeMenu() {
|
|
|
173
187
|
tiles while its menu is open, so the menu paints over them. */}
|
|
174
188
|
<View style={[styles.picker, pickerHost.openId !== null && styles.pickerOpen]}>{picker}</View>
|
|
175
189
|
|
|
190
|
+
{tabBar && <View style={styles.listWidth}>{tabBar}</View>}
|
|
191
|
+
|
|
176
192
|
<View style={styles.list}>
|
|
177
193
|
{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} />
|
|
194
|
+
<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
195
|
))}
|
|
180
196
|
</View>
|
|
181
197
|
</FadingScrollView>
|
|
@@ -200,6 +216,43 @@ const SEAT_ONE_PICKER = 'home-p1'
|
|
|
200
216
|
// No placement of the pack's own: hud places the menu itself.
|
|
201
217
|
const noPickerAlign = () => undefined
|
|
202
218
|
|
|
219
|
+
// A game the tab picked doesn't include: kept mounted, out of the layout and out of a screen reader's way.
|
|
220
|
+
function isHidden(tab: HomeTab | null, game: PackGameDefinition): boolean {
|
|
221
|
+
return !!tab && !tab.includes(game)
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// iOS leaves a scroller's offset past the end when its content shrinks (React Native's own TODO, T190695447
|
|
225
|
+
// in RCTScrollViewComponentView.mm), so a switch to a shorter tab while scrolled down would leave blank space
|
|
226
|
+
// under the last game until the next drag. This pulls it back in, as Android and the web do by themselves.
|
|
227
|
+
// Spread onto one scroller (its ref and three events) only in a pack with tabs, the one way Home's content
|
|
228
|
+
// shrinks. The ref is a callback so a scroller mounted afresh (a switch between the column and the grid)
|
|
229
|
+
// starts from its real offset, 0, not the last one's.
|
|
230
|
+
function useClampOnShrink() {
|
|
231
|
+
const view = useRef<ScrollView | null>(null)
|
|
232
|
+
const sizes = useRef({ viewport: 0, offset: 0 })
|
|
233
|
+
return useMemo(
|
|
234
|
+
() => ({
|
|
235
|
+
ref: (instance: ScrollView | null) => {
|
|
236
|
+
view.current = instance
|
|
237
|
+
sizes.current.offset = 0
|
|
238
|
+
},
|
|
239
|
+
onLayout: (e: LayoutChangeEvent) => {
|
|
240
|
+
sizes.current.viewport = e.nativeEvent.layout.height
|
|
241
|
+
},
|
|
242
|
+
onScroll: (e: NativeSyntheticEvent<NativeScrollEvent>) => {
|
|
243
|
+
sizes.current.offset = e.nativeEvent.contentOffset.y
|
|
244
|
+
},
|
|
245
|
+
onContentSizeChange: (_width: number, height: number) => {
|
|
246
|
+
const end = Math.max(0, height - sizes.current.viewport)
|
|
247
|
+
if (sizes.current.offset <= end) return
|
|
248
|
+
sizes.current.offset = end
|
|
249
|
+
view.current?.scrollTo({ y: end, animated: false })
|
|
250
|
+
}
|
|
251
|
+
}),
|
|
252
|
+
[]
|
|
253
|
+
)
|
|
254
|
+
}
|
|
255
|
+
|
|
203
256
|
// Whether a tile has a rules button: a rules sheet or How to Play cards to show.
|
|
204
257
|
function hasRules(game: PackGameDefinition): boolean {
|
|
205
258
|
return !!(game.Rules || game.guide)
|
|
@@ -214,6 +267,8 @@ function RulesHost({ enabled, children }: { enabled: boolean; children: ReactNod
|
|
|
214
267
|
|
|
215
268
|
interface GameRowProps {
|
|
216
269
|
game: PackGameDefinition
|
|
270
|
+
// Not in the tab picked (PackDefinition.homeTabs).
|
|
271
|
+
hidden: boolean
|
|
217
272
|
dark: boolean
|
|
218
273
|
p1Color: string
|
|
219
274
|
p2Color: string
|
|
@@ -227,13 +282,13 @@ interface GameRowProps {
|
|
|
227
282
|
// One game on its own well: its icon at app-icon size (the art was drawn as an app icon), or its
|
|
228
283
|
// preview where it has one, its name and one-line blurb, and a rules button at the end that shows its
|
|
229
284
|
// How to Play cards without opening the game.
|
|
230
|
-
function GameRow({ game, dark, p1Color, p2Color, previewRevealed, onPress, onRules }: GameRowProps) {
|
|
285
|
+
function GameRow({ game, hidden, dark, p1Color, p2Color, previewRevealed, onPress, onRules }: GameRowProps) {
|
|
231
286
|
const well = useWellColors()
|
|
232
287
|
const GameIcon = game.icon
|
|
233
288
|
return (
|
|
234
289
|
// The rules button sits beside the row's own button, laid over its end, not inside it: a button
|
|
235
290
|
// inside a button is invalid on the web.
|
|
236
|
-
<View>
|
|
291
|
+
<View style={hidden ? styles.hidden : undefined}>
|
|
237
292
|
<Pressable onPress={onPress} style={[styles.row, { backgroundColor: well.bg }]} accessibilityRole='button' accessibilityLabel={game.title}>
|
|
238
293
|
{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
294
|
<View style={styles.rowText}>
|
|
@@ -262,10 +317,10 @@ interface GameTileProps extends GameRowProps {
|
|
|
262
317
|
// One game in the wide layout's grid: the row turned on its side, its preview (or icon) on top and its
|
|
263
318
|
// name and blurb under it, since a row needs ~290pt, more than a grid column beside the hero has. The
|
|
264
319
|
// 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) {
|
|
320
|
+
function GameTile({ game, hidden, width, previewWidth, dark, p1Color, p2Color, previewRevealed, onPress, onRules }: GameTileProps) {
|
|
266
321
|
const well = useWellColors()
|
|
267
322
|
return (
|
|
268
|
-
<View style={{ width }}>
|
|
323
|
+
<View style={[{ width }, hidden && styles.hidden]}>
|
|
269
324
|
<Pressable onPress={onPress} style={[styles.tile, { backgroundColor: well.bg }]} accessibilityRole='button' accessibilityLabel={game.title}>
|
|
270
325
|
<PreviewBox game={game} width={previewWidth} revealed={previewRevealed} dark={dark} p1Color={p1Color} p2Color={p2Color} />
|
|
271
326
|
<Text variant='titleMedium' style={[styles.rowTitle, { color: well.fg }]} numberOfLines={1}>
|
|
@@ -337,12 +392,21 @@ const styles = StyleSheet.create({
|
|
|
337
392
|
flexWrap: 'wrap',
|
|
338
393
|
gap: HOME_GRID_GAP
|
|
339
394
|
},
|
|
395
|
+
// Out of the layout (a grid closes up around it) and hidden from screen readers.
|
|
396
|
+
hidden: {
|
|
397
|
+
display: 'none'
|
|
398
|
+
},
|
|
340
399
|
// Solitaire's list: one column, never wider than a phone's, centred on a wide screen.
|
|
341
400
|
list: {
|
|
342
401
|
gap: 14,
|
|
343
402
|
maxWidth: 480,
|
|
344
403
|
width: '100%'
|
|
345
404
|
},
|
|
405
|
+
// The list's width, for the tabs over it.
|
|
406
|
+
listWidth: {
|
|
407
|
+
maxWidth: 480,
|
|
408
|
+
width: '100%'
|
|
409
|
+
},
|
|
346
410
|
picker: {
|
|
347
411
|
alignItems: 'center'
|
|
348
412
|
},
|
|
@@ -405,6 +469,10 @@ const styles = StyleSheet.create({
|
|
|
405
469
|
scroll: {
|
|
406
470
|
flex: 1
|
|
407
471
|
},
|
|
472
|
+
// SegmentedButtons' own rounding, so a surface behind it is the same pill (the stats screen's tabs).
|
|
473
|
+
tabs: {
|
|
474
|
+
borderRadius: 20
|
|
475
|
+
},
|
|
408
476
|
// The same well as a row, turned on its side.
|
|
409
477
|
tile: {
|
|
410
478
|
borderRadius: 12,
|
|
@@ -443,5 +511,9 @@ const styles = StyleSheet.create({
|
|
|
443
511
|
position: 'absolute',
|
|
444
512
|
right: 0,
|
|
445
513
|
top: 0
|
|
514
|
+
},
|
|
515
|
+
// The tabs over the grid, a little further from it than the tiles are from each other.
|
|
516
|
+
wideTabs: {
|
|
517
|
+
marginBottom: 16
|
|
446
518
|
}
|
|
447
519
|
})
|
package/src/types.ts
CHANGED
|
@@ -225,6 +225,13 @@ export interface PackStatsSectionsProps {
|
|
|
225
225
|
gameId: string | null
|
|
226
226
|
}
|
|
227
227
|
|
|
228
|
+
// A tab on Home (PackDefinition.homeTabs): its label, and which of the pack's games it shows, in the
|
|
229
|
+
// pack's order (Solitaire's: the games with no seat 2, and the ones with one).
|
|
230
|
+
export interface HomeTab {
|
|
231
|
+
label: string
|
|
232
|
+
includes: (game: PackGameDefinition) => boolean
|
|
233
|
+
}
|
|
234
|
+
|
|
228
235
|
// The stack's screen options (expo-router's Stack), as an object: what PackDefinition.useScreenOptions
|
|
229
236
|
// returns.
|
|
230
237
|
export type StackScreenOptions = Exclude<NonNullable<ComponentProps<typeof Stack>['screenOptions']>, (...args: never[]) => unknown>
|
|
@@ -260,6 +267,10 @@ export interface PackDefinition {
|
|
|
260
267
|
// A seat's color pick for a profile is kept for this pack only; the seat button says so.
|
|
261
268
|
colorCaption: string
|
|
262
269
|
games: readonly PackGameDefinition[]
|
|
270
|
+
// Home splits the games into these tabs, a switch under seat 1's button (over the grid on a wide
|
|
271
|
+
// screen) showing one tab's games at a time. It opens on the first, and keeps the tab picked while Home
|
|
272
|
+
// is covered. Unset, or a single tab, shows every game with no switch.
|
|
273
|
+
homeTabs?: readonly HomeTab[]
|
|
263
274
|
// Player 1's and player 2's own default colors. They seed the theme's primary and secondary (the
|
|
264
275
|
// tertiary is derived from them, see shellState.ts) and the guest and CPU colors a fresh install
|
|
265
276
|
// starts with (seatColorsSlice.ts), so a fresh install's live theme and its remembered colors agree.
|