@tastic/shell 0.3.1 → 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 +2 -1
- package/dist/index.d.mts +6 -1
- package/dist/index.d.ts +6 -1
- package/dist/index.js +63 -7
- package/dist/index.mjs +67 -11
- package/package.json +1 -1
- package/src/index.ts +1 -1
- package/src/screens/HomeScreen.tsx +85 -13
- package/src/types.ts +11 -0
package/README.md
CHANGED
|
@@ -22,7 +22,7 @@ package ecosystem, on [`@tastic/hud`](https://github.com/jayrdeaton/react-native
|
|
|
22
22
|
own theme, not the phone's light or dark appearance: the status bar's text, the root window and the
|
|
23
23
|
navigation theme's background (what shows in a sliding screen's corners during a push or pop).
|
|
24
24
|
- **Screens** for the routes: `HomeScreen` (the pack's hero, seat 1's button, a row per game, or a
|
|
25
|
-
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]`,
|
|
26
26
|
`play/[game]`), `ProfilesRoute` and `StatsScreen`.
|
|
27
27
|
- **The store**: `createPackStore(pack, { keyPrefix, migrate, blacklist, serializableIgnoredPaths,
|
|
28
28
|
middleware })` builds one persisted store from the shell's keys (theme, haptics, sound, Keep Awake,
|
|
@@ -130,6 +130,7 @@ PuckTastic and DuelTastic draw what they always did:
|
|
|
130
130
|
| `statsGameTitles?` | `false` leaves each game's name off its sections on All Games, where the sections name it. |
|
|
131
131
|
| `newProfileColor?` | A new profile's starting color on Profiles. Unset is auto-paper's first swatch. |
|
|
132
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. |
|
|
133
134
|
|
|
134
135
|
### `GameDefinition`
|
|
135
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
|
@@ -1177,6 +1177,13 @@ function HomeMenu() {
|
|
|
1177
1177
|
const { previewColor } = pack;
|
|
1178
1178
|
const p1Drawn = previewColor ? previewColor(p1Color, dark) : p1Color;
|
|
1179
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();
|
|
1180
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]);
|
|
1181
1188
|
const openRules = (0, import_react16.useCallback)((game) => game.Rules ? setRulesGame(game) : openFor(game.id), [openFor]);
|
|
1182
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 };
|
|
@@ -1190,16 +1197,20 @@ function HomeMenu() {
|
|
|
1190
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: [
|
|
1191
1198
|
isWide ? /* @__PURE__ */ (0, import_jsx_runtime14.jsxs)(import_jsx_runtime14.Fragment, { children: [
|
|
1192
1199
|
pressAway,
|
|
1193
|
-
/* @__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
|
+
] }),
|
|
1194
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: [
|
|
1195
1205
|
/* @__PURE__ */ (0, import_jsx_runtime14.jsx)(pack.HeroTitle, { words: pack.wordmark, color: fg, p1Color, p2Color, active: heroActive, fit: layout.heroFit }),
|
|
1196
1206
|
/* @__PURE__ */ (0, import_jsx_runtime14.jsx)(import_react_native6.View, { style: styles4.widePicker, children: picker })
|
|
1197
1207
|
] })
|
|
1198
|
-
] }) : /* @__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: [
|
|
1199
1209
|
pressAway,
|
|
1200
1210
|
/* @__PURE__ */ (0, import_jsx_runtime14.jsx)(pack.HeroTitle, { words: pack.wordmark, color: fg, p1Color, p2Color, active: heroActive }),
|
|
1201
1211
|
/* @__PURE__ */ (0, import_jsx_runtime14.jsx)(import_react_native6.View, { style: [styles4.picker, pickerHost.openId !== null && styles4.pickerOpen], children: picker }),
|
|
1202
|
-
/* @__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)) })
|
|
1203
1214
|
] }),
|
|
1204
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" }),
|
|
1205
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" }),
|
|
@@ -1210,19 +1221,47 @@ function HomeMenu() {
|
|
|
1210
1221
|
}
|
|
1211
1222
|
var SEAT_ONE_PICKER = "home-p1";
|
|
1212
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
|
+
}
|
|
1213
1252
|
function hasRules(game) {
|
|
1214
1253
|
return !!(game.Rules || game.guide);
|
|
1215
1254
|
}
|
|
1216
1255
|
function RulesHost({ enabled, children }) {
|
|
1217
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 });
|
|
1218
1257
|
}
|
|
1219
|
-
function GameRow({ game, dark, p1Color, p2Color, previewRevealed, onPress, onRules }) {
|
|
1258
|
+
function GameRow({ game, hidden, dark, p1Color, p2Color, previewRevealed, onPress, onRules }) {
|
|
1220
1259
|
const well = (0, import_chrome5.useWellColors)();
|
|
1221
1260
|
const GameIcon = game.icon;
|
|
1222
1261
|
return (
|
|
1223
1262
|
// The rules button sits beside the row's own button, laid over its end, not inside it: a button
|
|
1224
1263
|
// inside a button is invalid on the web.
|
|
1225
|
-
/* @__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: [
|
|
1226
1265
|
/* @__PURE__ */ (0, import_jsx_runtime14.jsxs)(import_feedback_press3.Pressable, { onPress, style: [styles4.row, { backgroundColor: well.bg }], accessibilityRole: "button", accessibilityLabel: game.title, children: [
|
|
1227
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 }) }),
|
|
1228
1267
|
/* @__PURE__ */ (0, import_jsx_runtime14.jsxs)(import_react_native6.View, { style: styles4.rowText, children: [
|
|
@@ -1234,9 +1273,9 @@ function GameRow({ game, dark, p1Color, p2Color, previewRevealed, onPress, onRul
|
|
|
1234
1273
|
] })
|
|
1235
1274
|
);
|
|
1236
1275
|
}
|
|
1237
|
-
function GameTile({ game, width, previewWidth, dark, p1Color, p2Color, previewRevealed, onPress, onRules }) {
|
|
1276
|
+
function GameTile({ game, hidden, width, previewWidth, dark, p1Color, p2Color, previewRevealed, onPress, onRules }) {
|
|
1238
1277
|
const well = (0, import_chrome5.useWellColors)();
|
|
1239
|
-
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: [
|
|
1240
1279
|
/* @__PURE__ */ (0, import_jsx_runtime14.jsxs)(import_feedback_press3.Pressable, { onPress, style: [styles4.tile, { backgroundColor: well.bg }], accessibilityRole: "button", accessibilityLabel: game.title, children: [
|
|
1241
1280
|
/* @__PURE__ */ (0, import_jsx_runtime14.jsx)(PreviewBox, { game, width: previewWidth, revealed: previewRevealed, dark, p1Color, p2Color }),
|
|
1242
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 }),
|
|
@@ -1272,12 +1311,21 @@ var styles4 = import_react_native6.StyleSheet.create({
|
|
|
1272
1311
|
flexWrap: "wrap",
|
|
1273
1312
|
gap: HOME_GRID_GAP
|
|
1274
1313
|
},
|
|
1314
|
+
// Out of the layout (a grid closes up around it) and hidden from screen readers.
|
|
1315
|
+
hidden: {
|
|
1316
|
+
display: "none"
|
|
1317
|
+
},
|
|
1275
1318
|
// Solitaire's list: one column, never wider than a phone's, centred on a wide screen.
|
|
1276
1319
|
list: {
|
|
1277
1320
|
gap: 14,
|
|
1278
1321
|
maxWidth: 480,
|
|
1279
1322
|
width: "100%"
|
|
1280
1323
|
},
|
|
1324
|
+
// The list's width, for the tabs over it.
|
|
1325
|
+
listWidth: {
|
|
1326
|
+
maxWidth: 480,
|
|
1327
|
+
width: "100%"
|
|
1328
|
+
},
|
|
1281
1329
|
picker: {
|
|
1282
1330
|
alignItems: "center"
|
|
1283
1331
|
},
|
|
@@ -1340,6 +1388,10 @@ var styles4 = import_react_native6.StyleSheet.create({
|
|
|
1340
1388
|
scroll: {
|
|
1341
1389
|
flex: 1
|
|
1342
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
|
+
},
|
|
1343
1395
|
// The same well as a row, turned on its side.
|
|
1344
1396
|
tile: {
|
|
1345
1397
|
borderRadius: 12,
|
|
@@ -1378,6 +1430,10 @@ var styles4 = import_react_native6.StyleSheet.create({
|
|
|
1378
1430
|
position: "absolute",
|
|
1379
1431
|
right: 0,
|
|
1380
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
|
|
1381
1437
|
}
|
|
1382
1438
|
});
|
|
1383
1439
|
|
package/dist/index.mjs
CHANGED
|
@@ -968,9 +968,9 @@ import { FakeLandscapeView as FakeLandscapeView2, useRotation as useRotation4 }
|
|
|
968
968
|
import { PressAwayOverlay as PressAwayOverlay2, usePopoverHost as usePopoverHost2, useWellColors, WELL_SHADOW } from "@tastic/hud/chrome";
|
|
969
969
|
import { getProfilePickerContentSize, ProfilePicker } from "@tastic/profile";
|
|
970
970
|
import { Redirect as Redirect2, router as router3, useIsFocused } from "expo-router";
|
|
971
|
-
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";
|
|
972
972
|
import { AppState as AppState2, StyleSheet as StyleSheet4, View as View4 } from "react-native";
|
|
973
|
-
import { Icon, Portal, Text } from "react-native-paper";
|
|
973
|
+
import { Icon, Portal, SegmentedButtons, Text } from "react-native-paper";
|
|
974
974
|
|
|
975
975
|
// src/hooks/useFrozenWhileHidden.ts
|
|
976
976
|
import { useState as useState5 } from "react";
|
|
@@ -1103,6 +1103,13 @@ function HomeMenu() {
|
|
|
1103
1103
|
const { previewColor } = pack;
|
|
1104
1104
|
const p1Drawn = previewColor ? previewColor(p1Color, dark) : p1Color;
|
|
1105
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();
|
|
1106
1113
|
const openGame = useCallback10((game) => openFirstTime(game.id, () => router3.push(game.useLoadout ? loadoutHref(game.id) : playHref(game.id))), [openFirstTime]);
|
|
1107
1114
|
const openRules = useCallback10((game) => game.Rules ? setRulesGame(game) : openFor(game.id), [openFor]);
|
|
1108
1115
|
const colorSection = { value: p1Color, onChange: handleP1ColorChange, swatches: defaultColors2, caption: selectedProfile ? pack.colorCaption : void 0, previewValue: previewColor ? (hex) => previewColor(hex, dark) : void 0 };
|
|
@@ -1116,16 +1123,20 @@ function HomeMenu() {
|
|
|
1116
1123
|
/* @__PURE__ */ jsx14(FakeLandscapeView2, { style: styles4.rotatable, children: /* @__PURE__ */ jsxs6(RulesHost, { enabled: pack.games.some((game) => game.Rules), children: [
|
|
1117
1124
|
isWide ? /* @__PURE__ */ jsxs6(Fragment4, { children: [
|
|
1118
1125
|
pressAway,
|
|
1119
|
-
/* @__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
|
+
] }),
|
|
1120
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: [
|
|
1121
1131
|
/* @__PURE__ */ jsx14(pack.HeroTitle, { words: pack.wordmark, color: fg, p1Color, p2Color, active: heroActive, fit: layout.heroFit }),
|
|
1122
1132
|
/* @__PURE__ */ jsx14(View4, { style: styles4.widePicker, children: picker })
|
|
1123
1133
|
] })
|
|
1124
|
-
] }) : /* @__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: [
|
|
1125
1135
|
pressAway,
|
|
1126
1136
|
/* @__PURE__ */ jsx14(pack.HeroTitle, { words: pack.wordmark, color: fg, p1Color, p2Color, active: heroActive }),
|
|
1127
1137
|
/* @__PURE__ */ jsx14(View4, { style: [styles4.picker, pickerHost.openId !== null && styles4.pickerOpen], children: picker }),
|
|
1128
|
-
/* @__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)) })
|
|
1129
1140
|
] }),
|
|
1130
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" }),
|
|
1131
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" }),
|
|
@@ -1136,19 +1147,47 @@ function HomeMenu() {
|
|
|
1136
1147
|
}
|
|
1137
1148
|
var SEAT_ONE_PICKER = "home-p1";
|
|
1138
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
|
+
}
|
|
1139
1178
|
function hasRules(game) {
|
|
1140
1179
|
return !!(game.Rules || game.guide);
|
|
1141
1180
|
}
|
|
1142
1181
|
function RulesHost({ enabled, children }) {
|
|
1143
1182
|
return enabled ? /* @__PURE__ */ jsx14(Portal.Host, { children }) : /* @__PURE__ */ jsx14(Fragment4, { children });
|
|
1144
1183
|
}
|
|
1145
|
-
function GameRow({ game, dark, p1Color, p2Color, previewRevealed, onPress, onRules }) {
|
|
1184
|
+
function GameRow({ game, hidden, dark, p1Color, p2Color, previewRevealed, onPress, onRules }) {
|
|
1146
1185
|
const well = useWellColors();
|
|
1147
1186
|
const GameIcon = game.icon;
|
|
1148
1187
|
return (
|
|
1149
1188
|
// The rules button sits beside the row's own button, laid over its end, not inside it: a button
|
|
1150
1189
|
// inside a button is invalid on the web.
|
|
1151
|
-
/* @__PURE__ */ jsxs6(View4, { children: [
|
|
1190
|
+
/* @__PURE__ */ jsxs6(View4, { style: hidden ? styles4.hidden : void 0, children: [
|
|
1152
1191
|
/* @__PURE__ */ jsxs6(Pressable, { onPress, style: [styles4.row, { backgroundColor: well.bg }], accessibilityRole: "button", accessibilityLabel: game.title, children: [
|
|
1153
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 }) }),
|
|
1154
1193
|
/* @__PURE__ */ jsxs6(View4, { style: styles4.rowText, children: [
|
|
@@ -1160,9 +1199,9 @@ function GameRow({ game, dark, p1Color, p2Color, previewRevealed, onPress, onRul
|
|
|
1160
1199
|
] })
|
|
1161
1200
|
);
|
|
1162
1201
|
}
|
|
1163
|
-
function GameTile({ game, width, previewWidth, dark, p1Color, p2Color, previewRevealed, onPress, onRules }) {
|
|
1202
|
+
function GameTile({ game, hidden, width, previewWidth, dark, p1Color, p2Color, previewRevealed, onPress, onRules }) {
|
|
1164
1203
|
const well = useWellColors();
|
|
1165
|
-
return /* @__PURE__ */ jsxs6(View4, { style: { width }, children: [
|
|
1204
|
+
return /* @__PURE__ */ jsxs6(View4, { style: [{ width }, hidden && styles4.hidden], children: [
|
|
1166
1205
|
/* @__PURE__ */ jsxs6(Pressable, { onPress, style: [styles4.tile, { backgroundColor: well.bg }], accessibilityRole: "button", accessibilityLabel: game.title, children: [
|
|
1167
1206
|
/* @__PURE__ */ jsx14(PreviewBox, { game, width: previewWidth, revealed: previewRevealed, dark, p1Color, p2Color }),
|
|
1168
1207
|
/* @__PURE__ */ jsx14(Text, { variant: "titleMedium", style: [styles4.rowTitle, { color: well.fg }], numberOfLines: 1, children: game.title }),
|
|
@@ -1198,12 +1237,21 @@ var styles4 = StyleSheet4.create({
|
|
|
1198
1237
|
flexWrap: "wrap",
|
|
1199
1238
|
gap: HOME_GRID_GAP
|
|
1200
1239
|
},
|
|
1240
|
+
// Out of the layout (a grid closes up around it) and hidden from screen readers.
|
|
1241
|
+
hidden: {
|
|
1242
|
+
display: "none"
|
|
1243
|
+
},
|
|
1201
1244
|
// Solitaire's list: one column, never wider than a phone's, centred on a wide screen.
|
|
1202
1245
|
list: {
|
|
1203
1246
|
gap: 14,
|
|
1204
1247
|
maxWidth: 480,
|
|
1205
1248
|
width: "100%"
|
|
1206
1249
|
},
|
|
1250
|
+
// The list's width, for the tabs over it.
|
|
1251
|
+
listWidth: {
|
|
1252
|
+
maxWidth: 480,
|
|
1253
|
+
width: "100%"
|
|
1254
|
+
},
|
|
1207
1255
|
picker: {
|
|
1208
1256
|
alignItems: "center"
|
|
1209
1257
|
},
|
|
@@ -1266,6 +1314,10 @@ var styles4 = StyleSheet4.create({
|
|
|
1266
1314
|
scroll: {
|
|
1267
1315
|
flex: 1
|
|
1268
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
|
+
},
|
|
1269
1321
|
// The same well as a row, turned on its side.
|
|
1270
1322
|
tile: {
|
|
1271
1323
|
borderRadius: 12,
|
|
@@ -1304,6 +1356,10 @@ var styles4 = StyleSheet4.create({
|
|
|
1304
1356
|
position: "absolute",
|
|
1305
1357
|
right: 0,
|
|
1306
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
|
|
1307
1363
|
}
|
|
1308
1364
|
});
|
|
1309
1365
|
|
|
@@ -1329,7 +1385,7 @@ import { BaseStatsScreen, PressAwayOverlay as PressAwayOverlay3, RotatedFrame as
|
|
|
1329
1385
|
import { getOptionPickerContentSize as getOptionPickerContentSize2, OptionPicker as OptionPicker2 } from "@tastic/profile";
|
|
1330
1386
|
import { useMemo as useMemo7, useState as useState7 } from "react";
|
|
1331
1387
|
import { StyleSheet as StyleSheet5, View as View5 } from "react-native";
|
|
1332
|
-
import { SegmentedButtons, Text as Text2 } from "react-native-paper";
|
|
1388
|
+
import { SegmentedButtons as SegmentedButtons2, Text as Text2 } from "react-native-paper";
|
|
1333
1389
|
import { useSafeAreaInsets as useSafeAreaInsets3 } from "react-native-safe-area-context";
|
|
1334
1390
|
import { useDispatch as useDispatch10 } from "react-redux";
|
|
1335
1391
|
import { jsx as jsx16, jsxs as jsxs7 } from "react/jsx-runtime";
|
|
@@ -1387,7 +1443,7 @@ function StatsContent({ pickerHost }) {
|
|
|
1387
1443
|
const PackTabContent = packTab?.Content;
|
|
1388
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: [
|
|
1389
1445
|
/* @__PURE__ */ jsx16(PressAwayOverlay3, { active: pickerHost.openId !== null, onPress: pickerHost.close, style: styles5.pressAway }),
|
|
1390
|
-
/* @__PURE__ */ jsx16(
|
|
1446
|
+
/* @__PURE__ */ jsx16(SegmentedButtons2, { value: tab, onValueChange: setTab, buttons: tabButtons, style: [styles5.tabs, surface] }),
|
|
1391
1447
|
/* @__PURE__ */ jsxs7(View5, { style: [styles5.filters, pickerHost.openId !== null && styles5.filtersOpen], onStartShouldSetResponder: () => pickerHost.openId !== null, children: [
|
|
1392
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 }),
|
|
1393
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/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.
|