@tastic/profile 0.4.0 → 0.8.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 +166 -20
- package/dist/index.d.mts +83 -6
- package/dist/index.d.ts +83 -6
- package/dist/index.js +91 -13
- package/dist/index.mjs +82 -8
- package/package.json +10 -10
- package/src/ProfilePicker.tsx +67 -7
- package/src/ProfilesManager.tsx +4 -1
- package/src/ProfilesScreen.tsx +83 -0
- package/src/index.ts +5 -2
- package/src/redux/profileSelectionSlice.ts +81 -0
- package/src/redux/seatColorsSlice.ts +96 -0
package/README.md
CHANGED
|
@@ -49,11 +49,25 @@ see its own doc comment for why that component is generic over your own `Profile
|
|
|
49
49
|
- **`ProfilePicker`** — a name trigger + selection popover: pick an existing profile, fall back to a
|
|
50
50
|
null/"guest" selection, or (if `onManage` is passed) navigate to a management screen. Selection
|
|
51
51
|
only — creating, renaming, and deleting all live in `ProfilesManager`. Generic over your own
|
|
52
|
-
`Profile` type (see above).
|
|
52
|
+
`Profile` type (see above). Placement props (all optional, see "Fake-rotated frames" below):
|
|
53
|
+
`align` (force a horizontal side), `alignOverride` (substitute the whole placement result), and
|
|
54
|
+
`rotation` (forwarded to the automatic measurement). Also exports
|
|
55
|
+
`getProfilePickerContentSize(profileCount, hasManageRow?)` — the popover's
|
|
56
|
+
`{ width, height }`, for sizing your own placement hook.
|
|
53
57
|
- **`ProfilesManager`** — the roster screen itself: create/rename/recolor/retag/delete, editing
|
|
54
58
|
inline in the row you tap, with a destructive-delete confirmation. No navigation of its own (no
|
|
55
59
|
router, no back button) — a host app renders this as a routed screen's body and supplies its own
|
|
56
|
-
persistence via `profiles`/`onCreate`/`onSave`/`onDelete`.
|
|
60
|
+
persistence via `profiles`/`onCreate`/`onSave`/`onDelete`. `onCreate`/`onSave` both take a
|
|
61
|
+
`ProfileEditPatch` (`{ name, color, tag }`) — exported from this package's own public API (it was
|
|
62
|
+
a private, unexported interface inside `ProfilesManager.tsx` until `ProfilesScreen` below needed
|
|
63
|
+
to reference it from its own props).
|
|
64
|
+
- **`ProfilesScreen`** — the routed-screen shell around `ProfilesManager`: the container `View`,
|
|
65
|
+
theme-derived background/foreground, a back button, and the commit-pending-edit-before-navigate
|
|
66
|
+
composition every app in the fleet used to hand-roll on its own around `ProfilesManager` (see
|
|
67
|
+
"Routed screens: `ProfilesScreen`" below). Same `ProfileEditPatch`-shaped
|
|
68
|
+
`profiles`/`defaultColor`/`onCreate`/`onSave`/`onDelete` as `ProfilesManager` itself, plus
|
|
69
|
+
`onBack`. Owns its own `ProfilesManagerHandle` ref internally — there's no `ref` prop here, since
|
|
70
|
+
nothing outside it needs one anymore.
|
|
57
71
|
- **`loadSharedProfiles` / `saveSharedProfiles` / `isSharedProfileStoreAvailable`** — an *optional*
|
|
58
72
|
cross-app roster, shared between multiple apps of yours via a native iOS App Group (same Apple
|
|
59
73
|
Developer Team, same `"com.apple.security.application-groups"` entitlement value on every
|
|
@@ -88,13 +102,105 @@ see its own doc comment for why that component is generic over your own `Profile
|
|
|
88
102
|
{ "expo": { "ios": { "entitlements": { "com.apple.security.application-groups": ["group.com.yourteam.yourgames"] } } } }
|
|
89
103
|
```
|
|
90
104
|
|
|
105
|
+
### Fake-rotated frames: `ProfilePicker` placement
|
|
106
|
+
|
|
107
|
+
`ProfilePicker` positions its popover with `@tastic/hud`'s `useAutoAlign`, which measures the
|
|
108
|
+
trigger with `measureInWindow` and compares that rect against the raw window. Inside a *fake*
|
|
109
|
+
rotation (a `View` turned +-90deg while the OS stays portrait-locked) the rect is in true screen
|
|
110
|
+
space but the frame is not, so the hook measures along the wrong axes and can pick the wrong side —
|
|
111
|
+
flipping the list upward off-screen, or running it past the frame's bottom. Two optional props give
|
|
112
|
+
you the same seam `@tastic/hud`'s `InlineColorPicker` has:
|
|
113
|
+
|
|
114
|
+
| Prop | Type | Default | Effect |
|
|
115
|
+
| --- | --- | --- | --- |
|
|
116
|
+
| `align` | `'left' \| 'right' \| 'center'` | — | Forces the horizontal side; always beats the placement's own `align`. |
|
|
117
|
+
| `alignOverride` | `ProfilePickerAlignResult` (`{ align, verticalAlign, maxHeight, measured, triggerRef }`, the same shape as `useAutoAlign`'s return / `@tastic/hud`'s `AlignResult`) | — | Replaces the automatic placement wholesale: its `align`/`verticalAlign`/`maxHeight`/`measured` drive the popover, and its `triggerRef` is the ref attached to the measured trigger. |
|
|
118
|
+
| `rotation` | `0 \| 90 \| -90 \| 180` | `0` | Forwarded to the automatic `useAutoAlign` call (needs `@tastic/hud` >= 0.8.0). Ignored when `alignOverride` is given. |
|
|
119
|
+
|
|
120
|
+
With none of them set the picker behaves exactly as it always has. Note `rotation` is never read
|
|
121
|
+
from an ambient provider here (unlike `@tastic/hud`'s own pickers) — this package doesn't touch
|
|
122
|
+
`@tastic/core` at runtime — so pass it explicitly.
|
|
123
|
+
|
|
124
|
+
To supply your own placement, run a rotation-aware placement hook against the popover's real size,
|
|
125
|
+
using `getProfilePickerContentSize` so the two can't drift:
|
|
126
|
+
|
|
127
|
+
```tsx
|
|
128
|
+
const open = host.openId === `${idPrefix}-profile`
|
|
129
|
+
const { width, height } = getProfilePickerContentSize(profiles.length, !!onManage)
|
|
130
|
+
const placement = useMyRotatedAlign(open, width, height) // returns a ProfilePickerAlignResult
|
|
131
|
+
|
|
132
|
+
<ProfilePicker idPrefix={idPrefix} host={host} profiles={profiles} /* ... */ alignOverride={placement} />
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
`getProfilePickerContentSize(profileCount, hasManageRow = false)` returns `{ width, height }`: 220
|
|
136
|
+
wide, and `8 * 2 + (profileCount + 1 + (hasManageRow ? 1 : 0)) * 36` tall (the null/"guest" row, one
|
|
137
|
+
row per profile, and the Manage row when `onManage` is passed).
|
|
138
|
+
|
|
139
|
+
### Routed screens: `ProfilesScreen`
|
|
140
|
+
|
|
141
|
+
`ProfilesScreen` is the routed-screen shell every app in the fleet was hand-rolling on its own
|
|
142
|
+
around `ProfilesManager`: a container `View`, theme-derived bg/fg, a `headerLeft` back button, and
|
|
143
|
+
the two-line `managerRef.current?.commitPendingEdit(); onBack()` composition every app's own
|
|
144
|
+
back-button handler used to write out by hand (see `ProfilesManager`'s own `commitPendingEdit` doc
|
|
145
|
+
above for why that flush matters on a router that doesn't genuinely unmount a popped screen). It
|
|
146
|
+
owns that ref itself — there's no `ref` prop on `ProfilesScreen`, since nothing outside it needs
|
|
147
|
+
one any more.
|
|
148
|
+
|
|
149
|
+
Same `profiles`/`defaultColor`/`onCreate`/`onSave`/`onDelete` props as `ProfilesManager` — still
|
|
150
|
+
`ProfileEditPatch`-shaped, still deliberately not widened to your own richer `CreateProfileInput`;
|
|
151
|
+
compose any extra required fields (a key/control scheme, whatever's specific to your game) in your
|
|
152
|
+
own closure at the call site, exactly as you already would calling `ProfilesManager` directly —
|
|
153
|
+
plus `onBack`. `fg`/`fgMuted`/`bg`/`cardBg`/`titleVariant`/`colorPreview` are all independently
|
|
154
|
+
optional and forward straight through to the internal `ProfilesManager` (`bg` is new here —
|
|
155
|
+
`ProfilesManager` itself has no `bg`, since it doesn't own a container `View` of its own).
|
|
156
|
+
|
|
157
|
+
A whole routed screen, wired to your own store, is usually this small:
|
|
158
|
+
|
|
159
|
+
```tsx
|
|
160
|
+
// app/profiles.tsx
|
|
161
|
+
import { defaultColors } from '@rific/auto-paper'
|
|
162
|
+
import { ProfilesScreen } from '@tastic/profile'
|
|
163
|
+
import { router } from 'expo-router'
|
|
164
|
+
|
|
165
|
+
import { useGameStats } from '@/hooks/useGameStats'
|
|
166
|
+
import { useProfiles } from '@/hooks/useProfiles'
|
|
167
|
+
|
|
168
|
+
export default function Profiles() {
|
|
169
|
+
const { profiles, createProfile, updateProfile, deleteProfile } = useProfiles()
|
|
170
|
+
// Only for wiring onDelete below — clears a deleted profile's own stats record so it doesn't
|
|
171
|
+
// stick around as an orphan once the profile itself is gone.
|
|
172
|
+
const { removeProfileStats } = useGameStats()
|
|
173
|
+
|
|
174
|
+
return (
|
|
175
|
+
<ProfilesScreen
|
|
176
|
+
profiles={profiles}
|
|
177
|
+
defaultColor={defaultColors[0].value}
|
|
178
|
+
onCreate={(patch) => createProfile(patch)}
|
|
179
|
+
onSave={(id, patch) => updateProfile(id, patch)}
|
|
180
|
+
onDelete={(id) => {
|
|
181
|
+
deleteProfile(id)
|
|
182
|
+
removeProfileStats(id)
|
|
183
|
+
}}
|
|
184
|
+
onBack={() => router.back()}
|
|
185
|
+
/>
|
|
186
|
+
)
|
|
187
|
+
}
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
`useProfiles`/`useGameStats` are your own app's hooks, not exports of this package — `profiles` is
|
|
191
|
+
whatever `Profile[]` (or your own extended shape) you already keep in Redux/context/state, and
|
|
192
|
+
`createProfile`/`updateProfile`/`deleteProfile` are however you already persist it. `onBack` can be
|
|
193
|
+
anything with no arguments — `router.back()`, a `safeBack()`-style helper that no-ops with no back
|
|
194
|
+
stack behind it, `navigation.goBack()` — `ProfilesScreen` only ever calls it after flushing any
|
|
195
|
+
in-progress inline edit first.
|
|
196
|
+
|
|
91
197
|
### Redux helpers for the shared roster
|
|
92
198
|
|
|
93
199
|
The cross-app-roster sketch above is a pattern every `@tastic` game was hand-rolling on its own
|
|
94
200
|
(a roster reducer, a per-profile extension table, the initial-load decision, a foreground-resync
|
|
95
|
-
listener). These
|
|
96
|
-
plain TypeScript, no dependency on `redux`/`react-redux` themselves,
|
|
97
|
-
reducer(s) into whatever store it already has:
|
|
201
|
+
listener, a per-seat selection reducer). These five pieces are that logic extracted into reusable,
|
|
202
|
+
Redux-shaped building blocks — plain TypeScript, no dependency on `redux`/`react-redux` themselves,
|
|
203
|
+
so a host app plugs the reducer(s) into whatever store it already has:
|
|
98
204
|
|
|
99
205
|
- **`profilesActions` / `profilesReducer` / `createProfileRecord`** — the base roster as a reducer:
|
|
100
206
|
`add`/`update`/`remove`/`setAll` actions, plus `createProfileRecord(input)` to mint a complete
|
|
@@ -113,30 +219,70 @@ reducer(s) into whatever store it already has:
|
|
|
113
219
|
change to mirror it out, and `onRemoteChange` fires with a freshly-loaded roster whenever the app
|
|
114
220
|
returns to the foreground (skipped while a `syncToShared` write is still in flight, so a remote
|
|
115
221
|
refresh can't clobber a write that hasn't landed yet).
|
|
222
|
+
- **`createProfileSelectionSlice<TSeat>(namespace, defaultState)`** — a factory for the *other*
|
|
223
|
+
per-seat piece of state: which profile id each seat currently has selected (parallel to
|
|
224
|
+
`createProfileExtensionSlice` above, but keyed by seat instead of by profile id). Returns a
|
|
225
|
+
`{ actions: { select, clearProfile }, reducer }` pair over a plain `Record<TSeat, string | null>`.
|
|
226
|
+
Unlike `createProfileExtensionSlice`, the seat set can't be derived from `TSeat` alone (types
|
|
227
|
+
don't exist at runtime), so callers pass their own `defaultState` — the same literal object (e.g.
|
|
228
|
+
`{ 1: null, 2: null }`) every app already declares today. `select({ seat, profileId })` sets one
|
|
229
|
+
seat's selection directly; `clearProfile(profileId)` walks every seat and nulls out any that
|
|
230
|
+
currently points at that id — call it when a profile is deleted so no seat is left referencing a
|
|
231
|
+
ghost id. Always local, like the guest/CPU/override colors below — never part of the shared
|
|
232
|
+
App Group roster, since who's selected on *this* device is naturally per-app, per-device state.
|
|
116
233
|
|
|
117
|
-
##
|
|
234
|
+
## Per-seat color persistence
|
|
118
235
|
|
|
119
|
-
|
|
120
|
-
|
|
236
|
+
A loadout/lobby screen's color for a given seat is never just "the selected profile's saved
|
|
237
|
+
color" — a seat can be a guest (no profile selected), a CPU opponent, or a profile the player
|
|
238
|
+
manually recolored for a clash-swap without touching the profile itself. Every `@tastic` game
|
|
239
|
+
(AirHockey, BoxHockey, Pong, LightCycles, Snake) ends up needing the same three cooperating pieces
|
|
240
|
+
of state to cover all three cases. This is the established pattern every game should follow — it
|
|
241
|
+
was previously undocumented at the package level, which is exactly why two of the five apps
|
|
242
|
+
(AirHockey, Snake) didn't pick up the third piece below until after they'd already shipped without
|
|
243
|
+
it.
|
|
244
|
+
|
|
245
|
+
- **`lastGuestColor`** — a `Record<Seat, string>`: what a seat's color was the last time it was a
|
|
246
|
+
guest (no profile selected). Written only when a guest seat's color changes; a profile-selected
|
|
247
|
+
seat never reads or writes it.
|
|
248
|
+
- **`lastCpuColor`** — the same idea for the CPU slot, as a single remembered `string` rather than a
|
|
249
|
+
per-seat record (a CPU opponent only ever occupies one fixed seat in every shipped game so far).
|
|
250
|
+
- **`profileOverride`** — a `Record<Seat, string | null>`: a manual recolor or clash-swap landing on
|
|
251
|
+
a seat that currently has a profile selected. `null` means "no override — track the profile's own
|
|
252
|
+
saved color live." Cleared only when that seat's own profile selection genuinely changes, never by
|
|
253
|
+
merely leaving and returning to the screen.
|
|
254
|
+
|
|
255
|
+
None of these three are exports of this package today. `lastGuestColor`/`lastCpuColor` predate it
|
|
256
|
+
entirely, and `profileOverride` is still hand-rolled per app — typically alongside
|
|
257
|
+
`createProfileSelectionSlice`'s own selection state, in the same seat-scoped slice (see e.g.
|
|
258
|
+
BoxHockey's `src/redux/seatColorsSlice.ts`) or folded into the app's own `gameSlice.ts` when there's
|
|
259
|
+
no dedicated slice to put it in (see Snake's `src/redux/gameSlice.ts`). This section exists so a new
|
|
260
|
+
app's author reads the convention here first, rather than hand-rolling something that quietly can't
|
|
261
|
+
interop with the rest of the fleet.
|
|
262
|
+
|
|
263
|
+
The resolution order at a loadout/lobby screen, for one seat:
|
|
264
|
+
|
|
265
|
+
```ts
|
|
266
|
+
const color = profile
|
|
267
|
+
? (profileOverride[seat] ?? profile.color) // profile selected: override wins, else the profile's own color
|
|
268
|
+
: isCpu
|
|
269
|
+
? lastCpuColor // CPU slot: its own remembered color
|
|
270
|
+
: lastGuestColor[seat] // guest seat: that seat's own remembered color
|
|
121
271
|
```
|
|
122
272
|
|
|
123
|
-
|
|
124
|
-
|
|
273
|
+
`lastGuestColor`/`lastCpuColor` are only ever consulted when no profile is selected for that seat at
|
|
274
|
+
all — a profile-selected seat's guest/CPU memory is neither read nor overwritten while it's active,
|
|
275
|
+
so switching a seat back to "guest" later still recalls whatever guest color it had before a profile
|
|
276
|
+
was ever selected there.
|
|
125
277
|
|
|
126
|
-
|
|
278
|
+
## Install
|
|
127
279
|
|
|
128
280
|
```bash
|
|
129
|
-
|
|
130
|
-
npm run build
|
|
131
|
-
yalc publish
|
|
132
|
-
|
|
133
|
-
cd ../your-game
|
|
134
|
-
yalc add @tastic/profile
|
|
135
|
-
npm install
|
|
281
|
+
npm install @tastic/profile
|
|
136
282
|
```
|
|
137
283
|
|
|
138
|
-
|
|
139
|
-
|
|
284
|
+
Published to the public npm registry via tag-based CI (OIDC trusted publishing — see
|
|
285
|
+
`npm run release:patch`/`:minor`/`:major`).
|
|
140
286
|
|
|
141
287
|
## Peer dependencies
|
|
142
288
|
|
package/dist/index.d.mts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import * as react from 'react';
|
|
2
|
-
import { ReactNode, Ref } from 'react';
|
|
3
|
-
import { PopoverHost } from '@tastic/hud';
|
|
2
|
+
import { RefObject, ReactNode, Ref } from 'react';
|
|
3
|
+
import { PopoverHost, PopoverRotation } from '@tastic/hud';
|
|
4
|
+
import { View } from 'react-native';
|
|
4
5
|
|
|
5
6
|
interface Profile {
|
|
6
7
|
id: string;
|
|
@@ -18,6 +19,18 @@ interface Props {
|
|
|
18
19
|
}
|
|
19
20
|
declare function ProfileChip({ profile, size, filled }: Props): react.JSX.Element;
|
|
20
21
|
|
|
22
|
+
interface ProfilePickerAlignResult {
|
|
23
|
+
align: 'left' | 'right' | 'center';
|
|
24
|
+
verticalAlign: 'below' | 'above';
|
|
25
|
+
maxHeight: number;
|
|
26
|
+
measured: boolean;
|
|
27
|
+
triggerRef: RefObject<View | null>;
|
|
28
|
+
}
|
|
29
|
+
interface ProfilePickerContentSize {
|
|
30
|
+
width: number;
|
|
31
|
+
height: number;
|
|
32
|
+
}
|
|
33
|
+
declare function getProfilePickerContentSize(profileCount: number, hasManageRow?: boolean): ProfilePickerContentSize;
|
|
21
34
|
interface ProfilePickerProps<P extends Profile> {
|
|
22
35
|
idPrefix: string;
|
|
23
36
|
host: PopoverHost;
|
|
@@ -27,15 +40,17 @@ interface ProfilePickerProps<P extends Profile> {
|
|
|
27
40
|
color: string;
|
|
28
41
|
dark: boolean;
|
|
29
42
|
align?: 'left' | 'right' | 'center';
|
|
43
|
+
alignOverride?: ProfilePickerAlignResult;
|
|
44
|
+
rotation?: PopoverRotation;
|
|
30
45
|
guestLabel: string;
|
|
31
46
|
nullLabel?: string;
|
|
32
47
|
nullIcon?: string;
|
|
33
48
|
onSelect: (profile: P | null) => void;
|
|
34
49
|
onManage?: () => void;
|
|
35
50
|
}
|
|
36
|
-
declare function ProfilePicker<P extends Profile>({ idPrefix, host, profiles, selectedId, takenId, color, dark, align: alignOverride, guestLabel, nullLabel, nullIcon, onSelect, onManage }: ProfilePickerProps<P>): react.JSX.Element;
|
|
51
|
+
declare function ProfilePicker<P extends Profile>({ idPrefix, host, profiles, selectedId, takenId, color, dark, align: forcedAlign, alignOverride, rotation, guestLabel, nullLabel, nullIcon, onSelect, onManage }: ProfilePickerProps<P>): react.JSX.Element;
|
|
37
52
|
|
|
38
|
-
type MD3TextVariant = 'displayLarge' | 'displayMedium' | 'displaySmall' | 'headlineLarge' | 'headlineMedium' | 'headlineSmall' | 'titleLarge' | 'titleMedium' | 'titleSmall' | 'labelLarge' | 'labelMedium' | 'labelSmall' | 'bodyLarge' | 'bodyMedium' | 'bodySmall';
|
|
53
|
+
type MD3TextVariant$1 = 'displayLarge' | 'displayMedium' | 'displaySmall' | 'headlineLarge' | 'headlineMedium' | 'headlineSmall' | 'titleLarge' | 'titleMedium' | 'titleSmall' | 'labelLarge' | 'labelMedium' | 'labelSmall' | 'bodyLarge' | 'bodyMedium' | 'bodySmall';
|
|
39
54
|
interface ProfileEditPatch {
|
|
40
55
|
name: string;
|
|
41
56
|
color: string;
|
|
@@ -55,11 +70,28 @@ interface ProfilesManagerProps {
|
|
|
55
70
|
fg?: string;
|
|
56
71
|
fgMuted?: string;
|
|
57
72
|
cardBg?: string;
|
|
58
|
-
titleVariant?: MD3TextVariant;
|
|
73
|
+
titleVariant?: MD3TextVariant$1;
|
|
59
74
|
colorPreview?: (hex: string) => string;
|
|
60
75
|
}
|
|
61
76
|
declare function ProfilesManager({ profiles, defaultColor, onCreate, onSave, onDelete, headerLeft, ref, fg: fgOverride, fgMuted: fgMutedOverride, cardBg: cardBgOverride, titleVariant, colorPreview }: ProfilesManagerProps): react.JSX.Element;
|
|
62
77
|
|
|
78
|
+
type MD3TextVariant = 'displayLarge' | 'displayMedium' | 'displaySmall' | 'headlineLarge' | 'headlineMedium' | 'headlineSmall' | 'titleLarge' | 'titleMedium' | 'titleSmall' | 'labelLarge' | 'labelMedium' | 'labelSmall' | 'bodyLarge' | 'bodyMedium' | 'bodySmall';
|
|
79
|
+
interface ProfilesScreenProps {
|
|
80
|
+
profiles: Profile[];
|
|
81
|
+
defaultColor: string;
|
|
82
|
+
onCreate: (patch: ProfileEditPatch) => void;
|
|
83
|
+
onSave: (id: string, patch: ProfileEditPatch) => void;
|
|
84
|
+
onDelete: (id: string) => void;
|
|
85
|
+
onBack: () => void;
|
|
86
|
+
fg?: string;
|
|
87
|
+
fgMuted?: string;
|
|
88
|
+
bg?: string;
|
|
89
|
+
cardBg?: string;
|
|
90
|
+
titleVariant?: MD3TextVariant;
|
|
91
|
+
colorPreview?: (hex: string) => string;
|
|
92
|
+
}
|
|
93
|
+
declare function ProfilesScreen({ profiles, defaultColor, onCreate, onSave, onDelete, onBack, fg: fgOverride, fgMuted, bg: bgOverride, cardBg, titleVariant, colorPreview }: ProfilesScreenProps): react.JSX.Element;
|
|
94
|
+
|
|
63
95
|
declare const MAX_PROFILE_NAME_LENGTH = 20;
|
|
64
96
|
declare const MAX_TAG_LENGTH = 3;
|
|
65
97
|
declare function isValidTag(value: unknown): value is string;
|
|
@@ -93,6 +125,24 @@ interface ProfileExtensionSlice<TExtension> {
|
|
|
93
125
|
}
|
|
94
126
|
declare function createProfileExtensionSlice<TExtension>(namespace: string): ProfileExtensionSlice<TExtension>;
|
|
95
127
|
|
|
128
|
+
interface SelectProfilePayload<TSeat> {
|
|
129
|
+
profileId: string | null;
|
|
130
|
+
seat: TSeat;
|
|
131
|
+
}
|
|
132
|
+
interface ProfileSelectionActions<TSeat> {
|
|
133
|
+
clearProfile: ActionCreator<string>;
|
|
134
|
+
select: ActionCreator<SelectProfilePayload<TSeat>>;
|
|
135
|
+
}
|
|
136
|
+
type ProfileSelectionState<TSeat extends string | number> = Record<TSeat, string | null>;
|
|
137
|
+
interface ProfileSelectionSlice<TSeat extends string | number> {
|
|
138
|
+
actions: ProfileSelectionActions<TSeat>;
|
|
139
|
+
reducer: (state: ProfileSelectionState<TSeat> | undefined, action: {
|
|
140
|
+
type: string;
|
|
141
|
+
payload?: unknown;
|
|
142
|
+
}) => ProfileSelectionState<TSeat>;
|
|
143
|
+
}
|
|
144
|
+
declare function createProfileSelectionSlice<TSeat extends string | number>(namespace: string, defaultState: ProfileSelectionState<TSeat>, mountKey?: string): ProfileSelectionSlice<TSeat>;
|
|
145
|
+
|
|
96
146
|
interface UpdateProfilePayload {
|
|
97
147
|
id: string;
|
|
98
148
|
patch: Partial<Pick<Profile, 'name' | 'color' | 'tag'>>;
|
|
@@ -115,6 +165,33 @@ declare function createProfileRecord(input: CreateProfileInput): Profile;
|
|
|
115
165
|
|
|
116
166
|
declare function resolveInitialProfiles(groupId: string, localFallback: Profile[]): Promise<Profile[]>;
|
|
117
167
|
|
|
168
|
+
interface SetLastGuestColorPayload<TSeat> {
|
|
169
|
+
color: string;
|
|
170
|
+
seat: TSeat;
|
|
171
|
+
}
|
|
172
|
+
interface SetProfileOverridePayload<TSeat> {
|
|
173
|
+
color: string | null;
|
|
174
|
+
seat: TSeat;
|
|
175
|
+
}
|
|
176
|
+
interface SeatColorsActions<TSeat> {
|
|
177
|
+
setLastCpuColor: ActionCreator<string>;
|
|
178
|
+
setLastGuestColor: ActionCreator<SetLastGuestColorPayload<TSeat>>;
|
|
179
|
+
setProfileOverride: ActionCreator<SetProfileOverridePayload<TSeat>>;
|
|
180
|
+
}
|
|
181
|
+
type SeatColorsState<TSeat extends string | number> = {
|
|
182
|
+
lastCpuColor: string;
|
|
183
|
+
lastGuestColor: Record<TSeat, string>;
|
|
184
|
+
profileOverride: Record<TSeat, string | null>;
|
|
185
|
+
};
|
|
186
|
+
interface SeatColorsSlice<TSeat extends string | number> {
|
|
187
|
+
actions: SeatColorsActions<TSeat>;
|
|
188
|
+
reducer: (state: SeatColorsState<TSeat> | undefined, action: {
|
|
189
|
+
type: string;
|
|
190
|
+
payload?: unknown;
|
|
191
|
+
}) => SeatColorsState<TSeat>;
|
|
192
|
+
}
|
|
193
|
+
declare function createSeatColorsSlice<TSeat extends string | number>(namespace: string, defaultState: SeatColorsState<TSeat>, mountKey?: string): SeatColorsSlice<TSeat>;
|
|
194
|
+
|
|
118
195
|
interface UseSharedProfilesSyncOptions {
|
|
119
196
|
groupId: string;
|
|
120
197
|
onRemoteChange: (profiles: Profile[]) => void;
|
|
@@ -128,4 +205,4 @@ declare const isSharedProfileStoreAvailable: boolean;
|
|
|
128
205
|
declare function loadSharedProfiles(groupId: string): Promise<Profile[]>;
|
|
129
206
|
declare function saveSharedProfiles(groupId: string, profiles: Profile[]): Promise<void>;
|
|
130
207
|
|
|
131
|
-
export { type CreateProfileInput, MAX_PROFILE_NAME_LENGTH, MAX_TAG_LENGTH, type Profile, ProfileChip, type ProfileExtensionActions, type ProfileExtensionSlice, type ProfileExtensionState, ProfilePicker, ProfilesManager, type ProfilesManagerHandle, type ProfilesManagerProps, type SetProfileExtensionPayload, type UpdateProfilePayload, type UseSharedProfilesSyncOptions, type UseSharedProfilesSyncResult, createProfileExtensionSlice, createProfileRecord, isSharedProfileStoreAvailable, isValidProfile, isValidTag, loadSharedProfiles, profilesActions, profilesReducer, resolveInitialProfiles, saveSharedProfiles, useSharedProfilesSync };
|
|
208
|
+
export { type CreateProfileInput, MAX_PROFILE_NAME_LENGTH, MAX_TAG_LENGTH, type Profile, ProfileChip, type ProfileEditPatch, type ProfileExtensionActions, type ProfileExtensionSlice, type ProfileExtensionState, ProfilePicker, type ProfilePickerAlignResult, type ProfilePickerContentSize, type ProfileSelectionActions, type ProfileSelectionSlice, type ProfileSelectionState, ProfilesManager, type ProfilesManagerHandle, type ProfilesManagerProps, ProfilesScreen, type ProfilesScreenProps, type SeatColorsActions, type SeatColorsSlice, type SeatColorsState, type SelectProfilePayload, type SetLastGuestColorPayload, type SetProfileExtensionPayload, type SetProfileOverridePayload, type UpdateProfilePayload, type UseSharedProfilesSyncOptions, type UseSharedProfilesSyncResult, createProfileExtensionSlice, createProfileRecord, createProfileSelectionSlice, createSeatColorsSlice, getProfilePickerContentSize, isSharedProfileStoreAvailable, isValidProfile, isValidTag, loadSharedProfiles, profilesActions, profilesReducer, resolveInitialProfiles, saveSharedProfiles, useSharedProfilesSync };
|
package/dist/index.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import * as react from 'react';
|
|
2
|
-
import { ReactNode, Ref } from 'react';
|
|
3
|
-
import { PopoverHost } from '@tastic/hud';
|
|
2
|
+
import { RefObject, ReactNode, Ref } from 'react';
|
|
3
|
+
import { PopoverHost, PopoverRotation } from '@tastic/hud';
|
|
4
|
+
import { View } from 'react-native';
|
|
4
5
|
|
|
5
6
|
interface Profile {
|
|
6
7
|
id: string;
|
|
@@ -18,6 +19,18 @@ interface Props {
|
|
|
18
19
|
}
|
|
19
20
|
declare function ProfileChip({ profile, size, filled }: Props): react.JSX.Element;
|
|
20
21
|
|
|
22
|
+
interface ProfilePickerAlignResult {
|
|
23
|
+
align: 'left' | 'right' | 'center';
|
|
24
|
+
verticalAlign: 'below' | 'above';
|
|
25
|
+
maxHeight: number;
|
|
26
|
+
measured: boolean;
|
|
27
|
+
triggerRef: RefObject<View | null>;
|
|
28
|
+
}
|
|
29
|
+
interface ProfilePickerContentSize {
|
|
30
|
+
width: number;
|
|
31
|
+
height: number;
|
|
32
|
+
}
|
|
33
|
+
declare function getProfilePickerContentSize(profileCount: number, hasManageRow?: boolean): ProfilePickerContentSize;
|
|
21
34
|
interface ProfilePickerProps<P extends Profile> {
|
|
22
35
|
idPrefix: string;
|
|
23
36
|
host: PopoverHost;
|
|
@@ -27,15 +40,17 @@ interface ProfilePickerProps<P extends Profile> {
|
|
|
27
40
|
color: string;
|
|
28
41
|
dark: boolean;
|
|
29
42
|
align?: 'left' | 'right' | 'center';
|
|
43
|
+
alignOverride?: ProfilePickerAlignResult;
|
|
44
|
+
rotation?: PopoverRotation;
|
|
30
45
|
guestLabel: string;
|
|
31
46
|
nullLabel?: string;
|
|
32
47
|
nullIcon?: string;
|
|
33
48
|
onSelect: (profile: P | null) => void;
|
|
34
49
|
onManage?: () => void;
|
|
35
50
|
}
|
|
36
|
-
declare function ProfilePicker<P extends Profile>({ idPrefix, host, profiles, selectedId, takenId, color, dark, align: alignOverride, guestLabel, nullLabel, nullIcon, onSelect, onManage }: ProfilePickerProps<P>): react.JSX.Element;
|
|
51
|
+
declare function ProfilePicker<P extends Profile>({ idPrefix, host, profiles, selectedId, takenId, color, dark, align: forcedAlign, alignOverride, rotation, guestLabel, nullLabel, nullIcon, onSelect, onManage }: ProfilePickerProps<P>): react.JSX.Element;
|
|
37
52
|
|
|
38
|
-
type MD3TextVariant = 'displayLarge' | 'displayMedium' | 'displaySmall' | 'headlineLarge' | 'headlineMedium' | 'headlineSmall' | 'titleLarge' | 'titleMedium' | 'titleSmall' | 'labelLarge' | 'labelMedium' | 'labelSmall' | 'bodyLarge' | 'bodyMedium' | 'bodySmall';
|
|
53
|
+
type MD3TextVariant$1 = 'displayLarge' | 'displayMedium' | 'displaySmall' | 'headlineLarge' | 'headlineMedium' | 'headlineSmall' | 'titleLarge' | 'titleMedium' | 'titleSmall' | 'labelLarge' | 'labelMedium' | 'labelSmall' | 'bodyLarge' | 'bodyMedium' | 'bodySmall';
|
|
39
54
|
interface ProfileEditPatch {
|
|
40
55
|
name: string;
|
|
41
56
|
color: string;
|
|
@@ -55,11 +70,28 @@ interface ProfilesManagerProps {
|
|
|
55
70
|
fg?: string;
|
|
56
71
|
fgMuted?: string;
|
|
57
72
|
cardBg?: string;
|
|
58
|
-
titleVariant?: MD3TextVariant;
|
|
73
|
+
titleVariant?: MD3TextVariant$1;
|
|
59
74
|
colorPreview?: (hex: string) => string;
|
|
60
75
|
}
|
|
61
76
|
declare function ProfilesManager({ profiles, defaultColor, onCreate, onSave, onDelete, headerLeft, ref, fg: fgOverride, fgMuted: fgMutedOverride, cardBg: cardBgOverride, titleVariant, colorPreview }: ProfilesManagerProps): react.JSX.Element;
|
|
62
77
|
|
|
78
|
+
type MD3TextVariant = 'displayLarge' | 'displayMedium' | 'displaySmall' | 'headlineLarge' | 'headlineMedium' | 'headlineSmall' | 'titleLarge' | 'titleMedium' | 'titleSmall' | 'labelLarge' | 'labelMedium' | 'labelSmall' | 'bodyLarge' | 'bodyMedium' | 'bodySmall';
|
|
79
|
+
interface ProfilesScreenProps {
|
|
80
|
+
profiles: Profile[];
|
|
81
|
+
defaultColor: string;
|
|
82
|
+
onCreate: (patch: ProfileEditPatch) => void;
|
|
83
|
+
onSave: (id: string, patch: ProfileEditPatch) => void;
|
|
84
|
+
onDelete: (id: string) => void;
|
|
85
|
+
onBack: () => void;
|
|
86
|
+
fg?: string;
|
|
87
|
+
fgMuted?: string;
|
|
88
|
+
bg?: string;
|
|
89
|
+
cardBg?: string;
|
|
90
|
+
titleVariant?: MD3TextVariant;
|
|
91
|
+
colorPreview?: (hex: string) => string;
|
|
92
|
+
}
|
|
93
|
+
declare function ProfilesScreen({ profiles, defaultColor, onCreate, onSave, onDelete, onBack, fg: fgOverride, fgMuted, bg: bgOverride, cardBg, titleVariant, colorPreview }: ProfilesScreenProps): react.JSX.Element;
|
|
94
|
+
|
|
63
95
|
declare const MAX_PROFILE_NAME_LENGTH = 20;
|
|
64
96
|
declare const MAX_TAG_LENGTH = 3;
|
|
65
97
|
declare function isValidTag(value: unknown): value is string;
|
|
@@ -93,6 +125,24 @@ interface ProfileExtensionSlice<TExtension> {
|
|
|
93
125
|
}
|
|
94
126
|
declare function createProfileExtensionSlice<TExtension>(namespace: string): ProfileExtensionSlice<TExtension>;
|
|
95
127
|
|
|
128
|
+
interface SelectProfilePayload<TSeat> {
|
|
129
|
+
profileId: string | null;
|
|
130
|
+
seat: TSeat;
|
|
131
|
+
}
|
|
132
|
+
interface ProfileSelectionActions<TSeat> {
|
|
133
|
+
clearProfile: ActionCreator<string>;
|
|
134
|
+
select: ActionCreator<SelectProfilePayload<TSeat>>;
|
|
135
|
+
}
|
|
136
|
+
type ProfileSelectionState<TSeat extends string | number> = Record<TSeat, string | null>;
|
|
137
|
+
interface ProfileSelectionSlice<TSeat extends string | number> {
|
|
138
|
+
actions: ProfileSelectionActions<TSeat>;
|
|
139
|
+
reducer: (state: ProfileSelectionState<TSeat> | undefined, action: {
|
|
140
|
+
type: string;
|
|
141
|
+
payload?: unknown;
|
|
142
|
+
}) => ProfileSelectionState<TSeat>;
|
|
143
|
+
}
|
|
144
|
+
declare function createProfileSelectionSlice<TSeat extends string | number>(namespace: string, defaultState: ProfileSelectionState<TSeat>, mountKey?: string): ProfileSelectionSlice<TSeat>;
|
|
145
|
+
|
|
96
146
|
interface UpdateProfilePayload {
|
|
97
147
|
id: string;
|
|
98
148
|
patch: Partial<Pick<Profile, 'name' | 'color' | 'tag'>>;
|
|
@@ -115,6 +165,33 @@ declare function createProfileRecord(input: CreateProfileInput): Profile;
|
|
|
115
165
|
|
|
116
166
|
declare function resolveInitialProfiles(groupId: string, localFallback: Profile[]): Promise<Profile[]>;
|
|
117
167
|
|
|
168
|
+
interface SetLastGuestColorPayload<TSeat> {
|
|
169
|
+
color: string;
|
|
170
|
+
seat: TSeat;
|
|
171
|
+
}
|
|
172
|
+
interface SetProfileOverridePayload<TSeat> {
|
|
173
|
+
color: string | null;
|
|
174
|
+
seat: TSeat;
|
|
175
|
+
}
|
|
176
|
+
interface SeatColorsActions<TSeat> {
|
|
177
|
+
setLastCpuColor: ActionCreator<string>;
|
|
178
|
+
setLastGuestColor: ActionCreator<SetLastGuestColorPayload<TSeat>>;
|
|
179
|
+
setProfileOverride: ActionCreator<SetProfileOverridePayload<TSeat>>;
|
|
180
|
+
}
|
|
181
|
+
type SeatColorsState<TSeat extends string | number> = {
|
|
182
|
+
lastCpuColor: string;
|
|
183
|
+
lastGuestColor: Record<TSeat, string>;
|
|
184
|
+
profileOverride: Record<TSeat, string | null>;
|
|
185
|
+
};
|
|
186
|
+
interface SeatColorsSlice<TSeat extends string | number> {
|
|
187
|
+
actions: SeatColorsActions<TSeat>;
|
|
188
|
+
reducer: (state: SeatColorsState<TSeat> | undefined, action: {
|
|
189
|
+
type: string;
|
|
190
|
+
payload?: unknown;
|
|
191
|
+
}) => SeatColorsState<TSeat>;
|
|
192
|
+
}
|
|
193
|
+
declare function createSeatColorsSlice<TSeat extends string | number>(namespace: string, defaultState: SeatColorsState<TSeat>, mountKey?: string): SeatColorsSlice<TSeat>;
|
|
194
|
+
|
|
118
195
|
interface UseSharedProfilesSyncOptions {
|
|
119
196
|
groupId: string;
|
|
120
197
|
onRemoteChange: (profiles: Profile[]) => void;
|
|
@@ -128,4 +205,4 @@ declare const isSharedProfileStoreAvailable: boolean;
|
|
|
128
205
|
declare function loadSharedProfiles(groupId: string): Promise<Profile[]>;
|
|
129
206
|
declare function saveSharedProfiles(groupId: string, profiles: Profile[]): Promise<void>;
|
|
130
207
|
|
|
131
|
-
export { type CreateProfileInput, MAX_PROFILE_NAME_LENGTH, MAX_TAG_LENGTH, type Profile, ProfileChip, type ProfileExtensionActions, type ProfileExtensionSlice, type ProfileExtensionState, ProfilePicker, ProfilesManager, type ProfilesManagerHandle, type ProfilesManagerProps, type SetProfileExtensionPayload, type UpdateProfilePayload, type UseSharedProfilesSyncOptions, type UseSharedProfilesSyncResult, createProfileExtensionSlice, createProfileRecord, isSharedProfileStoreAvailable, isValidProfile, isValidTag, loadSharedProfiles, profilesActions, profilesReducer, resolveInitialProfiles, saveSharedProfiles, useSharedProfilesSync };
|
|
208
|
+
export { type CreateProfileInput, MAX_PROFILE_NAME_LENGTH, MAX_TAG_LENGTH, type Profile, ProfileChip, type ProfileEditPatch, type ProfileExtensionActions, type ProfileExtensionSlice, type ProfileExtensionState, ProfilePicker, type ProfilePickerAlignResult, type ProfilePickerContentSize, type ProfileSelectionActions, type ProfileSelectionSlice, type ProfileSelectionState, ProfilesManager, type ProfilesManagerHandle, type ProfilesManagerProps, ProfilesScreen, type ProfilesScreenProps, type SeatColorsActions, type SeatColorsSlice, type SeatColorsState, type SelectProfilePayload, type SetLastGuestColorPayload, type SetProfileExtensionPayload, type SetProfileOverridePayload, type UpdateProfilePayload, type UseSharedProfilesSyncOptions, type UseSharedProfilesSyncResult, createProfileExtensionSlice, createProfileRecord, createProfileSelectionSlice, createSeatColorsSlice, getProfilePickerContentSize, isSharedProfileStoreAvailable, isValidProfile, isValidTag, loadSharedProfiles, profilesActions, profilesReducer, resolveInitialProfiles, saveSharedProfiles, useSharedProfilesSync };
|
package/dist/index.js
CHANGED
|
@@ -25,8 +25,12 @@ __export(index_exports, {
|
|
|
25
25
|
ProfileChip: () => ProfileChip,
|
|
26
26
|
ProfilePicker: () => ProfilePicker,
|
|
27
27
|
ProfilesManager: () => ProfilesManager,
|
|
28
|
+
ProfilesScreen: () => ProfilesScreen,
|
|
28
29
|
createProfileExtensionSlice: () => createProfileExtensionSlice,
|
|
29
30
|
createProfileRecord: () => createProfileRecord,
|
|
31
|
+
createProfileSelectionSlice: () => createProfileSelectionSlice,
|
|
32
|
+
createSeatColorsSlice: () => createSeatColorsSlice,
|
|
33
|
+
getProfilePickerContentSize: () => getProfilePickerContentSize,
|
|
30
34
|
isSharedProfileStoreAvailable: () => isSharedProfileStoreAvailable,
|
|
31
35
|
isValidProfile: () => isValidProfile,
|
|
32
36
|
isValidTag: () => isValidTag,
|
|
@@ -73,17 +77,22 @@ var TRIGGER_HEIGHT = 28;
|
|
|
73
77
|
var POPOVER_WIDTH = 220;
|
|
74
78
|
var ROW_HEIGHT = 36;
|
|
75
79
|
var LIST_PADDING = 8;
|
|
76
|
-
function
|
|
80
|
+
function getProfilePickerContentSize(profileCount, hasManageRow = false) {
|
|
81
|
+
const rowCount = profileCount + 1 + (hasManageRow ? 1 : 0);
|
|
82
|
+
return { width: POPOVER_WIDTH, height: LIST_PADDING * 2 + rowCount * ROW_HEIGHT };
|
|
83
|
+
}
|
|
84
|
+
function ProfilePicker({ idPrefix, host, profiles, selectedId, takenId, color, dark, align: forcedAlign, alignOverride, rotation = 0, guestLabel, nullLabel = "Player", nullIcon = "account-off-outline", onSelect, onManage }) {
|
|
77
85
|
const id = `${idPrefix}-profile`;
|
|
78
86
|
const menuBg = dark ? "#000000" : "#FFFFFF";
|
|
79
87
|
const fg = dark ? "#FFFFFF" : "#000000";
|
|
80
88
|
const open = host.openId === id;
|
|
81
89
|
const selectedProfile = profiles.find((p) => p.id === selectedId) ?? null;
|
|
82
90
|
const sortedProfiles = (0, import_react.useMemo)(() => [...profiles].sort((a, b) => a.name.localeCompare(b.name)), [profiles]);
|
|
83
|
-
const
|
|
84
|
-
const
|
|
85
|
-
const
|
|
86
|
-
const
|
|
91
|
+
const { width: contentWidth, height: contentHeight } = getProfilePickerContentSize(profiles.length, !!onManage);
|
|
92
|
+
const auto = (0, import_hud.useAutoAlign)(open, contentWidth, contentHeight, rotation);
|
|
93
|
+
const placement = alignOverride ?? auto;
|
|
94
|
+
const { maxHeight, measured, triggerRef, verticalAlign } = placement;
|
|
95
|
+
const align = forcedAlign ?? placement.align;
|
|
87
96
|
const handleSelectGuest = () => {
|
|
88
97
|
onSelect(null);
|
|
89
98
|
host.close();
|
|
@@ -514,6 +523,29 @@ var styles3 = import_react_native3.StyleSheet.create({
|
|
|
514
523
|
}
|
|
515
524
|
});
|
|
516
525
|
|
|
526
|
+
// src/ProfilesScreen.tsx
|
|
527
|
+
var import_auto_paper4 = require("@rific/auto-paper");
|
|
528
|
+
var import_feedback_press3 = require("@rific/feedback-press");
|
|
529
|
+
var import_react3 = require("react");
|
|
530
|
+
var import_react_native4 = require("react-native");
|
|
531
|
+
var import_jsx_runtime4 = require("react/jsx-runtime");
|
|
532
|
+
function ProfilesScreen({ profiles, defaultColor, onCreate, onSave, onDelete, onBack, fg: fgOverride, fgMuted, bg: bgOverride, cardBg, titleVariant, colorPreview }) {
|
|
533
|
+
const { dark } = (0, import_auto_paper4.useAutoPaperTheme)();
|
|
534
|
+
const managerRef = (0, import_react3.useRef)(null);
|
|
535
|
+
const fg = fgOverride ?? (dark ? "#FFFFFF" : "#000000");
|
|
536
|
+
const bg = bgOverride ?? (dark ? "#000000" : "#FFFFFF");
|
|
537
|
+
const handleBack = () => {
|
|
538
|
+
managerRef.current?.commitPendingEdit();
|
|
539
|
+
onBack();
|
|
540
|
+
};
|
|
541
|
+
return /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(import_react_native4.View, { style: [styles4.container, { backgroundColor: bg }], children: /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(ProfilesManager, { ref: managerRef, profiles, defaultColor, onCreate, onSave, onDelete, fg, fgMuted, cardBg, titleVariant, colorPreview, headerLeft: /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(import_feedback_press3.IconButton, { icon: "arrow-left", iconColor: fg, size: 24, onPress: handleBack, accessibilityLabel: "Back" }) }) });
|
|
542
|
+
}
|
|
543
|
+
var styles4 = import_react_native4.StyleSheet.create({
|
|
544
|
+
container: {
|
|
545
|
+
flex: 1
|
|
546
|
+
}
|
|
547
|
+
});
|
|
548
|
+
|
|
517
549
|
// src/redux/createActionCreator.ts
|
|
518
550
|
function createActionCreator(type) {
|
|
519
551
|
const creator = ((payload) => ({ payload, type }));
|
|
@@ -539,6 +571,29 @@ function createProfileExtensionSlice(namespace) {
|
|
|
539
571
|
return { actions: { remove: remove2, set }, reducer };
|
|
540
572
|
}
|
|
541
573
|
|
|
574
|
+
// src/redux/profileSelectionSlice.ts
|
|
575
|
+
var REHYDRATE = "persist/REHYDRATE";
|
|
576
|
+
function createProfileSelectionSlice(namespace, defaultState, mountKey = "profileSelection") {
|
|
577
|
+
const select = createActionCreator(`profileSelection/${namespace}/select`);
|
|
578
|
+
const clearProfile = createActionCreator(`profileSelection/${namespace}/clearProfile`);
|
|
579
|
+
function reducer(state = defaultState, action) {
|
|
580
|
+
if (action.type === REHYDRATE) {
|
|
581
|
+
const persisted = action.payload?.[mountKey];
|
|
582
|
+
return { ...defaultState, ...state, ...persisted };
|
|
583
|
+
}
|
|
584
|
+
if (select.match(action)) return { ...state, [action.payload.seat]: action.payload.profileId };
|
|
585
|
+
if (clearProfile.match(action)) {
|
|
586
|
+
const next = { ...state };
|
|
587
|
+
for (const seat of Object.keys(state)) {
|
|
588
|
+
if (next[seat] === action.payload) next[seat] = null;
|
|
589
|
+
}
|
|
590
|
+
return next;
|
|
591
|
+
}
|
|
592
|
+
return state;
|
|
593
|
+
}
|
|
594
|
+
return { actions: { clearProfile, select }, reducer };
|
|
595
|
+
}
|
|
596
|
+
|
|
542
597
|
// src/redux/profilesSlice.ts
|
|
543
598
|
var setAll = createActionCreator("profiles/setAll");
|
|
544
599
|
var add = createActionCreator("profiles/add");
|
|
@@ -609,19 +664,38 @@ async function resolveInitialProfiles(groupId, localFallback) {
|
|
|
609
664
|
return shared;
|
|
610
665
|
}
|
|
611
666
|
|
|
667
|
+
// src/redux/seatColorsSlice.ts
|
|
668
|
+
var REHYDRATE2 = "persist/REHYDRATE";
|
|
669
|
+
function createSeatColorsSlice(namespace, defaultState, mountKey = "seatColors") {
|
|
670
|
+
const setLastCpuColor = createActionCreator(`seatColors/${namespace}/setLastCpuColor`);
|
|
671
|
+
const setLastGuestColor = createActionCreator(`seatColors/${namespace}/setLastGuestColor`);
|
|
672
|
+
const setProfileOverride = createActionCreator(`seatColors/${namespace}/setProfileOverride`);
|
|
673
|
+
function reducer(state = defaultState, action) {
|
|
674
|
+
if (action.type === REHYDRATE2) {
|
|
675
|
+
const persisted = action.payload?.[mountKey];
|
|
676
|
+
return { ...defaultState, ...state, ...persisted };
|
|
677
|
+
}
|
|
678
|
+
if (setLastCpuColor.match(action)) return { ...state, lastCpuColor: action.payload };
|
|
679
|
+
if (setLastGuestColor.match(action)) return { ...state, lastGuestColor: { ...state.lastGuestColor, [action.payload.seat]: action.payload.color } };
|
|
680
|
+
if (setProfileOverride.match(action)) return { ...state, profileOverride: { ...state.profileOverride, [action.payload.seat]: action.payload.color } };
|
|
681
|
+
return state;
|
|
682
|
+
}
|
|
683
|
+
return { actions: { setLastCpuColor, setLastGuestColor, setProfileOverride }, reducer };
|
|
684
|
+
}
|
|
685
|
+
|
|
612
686
|
// src/redux/useSharedProfilesSync.ts
|
|
613
|
-
var
|
|
614
|
-
var
|
|
687
|
+
var import_react4 = require("react");
|
|
688
|
+
var import_react_native5 = require("react-native");
|
|
615
689
|
function useSharedProfilesSync({ groupId, onRemoteChange }) {
|
|
616
|
-
const pendingWriteRef = (0,
|
|
617
|
-
const onRemoteChangeRef = (0,
|
|
618
|
-
(0,
|
|
690
|
+
const pendingWriteRef = (0, import_react4.useRef)(false);
|
|
691
|
+
const onRemoteChangeRef = (0, import_react4.useRef)(onRemoteChange);
|
|
692
|
+
(0, import_react4.useEffect)(() => {
|
|
619
693
|
onRemoteChangeRef.current = onRemoteChange;
|
|
620
694
|
}, [onRemoteChange]);
|
|
621
|
-
(0,
|
|
695
|
+
(0, import_react4.useEffect)(() => {
|
|
622
696
|
if (!isSharedProfileStoreAvailable) return;
|
|
623
697
|
let cancelled = false;
|
|
624
|
-
const sub =
|
|
698
|
+
const sub = import_react_native5.AppState.addEventListener("change", (next) => {
|
|
625
699
|
if (next !== "active" || pendingWriteRef.current) return;
|
|
626
700
|
loadSharedProfiles(groupId).then((shared) => {
|
|
627
701
|
if (!cancelled) onRemoteChangeRef.current(shared);
|
|
@@ -632,7 +706,7 @@ function useSharedProfilesSync({ groupId, onRemoteChange }) {
|
|
|
632
706
|
sub.remove();
|
|
633
707
|
};
|
|
634
708
|
}, [groupId]);
|
|
635
|
-
const syncToShared = (0,
|
|
709
|
+
const syncToShared = (0, import_react4.useCallback)(
|
|
636
710
|
(next) => {
|
|
637
711
|
if (!isSharedProfileStoreAvailable) return;
|
|
638
712
|
pendingWriteRef.current = true;
|
|
@@ -652,8 +726,12 @@ function useSharedProfilesSync({ groupId, onRemoteChange }) {
|
|
|
652
726
|
ProfileChip,
|
|
653
727
|
ProfilePicker,
|
|
654
728
|
ProfilesManager,
|
|
729
|
+
ProfilesScreen,
|
|
655
730
|
createProfileExtensionSlice,
|
|
656
731
|
createProfileRecord,
|
|
732
|
+
createProfileSelectionSlice,
|
|
733
|
+
createSeatColorsSlice,
|
|
734
|
+
getProfilePickerContentSize,
|
|
657
735
|
isSharedProfileStoreAvailable,
|
|
658
736
|
isValidProfile,
|
|
659
737
|
isValidTag,
|
package/dist/index.mjs
CHANGED
|
@@ -39,17 +39,22 @@ var TRIGGER_HEIGHT = 28;
|
|
|
39
39
|
var POPOVER_WIDTH = 220;
|
|
40
40
|
var ROW_HEIGHT = 36;
|
|
41
41
|
var LIST_PADDING = 8;
|
|
42
|
-
function
|
|
42
|
+
function getProfilePickerContentSize(profileCount, hasManageRow = false) {
|
|
43
|
+
const rowCount = profileCount + 1 + (hasManageRow ? 1 : 0);
|
|
44
|
+
return { width: POPOVER_WIDTH, height: LIST_PADDING * 2 + rowCount * ROW_HEIGHT };
|
|
45
|
+
}
|
|
46
|
+
function ProfilePicker({ idPrefix, host, profiles, selectedId, takenId, color, dark, align: forcedAlign, alignOverride, rotation = 0, guestLabel, nullLabel = "Player", nullIcon = "account-off-outline", onSelect, onManage }) {
|
|
43
47
|
const id = `${idPrefix}-profile`;
|
|
44
48
|
const menuBg = dark ? "#000000" : "#FFFFFF";
|
|
45
49
|
const fg = dark ? "#FFFFFF" : "#000000";
|
|
46
50
|
const open = host.openId === id;
|
|
47
51
|
const selectedProfile = profiles.find((p) => p.id === selectedId) ?? null;
|
|
48
52
|
const sortedProfiles = useMemo(() => [...profiles].sort((a, b) => a.name.localeCompare(b.name)), [profiles]);
|
|
49
|
-
const
|
|
50
|
-
const
|
|
51
|
-
const
|
|
52
|
-
const
|
|
53
|
+
const { width: contentWidth, height: contentHeight } = getProfilePickerContentSize(profiles.length, !!onManage);
|
|
54
|
+
const auto = useAutoAlign(open, contentWidth, contentHeight, rotation);
|
|
55
|
+
const placement = alignOverride ?? auto;
|
|
56
|
+
const { maxHeight, measured, triggerRef, verticalAlign } = placement;
|
|
57
|
+
const align = forcedAlign ?? placement.align;
|
|
53
58
|
const handleSelectGuest = () => {
|
|
54
59
|
onSelect(null);
|
|
55
60
|
host.close();
|
|
@@ -480,6 +485,29 @@ var styles3 = StyleSheet3.create({
|
|
|
480
485
|
}
|
|
481
486
|
});
|
|
482
487
|
|
|
488
|
+
// src/ProfilesScreen.tsx
|
|
489
|
+
import { useAutoPaperTheme as useAutoPaperTheme2 } from "@rific/auto-paper";
|
|
490
|
+
import { IconButton } from "@rific/feedback-press";
|
|
491
|
+
import { useRef as useRef2 } from "react";
|
|
492
|
+
import { StyleSheet as StyleSheet4, View as View4 } from "react-native";
|
|
493
|
+
import { jsx as jsx4 } from "react/jsx-runtime";
|
|
494
|
+
function ProfilesScreen({ profiles, defaultColor, onCreate, onSave, onDelete, onBack, fg: fgOverride, fgMuted, bg: bgOverride, cardBg, titleVariant, colorPreview }) {
|
|
495
|
+
const { dark } = useAutoPaperTheme2();
|
|
496
|
+
const managerRef = useRef2(null);
|
|
497
|
+
const fg = fgOverride ?? (dark ? "#FFFFFF" : "#000000");
|
|
498
|
+
const bg = bgOverride ?? (dark ? "#000000" : "#FFFFFF");
|
|
499
|
+
const handleBack = () => {
|
|
500
|
+
managerRef.current?.commitPendingEdit();
|
|
501
|
+
onBack();
|
|
502
|
+
};
|
|
503
|
+
return /* @__PURE__ */ jsx4(View4, { style: [styles4.container, { backgroundColor: bg }], children: /* @__PURE__ */ jsx4(ProfilesManager, { ref: managerRef, profiles, defaultColor, onCreate, onSave, onDelete, fg, fgMuted, cardBg, titleVariant, colorPreview, headerLeft: /* @__PURE__ */ jsx4(IconButton, { icon: "arrow-left", iconColor: fg, size: 24, onPress: handleBack, accessibilityLabel: "Back" }) }) });
|
|
504
|
+
}
|
|
505
|
+
var styles4 = StyleSheet4.create({
|
|
506
|
+
container: {
|
|
507
|
+
flex: 1
|
|
508
|
+
}
|
|
509
|
+
});
|
|
510
|
+
|
|
483
511
|
// src/redux/createActionCreator.ts
|
|
484
512
|
function createActionCreator(type) {
|
|
485
513
|
const creator = ((payload) => ({ payload, type }));
|
|
@@ -505,6 +533,29 @@ function createProfileExtensionSlice(namespace) {
|
|
|
505
533
|
return { actions: { remove: remove2, set }, reducer };
|
|
506
534
|
}
|
|
507
535
|
|
|
536
|
+
// src/redux/profileSelectionSlice.ts
|
|
537
|
+
var REHYDRATE = "persist/REHYDRATE";
|
|
538
|
+
function createProfileSelectionSlice(namespace, defaultState, mountKey = "profileSelection") {
|
|
539
|
+
const select = createActionCreator(`profileSelection/${namespace}/select`);
|
|
540
|
+
const clearProfile = createActionCreator(`profileSelection/${namespace}/clearProfile`);
|
|
541
|
+
function reducer(state = defaultState, action) {
|
|
542
|
+
if (action.type === REHYDRATE) {
|
|
543
|
+
const persisted = action.payload?.[mountKey];
|
|
544
|
+
return { ...defaultState, ...state, ...persisted };
|
|
545
|
+
}
|
|
546
|
+
if (select.match(action)) return { ...state, [action.payload.seat]: action.payload.profileId };
|
|
547
|
+
if (clearProfile.match(action)) {
|
|
548
|
+
const next = { ...state };
|
|
549
|
+
for (const seat of Object.keys(state)) {
|
|
550
|
+
if (next[seat] === action.payload) next[seat] = null;
|
|
551
|
+
}
|
|
552
|
+
return next;
|
|
553
|
+
}
|
|
554
|
+
return state;
|
|
555
|
+
}
|
|
556
|
+
return { actions: { clearProfile, select }, reducer };
|
|
557
|
+
}
|
|
558
|
+
|
|
508
559
|
// src/redux/profilesSlice.ts
|
|
509
560
|
var setAll = createActionCreator("profiles/setAll");
|
|
510
561
|
var add = createActionCreator("profiles/add");
|
|
@@ -575,12 +626,31 @@ async function resolveInitialProfiles(groupId, localFallback) {
|
|
|
575
626
|
return shared;
|
|
576
627
|
}
|
|
577
628
|
|
|
629
|
+
// src/redux/seatColorsSlice.ts
|
|
630
|
+
var REHYDRATE2 = "persist/REHYDRATE";
|
|
631
|
+
function createSeatColorsSlice(namespace, defaultState, mountKey = "seatColors") {
|
|
632
|
+
const setLastCpuColor = createActionCreator(`seatColors/${namespace}/setLastCpuColor`);
|
|
633
|
+
const setLastGuestColor = createActionCreator(`seatColors/${namespace}/setLastGuestColor`);
|
|
634
|
+
const setProfileOverride = createActionCreator(`seatColors/${namespace}/setProfileOverride`);
|
|
635
|
+
function reducer(state = defaultState, action) {
|
|
636
|
+
if (action.type === REHYDRATE2) {
|
|
637
|
+
const persisted = action.payload?.[mountKey];
|
|
638
|
+
return { ...defaultState, ...state, ...persisted };
|
|
639
|
+
}
|
|
640
|
+
if (setLastCpuColor.match(action)) return { ...state, lastCpuColor: action.payload };
|
|
641
|
+
if (setLastGuestColor.match(action)) return { ...state, lastGuestColor: { ...state.lastGuestColor, [action.payload.seat]: action.payload.color } };
|
|
642
|
+
if (setProfileOverride.match(action)) return { ...state, profileOverride: { ...state.profileOverride, [action.payload.seat]: action.payload.color } };
|
|
643
|
+
return state;
|
|
644
|
+
}
|
|
645
|
+
return { actions: { setLastCpuColor, setLastGuestColor, setProfileOverride }, reducer };
|
|
646
|
+
}
|
|
647
|
+
|
|
578
648
|
// src/redux/useSharedProfilesSync.ts
|
|
579
|
-
import { useCallback as useCallback2, useEffect as useEffect2, useRef as
|
|
649
|
+
import { useCallback as useCallback2, useEffect as useEffect2, useRef as useRef3 } from "react";
|
|
580
650
|
import { AppState } from "react-native";
|
|
581
651
|
function useSharedProfilesSync({ groupId, onRemoteChange }) {
|
|
582
|
-
const pendingWriteRef =
|
|
583
|
-
const onRemoteChangeRef =
|
|
652
|
+
const pendingWriteRef = useRef3(false);
|
|
653
|
+
const onRemoteChangeRef = useRef3(onRemoteChange);
|
|
584
654
|
useEffect2(() => {
|
|
585
655
|
onRemoteChangeRef.current = onRemoteChange;
|
|
586
656
|
}, [onRemoteChange]);
|
|
@@ -617,8 +687,12 @@ export {
|
|
|
617
687
|
ProfileChip,
|
|
618
688
|
ProfilePicker,
|
|
619
689
|
ProfilesManager,
|
|
690
|
+
ProfilesScreen,
|
|
620
691
|
createProfileExtensionSlice,
|
|
621
692
|
createProfileRecord,
|
|
693
|
+
createProfileSelectionSlice,
|
|
694
|
+
createSeatColorsSlice,
|
|
695
|
+
getProfilePickerContentSize,
|
|
622
696
|
isSharedProfileStoreAvailable,
|
|
623
697
|
isValidProfile,
|
|
624
698
|
isValidTag,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tastic/profile",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
4
4
|
"description": "Player profile identity for local-multiplayer React Native games — a saved name/color/tag a player selects per seat, with a picker, an inline-editable roster screen, and a reusable identity badge, deliberately blind to any app-specific fields (control scheme, stats) an app layers on top",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"react-native",
|
|
@@ -63,15 +63,15 @@
|
|
|
63
63
|
"prettier": "@infinitetoken/eslint-config/prettier",
|
|
64
64
|
"devDependencies": {
|
|
65
65
|
"@infinitetoken/eslint-config": "0.2.0",
|
|
66
|
-
"@infinitetoken/jest-config": "^0.
|
|
67
|
-
"@infinitetoken/tsconfig": "^0.
|
|
68
|
-
"@rific/auto-paper": "^0.
|
|
69
|
-
"@rific/core": "^0.
|
|
70
|
-
"@rific/feedback-press": "^0.
|
|
66
|
+
"@infinitetoken/jest-config": "^0.4.0",
|
|
67
|
+
"@infinitetoken/tsconfig": "^0.5.0",
|
|
68
|
+
"@rific/auto-paper": "^0.12.0",
|
|
69
|
+
"@rific/core": "^0.2.0",
|
|
70
|
+
"@rific/feedback-press": "^0.12.0",
|
|
71
71
|
"@rific/focus-chain": "^0.4.7",
|
|
72
|
-
"@rific/toaster": "^0.
|
|
73
|
-
"@tastic/core": "^0.
|
|
74
|
-
"@tastic/hud": "^0.
|
|
72
|
+
"@rific/toaster": "^0.7.0",
|
|
73
|
+
"@tastic/core": "^0.8.0",
|
|
74
|
+
"@tastic/hud": "^0.12.0",
|
|
75
75
|
"@testing-library/dom": "^10.4.1",
|
|
76
76
|
"@testing-library/react": "^16.3.2",
|
|
77
77
|
"@types/jest": "^30.0.0",
|
|
@@ -97,7 +97,7 @@
|
|
|
97
97
|
"@rific/focus-chain": ">=0.4.0",
|
|
98
98
|
"@rific/toaster": ">=0.4.0",
|
|
99
99
|
"@tastic/core": ">=0.1.0",
|
|
100
|
-
"@tastic/hud": ">=0.
|
|
100
|
+
"@tastic/hud": ">=0.8.0",
|
|
101
101
|
"expo-modules-core": ">=1.0.0",
|
|
102
102
|
"react": ">=19.0.0",
|
|
103
103
|
"react-native": ">=0.76.0",
|
package/src/ProfilePicker.tsx
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { getContrastColor } from '@rific/auto-paper'
|
|
2
2
|
import { TouchableRipple } from '@rific/feedback-press'
|
|
3
|
-
import { PopoverBody, PopoverHost, useAutoAlign } from '@tastic/hud'
|
|
4
|
-
import { useMemo } from 'react'
|
|
3
|
+
import { PopoverBody, PopoverHost, PopoverRotation, useAutoAlign } from '@tastic/hud'
|
|
4
|
+
import { RefObject, useMemo } from 'react'
|
|
5
5
|
import { ScrollView, StyleSheet, View } from 'react-native'
|
|
6
6
|
import { Icon, Text } from 'react-native-paper'
|
|
7
7
|
|
|
@@ -13,6 +13,39 @@ const POPOVER_WIDTH = 220
|
|
|
13
13
|
const ROW_HEIGHT = 36
|
|
14
14
|
const LIST_PADDING = 8
|
|
15
15
|
|
|
16
|
+
// A complete placement decision for the popover: which way it opens, how tall it may grow, whether
|
|
17
|
+
// this open's measurement has landed yet, and the ref attached to the measured trigger.
|
|
18
|
+
// Structurally identical to @tastic/hud's own `AlignResult` (exported from hud >= 0.12.0, and the
|
|
19
|
+
// same shape `useAutoAlign` itself returns) — declared here rather than imported so this package's
|
|
20
|
+
// public types don't start requiring a newer @tastic/hud than its peer range already admits. A
|
|
21
|
+
// hud `AlignResult` (e.g. from `useZoneClampedAlign`) is assignable to this as-is.
|
|
22
|
+
export interface ProfilePickerAlignResult {
|
|
23
|
+
align: 'left' | 'right' | 'center'
|
|
24
|
+
verticalAlign: 'below' | 'above'
|
|
25
|
+
maxHeight: number
|
|
26
|
+
measured: boolean
|
|
27
|
+
triggerRef: RefObject<View | null>
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface ProfilePickerContentSize {
|
|
31
|
+
width: number
|
|
32
|
+
height: number
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// The popover's content footprint for a given roster size: always POPOVER_WIDTH wide, tall enough
|
|
36
|
+
// for the null/"guest" row + one row per profile + (when `hasManageRow`, i.e. when the picker is
|
|
37
|
+
// given an `onManage`) the trailing Manage row, plus the list's own padding. This is the size
|
|
38
|
+
// ProfilePicker hands to `useAutoAlign` — the ONE formula, used by the component itself (it leaves
|
|
39
|
+
// out the menu's 2pt border and the Manage divider, ~13pt in all, so treat it as a placement
|
|
40
|
+
// estimate, not a pixel-exact measurement) — exported
|
|
41
|
+
// so a host supplying its own placement through `alignOverride` can size that placement hook (its
|
|
42
|
+
// own useAutoAlign/useZoneClampedAlign call) to match the popover it is actually positioning,
|
|
43
|
+
// instead of copying these constants and drifting whenever they change here.
|
|
44
|
+
export function getProfilePickerContentSize(profileCount: number, hasManageRow = false): ProfilePickerContentSize {
|
|
45
|
+
const rowCount = profileCount + 1 + (hasManageRow ? 1 : 0)
|
|
46
|
+
return { width: POPOVER_WIDTH, height: LIST_PADDING * 2 + rowCount * ROW_HEIGHT }
|
|
47
|
+
}
|
|
48
|
+
|
|
16
49
|
interface ProfilePickerProps<P extends Profile> {
|
|
17
50
|
// Namespaces this trigger's id — pass something unique per instance whenever more than one of
|
|
18
51
|
// these shares a PopoverHost (e.g. one per seat).
|
|
@@ -31,7 +64,30 @@ interface ProfilePickerProps<P extends Profile> {
|
|
|
31
64
|
// trigger reads as one identity with whatever it's paired with.
|
|
32
65
|
color: string
|
|
33
66
|
dark: boolean
|
|
67
|
+
// Forces a specific horizontal alignment, beating whatever the placement (the automatic
|
|
68
|
+
// measurement, or `alignOverride` below) would have picked. Omit to let placement decide.
|
|
34
69
|
align?: 'left' | 'right' | 'center'
|
|
70
|
+
// Substitutes the popover's whole placement — horizontal/vertical alignment, max height, the
|
|
71
|
+
// `measured` gate and the trigger ref — for the automatic `useAutoAlign` one, for a host whose
|
|
72
|
+
// trigger sits somewhere that hook's plain window-relative measurement gets wrong. The motivating
|
|
73
|
+
// case is a FAKE-rotated frame (a View turned +-90deg while the OS stays portrait-locked):
|
|
74
|
+
// `measureInWindow` reports a rect in true screen space, `useAutoAlign` compares it against the
|
|
75
|
+
// raw portrait window, and so measures along the wrong axes — flipping the list upward off-screen
|
|
76
|
+
// or running it past the frame's bottom. The host runs its own rotation-aware placement hook
|
|
77
|
+
// (sized with `getProfilePickerContentSize`, passing `open` from its own `host.openId === ...`
|
|
78
|
+
// check) and hands the result in here; `align` above still wins over this result's own `align`.
|
|
79
|
+
// The result's `triggerRef` is the one attached to the measured trigger, so the hook that
|
|
80
|
+
// produced it is measuring the right element. Omit for the default automatic placement.
|
|
81
|
+
alignOverride?: ProfilePickerAlignResult
|
|
82
|
+
// Rotation of an ancestor frame turned by a fake landscape (or equivalent), forwarded to the
|
|
83
|
+
// automatic `useAutoAlign` measurement — the same seam @tastic/hud's InlineColorPicker/
|
|
84
|
+
// SectionedDropdown expose (needs @tastic/hud >= 0.8.0; older versions ignore it). Defaults to 0
|
|
85
|
+
// (measurement used as-is), which is exactly how this picker behaved before this prop existed.
|
|
86
|
+
// Deliberately NOT read from an ambient provider the way hud's own pickers do: this component
|
|
87
|
+
// never touches @tastic/core at runtime, and an ambient read would silently change placement for
|
|
88
|
+
// every existing caller already living under a rotation provider. Ignored when `alignOverride` is
|
|
89
|
+
// supplied (the override replaces the placement this feeds).
|
|
90
|
+
rotation?: PopoverRotation
|
|
35
91
|
// Trigger's own idle-state text (uppercased here regardless of casing passed in) — shown until a
|
|
36
92
|
// profile is selected.
|
|
37
93
|
guestLabel: string
|
|
@@ -63,7 +119,7 @@ interface ProfilePickerProps<P extends Profile> {
|
|
|
63
119
|
// it keeps this popover from needing its own destructive-delete confirmation. Fully prop-driven —
|
|
64
120
|
// no internal profile-storage access of its own, so the host app stays the one place that reads
|
|
65
121
|
// its own persistence hook.
|
|
66
|
-
export function ProfilePicker<P extends Profile>({ idPrefix, host, profiles, selectedId, takenId, color, dark, align: alignOverride, guestLabel, nullLabel = 'Player', nullIcon = 'account-off-outline', onSelect, onManage }: ProfilePickerProps<P>) {
|
|
122
|
+
export function ProfilePicker<P extends Profile>({ idPrefix, host, profiles, selectedId, takenId, color, dark, align: forcedAlign, alignOverride, rotation = 0, guestLabel, nullLabel = 'Player', nullIcon = 'account-off-outline', onSelect, onManage }: ProfilePickerProps<P>) {
|
|
67
123
|
const id = `${idPrefix}-profile`
|
|
68
124
|
const menuBg = dark ? '#000000' : '#FFFFFF'
|
|
69
125
|
const fg = dark ? '#FFFFFF' : '#000000'
|
|
@@ -74,10 +130,14 @@ export function ProfilePicker<P extends Profile>({ idPrefix, host, profiles, sel
|
|
|
74
130
|
// most from staying scannable once there are more than a handful of saved profiles.
|
|
75
131
|
const sortedProfiles = useMemo(() => [...profiles].sort((a, b) => a.name.localeCompare(b.name)), [profiles])
|
|
76
132
|
// Null row + each saved profile + (if passed) the manage row.
|
|
77
|
-
const
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
133
|
+
const { width: contentWidth, height: contentHeight } = getProfilePickerContentSize(profiles.length, !!onManage)
|
|
134
|
+
// Always called, even when `alignOverride` is supplied and this result goes unused — hooks can't
|
|
135
|
+
// be conditional (see @tastic/hud's InlineColorPicker for the identical pattern). With an
|
|
136
|
+
// override its triggerRef is never attached, so this hook's own measurement is a no-op.
|
|
137
|
+
const auto = useAutoAlign(open, contentWidth, contentHeight, rotation)
|
|
138
|
+
const placement = alignOverride ?? auto
|
|
139
|
+
const { maxHeight, measured, triggerRef, verticalAlign } = placement
|
|
140
|
+
const align = forcedAlign ?? placement.align
|
|
81
141
|
|
|
82
142
|
const handleSelectGuest = () => {
|
|
83
143
|
onSelect(null)
|
package/src/ProfilesManager.tsx
CHANGED
|
@@ -21,7 +21,10 @@ const CHIP_SIZE = 40
|
|
|
21
21
|
// as @tastic/hud's BaseStatsScreen does for its own identical titleVariant prop.
|
|
22
22
|
type MD3TextVariant = 'displayLarge' | 'displayMedium' | 'displaySmall' | 'headlineLarge' | 'headlineMedium' | 'headlineSmall' | 'titleLarge' | 'titleMedium' | 'titleSmall' | 'labelLarge' | 'labelMedium' | 'labelSmall' | 'bodyLarge' | 'bodyMedium' | 'bodySmall'
|
|
23
23
|
|
|
24
|
-
|
|
24
|
+
// Exported (not just used internally) so a host composing a screen around this component — e.g.
|
|
25
|
+
// @tastic/profile's own ProfilesScreen — can reference this exact shape in its own onCreate/onSave
|
|
26
|
+
// prop types without redeclaring it.
|
|
27
|
+
export interface ProfileEditPatch {
|
|
25
28
|
name: string
|
|
26
29
|
color: string
|
|
27
30
|
tag: string
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { useAutoPaperTheme } from '@rific/auto-paper'
|
|
2
|
+
import { IconButton } from '@rific/feedback-press'
|
|
3
|
+
import { useRef } from 'react'
|
|
4
|
+
import { StyleSheet, View } from 'react-native'
|
|
5
|
+
|
|
6
|
+
import { ProfileEditPatch, ProfilesManager, ProfilesManagerHandle } from './ProfilesManager'
|
|
7
|
+
import { Profile } from './types'
|
|
8
|
+
|
|
9
|
+
// react-native-paper's own MD3 Text variant names — mirrored locally rather than importing its
|
|
10
|
+
// internal VariantProp/MD3TypescaleKey (not exported from the package's public entry point), same
|
|
11
|
+
// as ProfilesManager's own identical titleVariant prop does.
|
|
12
|
+
type MD3TextVariant = 'displayLarge' | 'displayMedium' | 'displaySmall' | 'headlineLarge' | 'headlineMedium' | 'headlineSmall' | 'titleLarge' | 'titleMedium' | 'titleSmall' | 'labelLarge' | 'labelMedium' | 'labelSmall' | 'bodyLarge' | 'bodyMedium' | 'bodySmall'
|
|
13
|
+
|
|
14
|
+
export interface ProfilesScreenProps {
|
|
15
|
+
profiles: Profile[]
|
|
16
|
+
// Same reasoning as ProfilesManager's own defaultColor: this component has no opinion on the
|
|
17
|
+
// host's palette, so it stays required rather than defaulted. Callers already differ here today
|
|
18
|
+
// (most apps pass @rific/auto-paper's defaultColors[0].value; one passes its own local
|
|
19
|
+
// DEFAULT_P1_COLOR constant instead) — this prop is exactly what lets both keep working unchanged.
|
|
20
|
+
defaultColor: string
|
|
21
|
+
// Same ProfileEditPatch shape ProfilesManager's own onCreate/onSave already take — deliberately
|
|
22
|
+
// NOT widened to accept a host's own richer CreateProfileInput. A host whose profile shape has
|
|
23
|
+
// extra required fields (e.g. a control/key scheme) composes those in its own closure at the call
|
|
24
|
+
// site, exactly as it already does calling <ProfilesManager> today.
|
|
25
|
+
onCreate: (patch: ProfileEditPatch) => void
|
|
26
|
+
onSave: (id: string, patch: ProfileEditPatch) => void
|
|
27
|
+
// Irreversible, same as ProfilesManager's own onDelete — fires once, after this component's
|
|
28
|
+
// internal confirmation card. If the host tracks anything else keyed by profile id (stats,
|
|
29
|
+
// achievement-unlock history), clear that on its own side inside this callback, same as every
|
|
30
|
+
// current app's `(id) => { deleteProfile(id); removeProfileStats(id) }`.
|
|
31
|
+
onDelete: (id: string) => void
|
|
32
|
+
onBack: () => void
|
|
33
|
+
// Independently optional overrides, each defaulting to the same literal-black/white-by-appearance
|
|
34
|
+
// formula every current caller renders with unchanged (none of them pass these today) — same
|
|
35
|
+
// convention as BaseStatsScreen's and ProfilesManager's own fg/bg-shaped overrides. fg/fgMuted/
|
|
36
|
+
// cardBg/titleVariant/colorPreview are forwarded straight through to the internal ProfilesManager.
|
|
37
|
+
fg?: string
|
|
38
|
+
fgMuted?: string
|
|
39
|
+
bg?: string
|
|
40
|
+
cardBg?: string
|
|
41
|
+
titleVariant?: MD3TextVariant
|
|
42
|
+
colorPreview?: (hex: string) => string
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// The routed profiles-roster screen shell every app in this fleet hand-rolls around ProfilesManager
|
|
46
|
+
// today: the container View + theme-derived bg/fg + back button + commit-pending-edit-before-
|
|
47
|
+
// navigate composition. Lives here rather than in @tastic/hud since it has zero generic/non-profile
|
|
48
|
+
// UI content — it's one layer up from ProfilesManager, not a different kind of thing. Deliberately
|
|
49
|
+
// still doesn't call useProfiles()/useGameStats() (or any host-specific hook) itself — those two
|
|
50
|
+
// hooks' shapes are genuinely app-specific (different CreateProfileInput/GameStatsContextValue per
|
|
51
|
+
// app), so this stays props-in/callbacks-out, exactly like ProfilesManager itself.
|
|
52
|
+
export function ProfilesScreen({ profiles, defaultColor, onCreate, onSave, onDelete, onBack, fg: fgOverride, fgMuted, bg: bgOverride, cardBg, titleVariant, colorPreview }: ProfilesScreenProps) {
|
|
53
|
+
const { dark } = useAutoPaperTheme()
|
|
54
|
+
// Owns the ref itself — nothing outside this component ever needs it, since this is now the one
|
|
55
|
+
// thing that both renders ProfilesManager AND owns the only navigation trigger next to it.
|
|
56
|
+
const managerRef = useRef<ProfilesManagerHandle>(null)
|
|
57
|
+
// Same literal-black/white-by-appearance formula as ProfilesManager's own fg default, resolved
|
|
58
|
+
// here (rather than left to ProfilesManager) so this component's own headerLeft IconButton and
|
|
59
|
+
// ProfilesManager's title always share the identical color.
|
|
60
|
+
const fg = fgOverride ?? (dark ? '#FFFFFF' : '#000000')
|
|
61
|
+
const bg = bgOverride ?? (dark ? '#000000' : '#FFFFFF')
|
|
62
|
+
|
|
63
|
+
// Folds in the exact 2-line composition every app hand-rolls today: a hardware back gesture or
|
|
64
|
+
// swipe-back unmounts ProfilesManager (which flushes on its own), but a host router that keeps a
|
|
65
|
+
// popped screen mounted-but-hidden instead of unmounting it (expo-router's web renderer) needs
|
|
66
|
+
// this explicit flush before navigating away, or an in-progress edit is silently lost.
|
|
67
|
+
const handleBack = () => {
|
|
68
|
+
managerRef.current?.commitPendingEdit()
|
|
69
|
+
onBack()
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return (
|
|
73
|
+
<View style={[styles.container, { backgroundColor: bg }]}>
|
|
74
|
+
<ProfilesManager ref={managerRef} profiles={profiles} defaultColor={defaultColor} onCreate={onCreate} onSave={onSave} onDelete={onDelete} fg={fg} fgMuted={fgMuted} cardBg={cardBg} titleVariant={titleVariant} colorPreview={colorPreview} headerLeft={<IconButton icon='arrow-left' iconColor={fg} size={24} onPress={handleBack} accessibilityLabel='Back' />} />
|
|
75
|
+
</View>
|
|
76
|
+
)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const styles = StyleSheet.create({
|
|
80
|
+
container: {
|
|
81
|
+
flex: 1
|
|
82
|
+
}
|
|
83
|
+
})
|
package/src/index.ts
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
export { ProfileChip } from './ProfileChip'
|
|
2
|
-
export { ProfilePicker } from './ProfilePicker'
|
|
3
|
-
export { ProfilesManager, type ProfilesManagerHandle, type ProfilesManagerProps } from './ProfilesManager'
|
|
2
|
+
export { getProfilePickerContentSize, ProfilePicker, type ProfilePickerAlignResult, type ProfilePickerContentSize } from './ProfilePicker'
|
|
3
|
+
export { type ProfileEditPatch, ProfilesManager, type ProfilesManagerHandle, type ProfilesManagerProps } from './ProfilesManager'
|
|
4
|
+
export { ProfilesScreen, type ProfilesScreenProps } from './ProfilesScreen'
|
|
4
5
|
export { isValidProfile, isValidTag, MAX_PROFILE_NAME_LENGTH, MAX_TAG_LENGTH } from './profilesValidation'
|
|
5
6
|
export { createProfileExtensionSlice, type ProfileExtensionActions, type ProfileExtensionSlice, type ProfileExtensionState, type SetProfileExtensionPayload } from './redux/profileExtensionSlice'
|
|
7
|
+
export { createProfileSelectionSlice, type ProfileSelectionActions, type ProfileSelectionSlice, type ProfileSelectionState, type SelectProfilePayload } from './redux/profileSelectionSlice'
|
|
6
8
|
export { type CreateProfileInput, createProfileRecord, profilesActions, profilesReducer, type UpdateProfilePayload } from './redux/profilesSlice'
|
|
7
9
|
export { resolveInitialProfiles } from './redux/resolveInitialProfiles'
|
|
10
|
+
export { createSeatColorsSlice, type SeatColorsActions, type SeatColorsSlice, type SeatColorsState, type SetLastGuestColorPayload, type SetProfileOverridePayload } from './redux/seatColorsSlice'
|
|
8
11
|
export { useSharedProfilesSync, type UseSharedProfilesSyncOptions, type UseSharedProfilesSyncResult } from './redux/useSharedProfilesSync'
|
|
9
12
|
export { isSharedProfileStoreAvailable, loadSharedProfiles, saveSharedProfiles } from './sharedProfileStore'
|
|
10
13
|
export type { Profile } from './types'
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { ActionCreator, createActionCreator } from './createActionCreator'
|
|
2
|
+
|
|
3
|
+
// redux-persist's own REHYDRATE action-type constant, inlined as a literal rather than taking a
|
|
4
|
+
// dependency on the package itself — this factory otherwise has no opinion on whether the
|
|
5
|
+
// consuming app uses redux-persist at all, and the string itself is a long-stable part of
|
|
6
|
+
// redux-persist's own public contract (unchanged across every major version). See the reducer's
|
|
7
|
+
// own REHYDRATE case below for why this needs handling at all.
|
|
8
|
+
const REHYDRATE = 'persist/REHYDRATE'
|
|
9
|
+
|
|
10
|
+
export interface SelectProfilePayload<TSeat> {
|
|
11
|
+
profileId: string | null
|
|
12
|
+
seat: TSeat
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface ProfileSelectionActions<TSeat> {
|
|
16
|
+
clearProfile: ActionCreator<string>
|
|
17
|
+
select: ActionCreator<SelectProfilePayload<TSeat>>
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export type ProfileSelectionState<TSeat extends string | number> = Record<TSeat, string | null>
|
|
21
|
+
|
|
22
|
+
export interface ProfileSelectionSlice<TSeat extends string | number> {
|
|
23
|
+
actions: ProfileSelectionActions<TSeat>
|
|
24
|
+
reducer: (state: ProfileSelectionState<TSeat> | undefined, action: { type: string; payload?: unknown }) => ProfileSelectionState<TSeat>
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Which profile each seat last picked — always local, never part of the shared App Group roster
|
|
28
|
+
// (this package's own profiles slice, see profilesSlice.ts): who's selected on THIS device is
|
|
29
|
+
// naturally a per-app, per-device thing, not something a sibling @tastic game should see or
|
|
30
|
+
// influence. Five @tastic games (AirHockey, BoxHockey, Pong, LightCycles, Snake) each hand-rolled
|
|
31
|
+
// an identical Record<Seat, string | null> reducer before this factory existed — same duplication
|
|
32
|
+
// createProfileExtensionSlice generalized away for per-profile extensions, this is that shape for
|
|
33
|
+
// per-seat selection instead.
|
|
34
|
+
//
|
|
35
|
+
// `namespace` prefixes every action type (e.g. 'profileSelection/loadout/select') so an app with
|
|
36
|
+
// more than one independent seat-selection concern never collides between them — same convention
|
|
37
|
+
// createProfileExtensionSlice's own namespace argument follows.
|
|
38
|
+
//
|
|
39
|
+
// Unlike createProfileExtensionSlice, the seat set can't be derived from TSeat alone (types don't
|
|
40
|
+
// exist at runtime), so callers pass their own `defaultState` — the same literal object (e.g.
|
|
41
|
+
// `{ 1: null, 2: null }`) each app already declares as its own `defaultProfileSelectionState`
|
|
42
|
+
// constant today. `clearProfile` walks that state's own keys rather than assuming any particular
|
|
43
|
+
// seat count, so it reproduces the 5 apps' current two-seat behavior without hardcoding "1"/"2".
|
|
44
|
+
//
|
|
45
|
+
// `mountKey` defaults to 'profileSelection' — the literal key every current consumer (AirHockey,
|
|
46
|
+
// BoxHockey, Pong, LightCycles, Snake) already mounts this reducer under in its own
|
|
47
|
+
// combineReducers({...}) call (e.g. `combineReducers({ ..., profileSelection })`). It has to be
|
|
48
|
+
// passed explicitly rather than inferred, since redux-persist's REHYDRATE payload is keyed by
|
|
49
|
+
// whatever name the app's own root reducer happens to use, which this factory has no way to see
|
|
50
|
+
// from inside its own reducer function — only worth overriding if some future consumer ever mounts
|
|
51
|
+
// this under a different key. Note `namespace` above is a different thing: it's the app's own name
|
|
52
|
+
// (e.g. 'airhockey', 'boxhockey'), used only to prefix action types, not the mount key.
|
|
53
|
+
export function createProfileSelectionSlice<TSeat extends string | number>(namespace: string, defaultState: ProfileSelectionState<TSeat>, mountKey = 'profileSelection'): ProfileSelectionSlice<TSeat> {
|
|
54
|
+
const select = createActionCreator<SelectProfilePayload<TSeat>>(`profileSelection/${namespace}/select`)
|
|
55
|
+
const clearProfile = createActionCreator<string>(`profileSelection/${namespace}/clearProfile`)
|
|
56
|
+
|
|
57
|
+
function reducer(state: ProfileSelectionState<TSeat> = defaultState, action: { type: string; payload?: unknown }): ProfileSelectionState<TSeat> {
|
|
58
|
+
// redux-persist's own default stateReconciler (autoMergeLevel1) HARD-REPLACES this slice's
|
|
59
|
+
// entire persisted sub-state on rehydration rather than backfilling missing fields (confirmed
|
|
60
|
+
// against autoMergeLevel1's actual source: `newState[key] = inboundState[key]`, not a merge) —
|
|
61
|
+
// so a device with a blob persisted before a new seat existed would otherwise rehydrate with
|
|
62
|
+
// that seat's key missing entirely, and every consumer indexes into it (`state[seat]`) with no
|
|
63
|
+
// fallback. Spreading `defaultState` first, then the incoming persisted value, backfills exactly
|
|
64
|
+
// that gap — the same pattern createSeatColorsSlice's own REHYDRATE case already uses.
|
|
65
|
+
if (action.type === REHYDRATE) {
|
|
66
|
+
const persisted = (action.payload as Record<string, Partial<ProfileSelectionState<TSeat>>> | undefined)?.[mountKey]
|
|
67
|
+
return { ...defaultState, ...state, ...persisted }
|
|
68
|
+
}
|
|
69
|
+
if (select.match(action)) return { ...state, [action.payload.seat]: action.payload.profileId } as ProfileSelectionState<TSeat>
|
|
70
|
+
if (clearProfile.match(action)) {
|
|
71
|
+
const next = { ...state }
|
|
72
|
+
for (const seat of Object.keys(state) as TSeat[]) {
|
|
73
|
+
if (next[seat] === action.payload) next[seat] = null
|
|
74
|
+
}
|
|
75
|
+
return next
|
|
76
|
+
}
|
|
77
|
+
return state
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return { actions: { clearProfile, select }, reducer }
|
|
81
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { ActionCreator, createActionCreator } from './createActionCreator'
|
|
2
|
+
|
|
3
|
+
// redux-persist's own REHYDRATE action-type constant, inlined as a literal rather than taking a
|
|
4
|
+
// dependency on the package itself — this factory otherwise has no opinion on whether the
|
|
5
|
+
// consuming app uses redux-persist at all, and the string itself is a long-stable part of
|
|
6
|
+
// redux-persist's own public contract (unchanged across every major version). See the reducer's
|
|
7
|
+
// own REHYDRATE case below for why this needs handling at all.
|
|
8
|
+
const REHYDRATE = 'persist/REHYDRATE'
|
|
9
|
+
|
|
10
|
+
export interface SetLastGuestColorPayload<TSeat> {
|
|
11
|
+
color: string
|
|
12
|
+
seat: TSeat
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface SetProfileOverridePayload<TSeat> {
|
|
16
|
+
color: string | null
|
|
17
|
+
seat: TSeat
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface SeatColorsActions<TSeat> {
|
|
21
|
+
setLastCpuColor: ActionCreator<string>
|
|
22
|
+
setLastGuestColor: ActionCreator<SetLastGuestColorPayload<TSeat>>
|
|
23
|
+
setProfileOverride: ActionCreator<SetProfileOverridePayload<TSeat>>
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export type SeatColorsState<TSeat extends string | number> = {
|
|
27
|
+
lastCpuColor: string
|
|
28
|
+
lastGuestColor: Record<TSeat, string>
|
|
29
|
+
profileOverride: Record<TSeat, string | null>
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface SeatColorsSlice<TSeat extends string | number> {
|
|
33
|
+
actions: SeatColorsActions<TSeat>
|
|
34
|
+
reducer: (state: SeatColorsState<TSeat> | undefined, action: { type: string; payload?: unknown }) => SeatColorsState<TSeat>
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Which color a seat should show right now, remembered independently for three sources that must
|
|
38
|
+
// never fight over one value: a guest seat's own last pick, the CPU seat's own last pick, and a
|
|
39
|
+
// manual recolor/clash-swap override landing on a seat that currently has a profile selected (null
|
|
40
|
+
// meaning "no override, track that profile's own saved color live"). Four @tastic games (AirHockey,
|
|
41
|
+
// BoxHockey, LightCycles, Pong) each hand-rolled an identical dedicated seatColorsSlice.ts — same
|
|
42
|
+
// three fields, same createSlice-based reducer, differing only in their own seat type's name
|
|
43
|
+
// (Player) and default-color constants — before this factory existed; this is that shape,
|
|
44
|
+
// generalized over the seat type exactly like createProfileSelectionSlice generalized per-seat
|
|
45
|
+
// profile selection.
|
|
46
|
+
//
|
|
47
|
+
// Snake has the same three fields too, but folded directly into its own catch-all gameSlice.ts
|
|
48
|
+
// alongside a dozen unrelated persisted settings (wrapEdges, cpuDifficulty, speedTier, ...) rather
|
|
49
|
+
// than in a standalone slice of their own. There's no separate `seatColors` sub-state there for this
|
|
50
|
+
// factory's reducer to own without restructuring gameSlice's top-level shape to nest these three
|
|
51
|
+
// fields under their own key first — a real state-shape migration (and a redux-persist rehydration
|
|
52
|
+
// concern, see gameSlice's own REHYDRATE extraReducer), not a drop-in swap. This factory
|
|
53
|
+
// intentionally targets the standalone-slice shape the other four apps already have; Snake staying
|
|
54
|
+
// hand-rolled inside gameSlice is a deliberate scope call for this pass, not an oversight.
|
|
55
|
+
//
|
|
56
|
+
// `namespace` prefixes every action type (e.g. 'seatColors/loadout/setLastGuestColor') so an app
|
|
57
|
+
// with more than one independent seat-colors concern never collides between them — same convention
|
|
58
|
+
// createProfileSelectionSlice's and createProfileExtensionSlice's own namespace arguments follow.
|
|
59
|
+
//
|
|
60
|
+
// Unlike createProfileExtensionSlice, the seat set can't be derived from TSeat alone (types don't
|
|
61
|
+
// exist at runtime), so callers pass their own `defaultState` — same reasoning, and the same
|
|
62
|
+
// {1: ..., 2: ...}-shaped literal each app already declares as its own defaultSeatColorsState
|
|
63
|
+
// constant today, as createProfileSelectionSlice's own defaultState parameter.
|
|
64
|
+
//
|
|
65
|
+
// `mountKey` defaults to 'seatColors' — the literal key every current consumer (AirHockey,
|
|
66
|
+
// BoxHockey, Pong) already mounts this reducer under in its own combineReducers({...}) call (e.g.
|
|
67
|
+
// `combineReducers({ ..., seatColors })`). It has to be passed explicitly rather than inferred,
|
|
68
|
+
// since redux-persist's REHYDRATE payload is keyed by whatever name the app's own root reducer
|
|
69
|
+
// happens to use, which this factory has no way to see from inside its own reducer function — only
|
|
70
|
+
// worth overriding if some future consumer ever mounts this under a different key.
|
|
71
|
+
export function createSeatColorsSlice<TSeat extends string | number>(namespace: string, defaultState: SeatColorsState<TSeat>, mountKey = 'seatColors'): SeatColorsSlice<TSeat> {
|
|
72
|
+
const setLastCpuColor = createActionCreator<string>(`seatColors/${namespace}/setLastCpuColor`)
|
|
73
|
+
const setLastGuestColor = createActionCreator<SetLastGuestColorPayload<TSeat>>(`seatColors/${namespace}/setLastGuestColor`)
|
|
74
|
+
const setProfileOverride = createActionCreator<SetProfileOverridePayload<TSeat>>(`seatColors/${namespace}/setProfileOverride`)
|
|
75
|
+
|
|
76
|
+
function reducer(state: SeatColorsState<TSeat> = defaultState, action: { type: string; payload?: unknown }): SeatColorsState<TSeat> {
|
|
77
|
+
// redux-persist's own default stateReconciler (autoMergeLevel1) HARD-REPLACES this slice's
|
|
78
|
+
// entire persisted sub-state on rehydration rather than backfilling missing fields (confirmed
|
|
79
|
+
// against autoMergeLevel1's actual source: `newState[key] = inboundState[key]`, not a merge) —
|
|
80
|
+
// so a device with a blob persisted before `profileOverride` existed would otherwise rehydrate
|
|
81
|
+
// with `state.profileOverride === undefined`, and every consumer indexes into it (`profileOverride[seat]`)
|
|
82
|
+
// with no fallback, throwing. Spreading `defaultState` first, then the incoming persisted
|
|
83
|
+
// value, backfills exactly that gap — the same pattern Snake's own hand-rolled `gameSlice.ts`
|
|
84
|
+
// already uses for its equivalent (non-factory) fields via its own REHYDRATE extraReducer.
|
|
85
|
+
if (action.type === REHYDRATE) {
|
|
86
|
+
const persisted = (action.payload as Record<string, Partial<SeatColorsState<TSeat>>> | undefined)?.[mountKey]
|
|
87
|
+
return { ...defaultState, ...state, ...persisted }
|
|
88
|
+
}
|
|
89
|
+
if (setLastCpuColor.match(action)) return { ...state, lastCpuColor: action.payload }
|
|
90
|
+
if (setLastGuestColor.match(action)) return { ...state, lastGuestColor: { ...state.lastGuestColor, [action.payload.seat]: action.payload.color } as Record<TSeat, string> }
|
|
91
|
+
if (setProfileOverride.match(action)) return { ...state, profileOverride: { ...state.profileOverride, [action.payload.seat]: action.payload.color } as Record<TSeat, string | null> }
|
|
92
|
+
return state
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return { actions: { setLastCpuColor, setLastGuestColor, setProfileOverride }, reducer }
|
|
96
|
+
}
|