@tastic/profile 0.4.0 → 0.7.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 +127 -19
- package/dist/index.d.mts +65 -3
- package/dist/index.d.ts +65 -3
- package/dist/index.js +79 -8
- package/dist/index.mjs +71 -3
- package/package.json +7 -7
- package/src/ProfilesManager.tsx +4 -1
- package/src/ProfilesScreen.tsx +83 -0
- package/src/index.ts +4 -1
- package/src/redux/profileSelectionSlice.ts +81 -0
- package/src/redux/seatColorsSlice.ts +96 -0
package/README.md
CHANGED
|
@@ -53,7 +53,17 @@ see its own doc comment for why that component is generic over your own `Profile
|
|
|
53
53
|
- **`ProfilesManager`** — the roster screen itself: create/rename/recolor/retag/delete, editing
|
|
54
54
|
inline in the row you tap, with a destructive-delete confirmation. No navigation of its own (no
|
|
55
55
|
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`.
|
|
56
|
+
persistence via `profiles`/`onCreate`/`onSave`/`onDelete`. `onCreate`/`onSave` both take a
|
|
57
|
+
`ProfileEditPatch` (`{ name, color, tag }`) — exported from this package's own public API (it was
|
|
58
|
+
a private, unexported interface inside `ProfilesManager.tsx` until `ProfilesScreen` below needed
|
|
59
|
+
to reference it from its own props).
|
|
60
|
+
- **`ProfilesScreen`** — the routed-screen shell around `ProfilesManager`: the container `View`,
|
|
61
|
+
theme-derived background/foreground, a back button, and the commit-pending-edit-before-navigate
|
|
62
|
+
composition every app in the fleet used to hand-roll on its own around `ProfilesManager` (see
|
|
63
|
+
"Routed screens: `ProfilesScreen`" below). Same `ProfileEditPatch`-shaped
|
|
64
|
+
`profiles`/`defaultColor`/`onCreate`/`onSave`/`onDelete` as `ProfilesManager` itself, plus
|
|
65
|
+
`onBack`. Owns its own `ProfilesManagerHandle` ref internally — there's no `ref` prop here, since
|
|
66
|
+
nothing outside it needs one anymore.
|
|
57
67
|
- **`loadSharedProfiles` / `saveSharedProfiles` / `isSharedProfileStoreAvailable`** — an *optional*
|
|
58
68
|
cross-app roster, shared between multiple apps of yours via a native iOS App Group (same Apple
|
|
59
69
|
Developer Team, same `"com.apple.security.application-groups"` entitlement value on every
|
|
@@ -88,13 +98,71 @@ see its own doc comment for why that component is generic over your own `Profile
|
|
|
88
98
|
{ "expo": { "ios": { "entitlements": { "com.apple.security.application-groups": ["group.com.yourteam.yourgames"] } } } }
|
|
89
99
|
```
|
|
90
100
|
|
|
101
|
+
### Routed screens: `ProfilesScreen`
|
|
102
|
+
|
|
103
|
+
`ProfilesScreen` is the routed-screen shell every app in the fleet was hand-rolling on its own
|
|
104
|
+
around `ProfilesManager`: a container `View`, theme-derived bg/fg, a `headerLeft` back button, and
|
|
105
|
+
the two-line `managerRef.current?.commitPendingEdit(); onBack()` composition every app's own
|
|
106
|
+
back-button handler used to write out by hand (see `ProfilesManager`'s own `commitPendingEdit` doc
|
|
107
|
+
above for why that flush matters on a router that doesn't genuinely unmount a popped screen). It
|
|
108
|
+
owns that ref itself — there's no `ref` prop on `ProfilesScreen`, since nothing outside it needs
|
|
109
|
+
one any more.
|
|
110
|
+
|
|
111
|
+
Same `profiles`/`defaultColor`/`onCreate`/`onSave`/`onDelete` props as `ProfilesManager` — still
|
|
112
|
+
`ProfileEditPatch`-shaped, still deliberately not widened to your own richer `CreateProfileInput`;
|
|
113
|
+
compose any extra required fields (a key/control scheme, whatever's specific to your game) in your
|
|
114
|
+
own closure at the call site, exactly as you already would calling `ProfilesManager` directly —
|
|
115
|
+
plus `onBack`. `fg`/`fgMuted`/`bg`/`cardBg`/`titleVariant`/`colorPreview` are all independently
|
|
116
|
+
optional and forward straight through to the internal `ProfilesManager` (`bg` is new here —
|
|
117
|
+
`ProfilesManager` itself has no `bg`, since it doesn't own a container `View` of its own).
|
|
118
|
+
|
|
119
|
+
A whole routed screen, wired to your own store, is usually this small:
|
|
120
|
+
|
|
121
|
+
```tsx
|
|
122
|
+
// app/profiles.tsx
|
|
123
|
+
import { defaultColors } from '@rific/auto-paper'
|
|
124
|
+
import { ProfilesScreen } from '@tastic/profile'
|
|
125
|
+
import { router } from 'expo-router'
|
|
126
|
+
|
|
127
|
+
import { useGameStats } from '@/hooks/useGameStats'
|
|
128
|
+
import { useProfiles } from '@/hooks/useProfiles'
|
|
129
|
+
|
|
130
|
+
export default function Profiles() {
|
|
131
|
+
const { profiles, createProfile, updateProfile, deleteProfile } = useProfiles()
|
|
132
|
+
// Only for wiring onDelete below — clears a deleted profile's own stats record so it doesn't
|
|
133
|
+
// stick around as an orphan once the profile itself is gone.
|
|
134
|
+
const { removeProfileStats } = useGameStats()
|
|
135
|
+
|
|
136
|
+
return (
|
|
137
|
+
<ProfilesScreen
|
|
138
|
+
profiles={profiles}
|
|
139
|
+
defaultColor={defaultColors[0].value}
|
|
140
|
+
onCreate={(patch) => createProfile(patch)}
|
|
141
|
+
onSave={(id, patch) => updateProfile(id, patch)}
|
|
142
|
+
onDelete={(id) => {
|
|
143
|
+
deleteProfile(id)
|
|
144
|
+
removeProfileStats(id)
|
|
145
|
+
}}
|
|
146
|
+
onBack={() => router.back()}
|
|
147
|
+
/>
|
|
148
|
+
)
|
|
149
|
+
}
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
`useProfiles`/`useGameStats` are your own app's hooks, not exports of this package — `profiles` is
|
|
153
|
+
whatever `Profile[]` (or your own extended shape) you already keep in Redux/context/state, and
|
|
154
|
+
`createProfile`/`updateProfile`/`deleteProfile` are however you already persist it. `onBack` can be
|
|
155
|
+
anything with no arguments — `router.back()`, a `safeBack()`-style helper that no-ops with no back
|
|
156
|
+
stack behind it, `navigation.goBack()` — `ProfilesScreen` only ever calls it after flushing any
|
|
157
|
+
in-progress inline edit first.
|
|
158
|
+
|
|
91
159
|
### Redux helpers for the shared roster
|
|
92
160
|
|
|
93
161
|
The cross-app-roster sketch above is a pattern every `@tastic` game was hand-rolling on its own
|
|
94
162
|
(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:
|
|
163
|
+
listener, a per-seat selection reducer). These five pieces are that logic extracted into reusable,
|
|
164
|
+
Redux-shaped building blocks — plain TypeScript, no dependency on `redux`/`react-redux` themselves,
|
|
165
|
+
so a host app plugs the reducer(s) into whatever store it already has:
|
|
98
166
|
|
|
99
167
|
- **`profilesActions` / `profilesReducer` / `createProfileRecord`** — the base roster as a reducer:
|
|
100
168
|
`add`/`update`/`remove`/`setAll` actions, plus `createProfileRecord(input)` to mint a complete
|
|
@@ -113,30 +181,70 @@ reducer(s) into whatever store it already has:
|
|
|
113
181
|
change to mirror it out, and `onRemoteChange` fires with a freshly-loaded roster whenever the app
|
|
114
182
|
returns to the foreground (skipped while a `syncToShared` write is still in flight, so a remote
|
|
115
183
|
refresh can't clobber a write that hasn't landed yet).
|
|
184
|
+
- **`createProfileSelectionSlice<TSeat>(namespace, defaultState)`** — a factory for the *other*
|
|
185
|
+
per-seat piece of state: which profile id each seat currently has selected (parallel to
|
|
186
|
+
`createProfileExtensionSlice` above, but keyed by seat instead of by profile id). Returns a
|
|
187
|
+
`{ actions: { select, clearProfile }, reducer }` pair over a plain `Record<TSeat, string | null>`.
|
|
188
|
+
Unlike `createProfileExtensionSlice`, the seat set can't be derived from `TSeat` alone (types
|
|
189
|
+
don't exist at runtime), so callers pass their own `defaultState` — the same literal object (e.g.
|
|
190
|
+
`{ 1: null, 2: null }`) every app already declares today. `select({ seat, profileId })` sets one
|
|
191
|
+
seat's selection directly; `clearProfile(profileId)` walks every seat and nulls out any that
|
|
192
|
+
currently points at that id — call it when a profile is deleted so no seat is left referencing a
|
|
193
|
+
ghost id. Always local, like the guest/CPU/override colors below — never part of the shared
|
|
194
|
+
App Group roster, since who's selected on *this* device is naturally per-app, per-device state.
|
|
116
195
|
|
|
117
|
-
##
|
|
196
|
+
## Per-seat color persistence
|
|
118
197
|
|
|
119
|
-
|
|
120
|
-
|
|
198
|
+
A loadout/lobby screen's color for a given seat is never just "the selected profile's saved
|
|
199
|
+
color" — a seat can be a guest (no profile selected), a CPU opponent, or a profile the player
|
|
200
|
+
manually recolored for a clash-swap without touching the profile itself. Every `@tastic` game
|
|
201
|
+
(AirHockey, BoxHockey, Pong, LightCycles, Snake) ends up needing the same three cooperating pieces
|
|
202
|
+
of state to cover all three cases. This is the established pattern every game should follow — it
|
|
203
|
+
was previously undocumented at the package level, which is exactly why two of the five apps
|
|
204
|
+
(AirHockey, Snake) didn't pick up the third piece below until after they'd already shipped without
|
|
205
|
+
it.
|
|
206
|
+
|
|
207
|
+
- **`lastGuestColor`** — a `Record<Seat, string>`: what a seat's color was the last time it was a
|
|
208
|
+
guest (no profile selected). Written only when a guest seat's color changes; a profile-selected
|
|
209
|
+
seat never reads or writes it.
|
|
210
|
+
- **`lastCpuColor`** — the same idea for the CPU slot, as a single remembered `string` rather than a
|
|
211
|
+
per-seat record (a CPU opponent only ever occupies one fixed seat in every shipped game so far).
|
|
212
|
+
- **`profileOverride`** — a `Record<Seat, string | null>`: a manual recolor or clash-swap landing on
|
|
213
|
+
a seat that currently has a profile selected. `null` means "no override — track the profile's own
|
|
214
|
+
saved color live." Cleared only when that seat's own profile selection genuinely changes, never by
|
|
215
|
+
merely leaving and returning to the screen.
|
|
216
|
+
|
|
217
|
+
None of these three are exports of this package today. `lastGuestColor`/`lastCpuColor` predate it
|
|
218
|
+
entirely, and `profileOverride` is still hand-rolled per app — typically alongside
|
|
219
|
+
`createProfileSelectionSlice`'s own selection state, in the same seat-scoped slice (see e.g.
|
|
220
|
+
BoxHockey's `src/redux/seatColorsSlice.ts`) or folded into the app's own `gameSlice.ts` when there's
|
|
221
|
+
no dedicated slice to put it in (see Snake's `src/redux/gameSlice.ts`). This section exists so a new
|
|
222
|
+
app's author reads the convention here first, rather than hand-rolling something that quietly can't
|
|
223
|
+
interop with the rest of the fleet.
|
|
224
|
+
|
|
225
|
+
The resolution order at a loadout/lobby screen, for one seat:
|
|
226
|
+
|
|
227
|
+
```ts
|
|
228
|
+
const color = profile
|
|
229
|
+
? (profileOverride[seat] ?? profile.color) // profile selected: override wins, else the profile's own color
|
|
230
|
+
: isCpu
|
|
231
|
+
? lastCpuColor // CPU slot: its own remembered color
|
|
232
|
+
: lastGuestColor[seat] // guest seat: that seat's own remembered color
|
|
121
233
|
```
|
|
122
234
|
|
|
123
|
-
|
|
124
|
-
|
|
235
|
+
`lastGuestColor`/`lastCpuColor` are only ever consulted when no profile is selected for that seat at
|
|
236
|
+
all — a profile-selected seat's guest/CPU memory is neither read nor overwritten while it's active,
|
|
237
|
+
so switching a seat back to "guest" later still recalls whatever guest color it had before a profile
|
|
238
|
+
was ever selected there.
|
|
125
239
|
|
|
126
|
-
|
|
240
|
+
## Install
|
|
127
241
|
|
|
128
242
|
```bash
|
|
129
|
-
|
|
130
|
-
npm run build
|
|
131
|
-
yalc publish
|
|
132
|
-
|
|
133
|
-
cd ../your-game
|
|
134
|
-
yalc add @tastic/profile
|
|
135
|
-
npm install
|
|
243
|
+
npm install @tastic/profile
|
|
136
244
|
```
|
|
137
245
|
|
|
138
|
-
|
|
139
|
-
|
|
246
|
+
Published to the public npm registry via tag-based CI (OIDC trusted publishing — see
|
|
247
|
+
`npm run release:patch`/`:minor`/`:major`).
|
|
140
248
|
|
|
141
249
|
## Peer dependencies
|
|
142
250
|
|
package/dist/index.d.mts
CHANGED
|
@@ -35,7 +35,7 @@ interface ProfilePickerProps<P extends Profile> {
|
|
|
35
35
|
}
|
|
36
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;
|
|
37
37
|
|
|
38
|
-
type MD3TextVariant = 'displayLarge' | 'displayMedium' | 'displaySmall' | 'headlineLarge' | 'headlineMedium' | 'headlineSmall' | 'titleLarge' | 'titleMedium' | 'titleSmall' | 'labelLarge' | 'labelMedium' | 'labelSmall' | 'bodyLarge' | 'bodyMedium' | 'bodySmall';
|
|
38
|
+
type MD3TextVariant$1 = 'displayLarge' | 'displayMedium' | 'displaySmall' | 'headlineLarge' | 'headlineMedium' | 'headlineSmall' | 'titleLarge' | 'titleMedium' | 'titleSmall' | 'labelLarge' | 'labelMedium' | 'labelSmall' | 'bodyLarge' | 'bodyMedium' | 'bodySmall';
|
|
39
39
|
interface ProfileEditPatch {
|
|
40
40
|
name: string;
|
|
41
41
|
color: string;
|
|
@@ -55,11 +55,28 @@ interface ProfilesManagerProps {
|
|
|
55
55
|
fg?: string;
|
|
56
56
|
fgMuted?: string;
|
|
57
57
|
cardBg?: string;
|
|
58
|
-
titleVariant?: MD3TextVariant;
|
|
58
|
+
titleVariant?: MD3TextVariant$1;
|
|
59
59
|
colorPreview?: (hex: string) => string;
|
|
60
60
|
}
|
|
61
61
|
declare function ProfilesManager({ profiles, defaultColor, onCreate, onSave, onDelete, headerLeft, ref, fg: fgOverride, fgMuted: fgMutedOverride, cardBg: cardBgOverride, titleVariant, colorPreview }: ProfilesManagerProps): react.JSX.Element;
|
|
62
62
|
|
|
63
|
+
type MD3TextVariant = 'displayLarge' | 'displayMedium' | 'displaySmall' | 'headlineLarge' | 'headlineMedium' | 'headlineSmall' | 'titleLarge' | 'titleMedium' | 'titleSmall' | 'labelLarge' | 'labelMedium' | 'labelSmall' | 'bodyLarge' | 'bodyMedium' | 'bodySmall';
|
|
64
|
+
interface ProfilesScreenProps {
|
|
65
|
+
profiles: Profile[];
|
|
66
|
+
defaultColor: string;
|
|
67
|
+
onCreate: (patch: ProfileEditPatch) => void;
|
|
68
|
+
onSave: (id: string, patch: ProfileEditPatch) => void;
|
|
69
|
+
onDelete: (id: string) => void;
|
|
70
|
+
onBack: () => void;
|
|
71
|
+
fg?: string;
|
|
72
|
+
fgMuted?: string;
|
|
73
|
+
bg?: string;
|
|
74
|
+
cardBg?: string;
|
|
75
|
+
titleVariant?: MD3TextVariant;
|
|
76
|
+
colorPreview?: (hex: string) => string;
|
|
77
|
+
}
|
|
78
|
+
declare function ProfilesScreen({ profiles, defaultColor, onCreate, onSave, onDelete, onBack, fg: fgOverride, fgMuted, bg: bgOverride, cardBg, titleVariant, colorPreview }: ProfilesScreenProps): react.JSX.Element;
|
|
79
|
+
|
|
63
80
|
declare const MAX_PROFILE_NAME_LENGTH = 20;
|
|
64
81
|
declare const MAX_TAG_LENGTH = 3;
|
|
65
82
|
declare function isValidTag(value: unknown): value is string;
|
|
@@ -93,6 +110,24 @@ interface ProfileExtensionSlice<TExtension> {
|
|
|
93
110
|
}
|
|
94
111
|
declare function createProfileExtensionSlice<TExtension>(namespace: string): ProfileExtensionSlice<TExtension>;
|
|
95
112
|
|
|
113
|
+
interface SelectProfilePayload<TSeat> {
|
|
114
|
+
profileId: string | null;
|
|
115
|
+
seat: TSeat;
|
|
116
|
+
}
|
|
117
|
+
interface ProfileSelectionActions<TSeat> {
|
|
118
|
+
clearProfile: ActionCreator<string>;
|
|
119
|
+
select: ActionCreator<SelectProfilePayload<TSeat>>;
|
|
120
|
+
}
|
|
121
|
+
type ProfileSelectionState<TSeat extends string | number> = Record<TSeat, string | null>;
|
|
122
|
+
interface ProfileSelectionSlice<TSeat extends string | number> {
|
|
123
|
+
actions: ProfileSelectionActions<TSeat>;
|
|
124
|
+
reducer: (state: ProfileSelectionState<TSeat> | undefined, action: {
|
|
125
|
+
type: string;
|
|
126
|
+
payload?: unknown;
|
|
127
|
+
}) => ProfileSelectionState<TSeat>;
|
|
128
|
+
}
|
|
129
|
+
declare function createProfileSelectionSlice<TSeat extends string | number>(namespace: string, defaultState: ProfileSelectionState<TSeat>, mountKey?: string): ProfileSelectionSlice<TSeat>;
|
|
130
|
+
|
|
96
131
|
interface UpdateProfilePayload {
|
|
97
132
|
id: string;
|
|
98
133
|
patch: Partial<Pick<Profile, 'name' | 'color' | 'tag'>>;
|
|
@@ -115,6 +150,33 @@ declare function createProfileRecord(input: CreateProfileInput): Profile;
|
|
|
115
150
|
|
|
116
151
|
declare function resolveInitialProfiles(groupId: string, localFallback: Profile[]): Promise<Profile[]>;
|
|
117
152
|
|
|
153
|
+
interface SetLastGuestColorPayload<TSeat> {
|
|
154
|
+
color: string;
|
|
155
|
+
seat: TSeat;
|
|
156
|
+
}
|
|
157
|
+
interface SetProfileOverridePayload<TSeat> {
|
|
158
|
+
color: string | null;
|
|
159
|
+
seat: TSeat;
|
|
160
|
+
}
|
|
161
|
+
interface SeatColorsActions<TSeat> {
|
|
162
|
+
setLastCpuColor: ActionCreator<string>;
|
|
163
|
+
setLastGuestColor: ActionCreator<SetLastGuestColorPayload<TSeat>>;
|
|
164
|
+
setProfileOverride: ActionCreator<SetProfileOverridePayload<TSeat>>;
|
|
165
|
+
}
|
|
166
|
+
type SeatColorsState<TSeat extends string | number> = {
|
|
167
|
+
lastCpuColor: string;
|
|
168
|
+
lastGuestColor: Record<TSeat, string>;
|
|
169
|
+
profileOverride: Record<TSeat, string | null>;
|
|
170
|
+
};
|
|
171
|
+
interface SeatColorsSlice<TSeat extends string | number> {
|
|
172
|
+
actions: SeatColorsActions<TSeat>;
|
|
173
|
+
reducer: (state: SeatColorsState<TSeat> | undefined, action: {
|
|
174
|
+
type: string;
|
|
175
|
+
payload?: unknown;
|
|
176
|
+
}) => SeatColorsState<TSeat>;
|
|
177
|
+
}
|
|
178
|
+
declare function createSeatColorsSlice<TSeat extends string | number>(namespace: string, defaultState: SeatColorsState<TSeat>, mountKey?: string): SeatColorsSlice<TSeat>;
|
|
179
|
+
|
|
118
180
|
interface UseSharedProfilesSyncOptions {
|
|
119
181
|
groupId: string;
|
|
120
182
|
onRemoteChange: (profiles: Profile[]) => void;
|
|
@@ -128,4 +190,4 @@ declare const isSharedProfileStoreAvailable: boolean;
|
|
|
128
190
|
declare function loadSharedProfiles(groupId: string): Promise<Profile[]>;
|
|
129
191
|
declare function saveSharedProfiles(groupId: string, profiles: Profile[]): Promise<void>;
|
|
130
192
|
|
|
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 };
|
|
193
|
+
export { type CreateProfileInput, MAX_PROFILE_NAME_LENGTH, MAX_TAG_LENGTH, type Profile, ProfileChip, type ProfileEditPatch, type ProfileExtensionActions, type ProfileExtensionSlice, type ProfileExtensionState, ProfilePicker, 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, isSharedProfileStoreAvailable, isValidProfile, isValidTag, loadSharedProfiles, profilesActions, profilesReducer, resolveInitialProfiles, saveSharedProfiles, useSharedProfilesSync };
|
package/dist/index.d.ts
CHANGED
|
@@ -35,7 +35,7 @@ interface ProfilePickerProps<P extends Profile> {
|
|
|
35
35
|
}
|
|
36
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;
|
|
37
37
|
|
|
38
|
-
type MD3TextVariant = 'displayLarge' | 'displayMedium' | 'displaySmall' | 'headlineLarge' | 'headlineMedium' | 'headlineSmall' | 'titleLarge' | 'titleMedium' | 'titleSmall' | 'labelLarge' | 'labelMedium' | 'labelSmall' | 'bodyLarge' | 'bodyMedium' | 'bodySmall';
|
|
38
|
+
type MD3TextVariant$1 = 'displayLarge' | 'displayMedium' | 'displaySmall' | 'headlineLarge' | 'headlineMedium' | 'headlineSmall' | 'titleLarge' | 'titleMedium' | 'titleSmall' | 'labelLarge' | 'labelMedium' | 'labelSmall' | 'bodyLarge' | 'bodyMedium' | 'bodySmall';
|
|
39
39
|
interface ProfileEditPatch {
|
|
40
40
|
name: string;
|
|
41
41
|
color: string;
|
|
@@ -55,11 +55,28 @@ interface ProfilesManagerProps {
|
|
|
55
55
|
fg?: string;
|
|
56
56
|
fgMuted?: string;
|
|
57
57
|
cardBg?: string;
|
|
58
|
-
titleVariant?: MD3TextVariant;
|
|
58
|
+
titleVariant?: MD3TextVariant$1;
|
|
59
59
|
colorPreview?: (hex: string) => string;
|
|
60
60
|
}
|
|
61
61
|
declare function ProfilesManager({ profiles, defaultColor, onCreate, onSave, onDelete, headerLeft, ref, fg: fgOverride, fgMuted: fgMutedOverride, cardBg: cardBgOverride, titleVariant, colorPreview }: ProfilesManagerProps): react.JSX.Element;
|
|
62
62
|
|
|
63
|
+
type MD3TextVariant = 'displayLarge' | 'displayMedium' | 'displaySmall' | 'headlineLarge' | 'headlineMedium' | 'headlineSmall' | 'titleLarge' | 'titleMedium' | 'titleSmall' | 'labelLarge' | 'labelMedium' | 'labelSmall' | 'bodyLarge' | 'bodyMedium' | 'bodySmall';
|
|
64
|
+
interface ProfilesScreenProps {
|
|
65
|
+
profiles: Profile[];
|
|
66
|
+
defaultColor: string;
|
|
67
|
+
onCreate: (patch: ProfileEditPatch) => void;
|
|
68
|
+
onSave: (id: string, patch: ProfileEditPatch) => void;
|
|
69
|
+
onDelete: (id: string) => void;
|
|
70
|
+
onBack: () => void;
|
|
71
|
+
fg?: string;
|
|
72
|
+
fgMuted?: string;
|
|
73
|
+
bg?: string;
|
|
74
|
+
cardBg?: string;
|
|
75
|
+
titleVariant?: MD3TextVariant;
|
|
76
|
+
colorPreview?: (hex: string) => string;
|
|
77
|
+
}
|
|
78
|
+
declare function ProfilesScreen({ profiles, defaultColor, onCreate, onSave, onDelete, onBack, fg: fgOverride, fgMuted, bg: bgOverride, cardBg, titleVariant, colorPreview }: ProfilesScreenProps): react.JSX.Element;
|
|
79
|
+
|
|
63
80
|
declare const MAX_PROFILE_NAME_LENGTH = 20;
|
|
64
81
|
declare const MAX_TAG_LENGTH = 3;
|
|
65
82
|
declare function isValidTag(value: unknown): value is string;
|
|
@@ -93,6 +110,24 @@ interface ProfileExtensionSlice<TExtension> {
|
|
|
93
110
|
}
|
|
94
111
|
declare function createProfileExtensionSlice<TExtension>(namespace: string): ProfileExtensionSlice<TExtension>;
|
|
95
112
|
|
|
113
|
+
interface SelectProfilePayload<TSeat> {
|
|
114
|
+
profileId: string | null;
|
|
115
|
+
seat: TSeat;
|
|
116
|
+
}
|
|
117
|
+
interface ProfileSelectionActions<TSeat> {
|
|
118
|
+
clearProfile: ActionCreator<string>;
|
|
119
|
+
select: ActionCreator<SelectProfilePayload<TSeat>>;
|
|
120
|
+
}
|
|
121
|
+
type ProfileSelectionState<TSeat extends string | number> = Record<TSeat, string | null>;
|
|
122
|
+
interface ProfileSelectionSlice<TSeat extends string | number> {
|
|
123
|
+
actions: ProfileSelectionActions<TSeat>;
|
|
124
|
+
reducer: (state: ProfileSelectionState<TSeat> | undefined, action: {
|
|
125
|
+
type: string;
|
|
126
|
+
payload?: unknown;
|
|
127
|
+
}) => ProfileSelectionState<TSeat>;
|
|
128
|
+
}
|
|
129
|
+
declare function createProfileSelectionSlice<TSeat extends string | number>(namespace: string, defaultState: ProfileSelectionState<TSeat>, mountKey?: string): ProfileSelectionSlice<TSeat>;
|
|
130
|
+
|
|
96
131
|
interface UpdateProfilePayload {
|
|
97
132
|
id: string;
|
|
98
133
|
patch: Partial<Pick<Profile, 'name' | 'color' | 'tag'>>;
|
|
@@ -115,6 +150,33 @@ declare function createProfileRecord(input: CreateProfileInput): Profile;
|
|
|
115
150
|
|
|
116
151
|
declare function resolveInitialProfiles(groupId: string, localFallback: Profile[]): Promise<Profile[]>;
|
|
117
152
|
|
|
153
|
+
interface SetLastGuestColorPayload<TSeat> {
|
|
154
|
+
color: string;
|
|
155
|
+
seat: TSeat;
|
|
156
|
+
}
|
|
157
|
+
interface SetProfileOverridePayload<TSeat> {
|
|
158
|
+
color: string | null;
|
|
159
|
+
seat: TSeat;
|
|
160
|
+
}
|
|
161
|
+
interface SeatColorsActions<TSeat> {
|
|
162
|
+
setLastCpuColor: ActionCreator<string>;
|
|
163
|
+
setLastGuestColor: ActionCreator<SetLastGuestColorPayload<TSeat>>;
|
|
164
|
+
setProfileOverride: ActionCreator<SetProfileOverridePayload<TSeat>>;
|
|
165
|
+
}
|
|
166
|
+
type SeatColorsState<TSeat extends string | number> = {
|
|
167
|
+
lastCpuColor: string;
|
|
168
|
+
lastGuestColor: Record<TSeat, string>;
|
|
169
|
+
profileOverride: Record<TSeat, string | null>;
|
|
170
|
+
};
|
|
171
|
+
interface SeatColorsSlice<TSeat extends string | number> {
|
|
172
|
+
actions: SeatColorsActions<TSeat>;
|
|
173
|
+
reducer: (state: SeatColorsState<TSeat> | undefined, action: {
|
|
174
|
+
type: string;
|
|
175
|
+
payload?: unknown;
|
|
176
|
+
}) => SeatColorsState<TSeat>;
|
|
177
|
+
}
|
|
178
|
+
declare function createSeatColorsSlice<TSeat extends string | number>(namespace: string, defaultState: SeatColorsState<TSeat>, mountKey?: string): SeatColorsSlice<TSeat>;
|
|
179
|
+
|
|
118
180
|
interface UseSharedProfilesSyncOptions {
|
|
119
181
|
groupId: string;
|
|
120
182
|
onRemoteChange: (profiles: Profile[]) => void;
|
|
@@ -128,4 +190,4 @@ declare const isSharedProfileStoreAvailable: boolean;
|
|
|
128
190
|
declare function loadSharedProfiles(groupId: string): Promise<Profile[]>;
|
|
129
191
|
declare function saveSharedProfiles(groupId: string, profiles: Profile[]): Promise<void>;
|
|
130
192
|
|
|
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 };
|
|
193
|
+
export { type CreateProfileInput, MAX_PROFILE_NAME_LENGTH, MAX_TAG_LENGTH, type Profile, ProfileChip, type ProfileEditPatch, type ProfileExtensionActions, type ProfileExtensionSlice, type ProfileExtensionState, ProfilePicker, 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, isSharedProfileStoreAvailable, isValidProfile, isValidTag, loadSharedProfiles, profilesActions, profilesReducer, resolveInitialProfiles, saveSharedProfiles, useSharedProfilesSync };
|
package/dist/index.js
CHANGED
|
@@ -25,8 +25,11 @@ __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,
|
|
30
33
|
isSharedProfileStoreAvailable: () => isSharedProfileStoreAvailable,
|
|
31
34
|
isValidProfile: () => isValidProfile,
|
|
32
35
|
isValidTag: () => isValidTag,
|
|
@@ -514,6 +517,29 @@ var styles3 = import_react_native3.StyleSheet.create({
|
|
|
514
517
|
}
|
|
515
518
|
});
|
|
516
519
|
|
|
520
|
+
// src/ProfilesScreen.tsx
|
|
521
|
+
var import_auto_paper4 = require("@rific/auto-paper");
|
|
522
|
+
var import_feedback_press3 = require("@rific/feedback-press");
|
|
523
|
+
var import_react3 = require("react");
|
|
524
|
+
var import_react_native4 = require("react-native");
|
|
525
|
+
var import_jsx_runtime4 = require("react/jsx-runtime");
|
|
526
|
+
function ProfilesScreen({ profiles, defaultColor, onCreate, onSave, onDelete, onBack, fg: fgOverride, fgMuted, bg: bgOverride, cardBg, titleVariant, colorPreview }) {
|
|
527
|
+
const { dark } = (0, import_auto_paper4.useAutoPaperTheme)();
|
|
528
|
+
const managerRef = (0, import_react3.useRef)(null);
|
|
529
|
+
const fg = fgOverride ?? (dark ? "#FFFFFF" : "#000000");
|
|
530
|
+
const bg = bgOverride ?? (dark ? "#000000" : "#FFFFFF");
|
|
531
|
+
const handleBack = () => {
|
|
532
|
+
managerRef.current?.commitPendingEdit();
|
|
533
|
+
onBack();
|
|
534
|
+
};
|
|
535
|
+
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" }) }) });
|
|
536
|
+
}
|
|
537
|
+
var styles4 = import_react_native4.StyleSheet.create({
|
|
538
|
+
container: {
|
|
539
|
+
flex: 1
|
|
540
|
+
}
|
|
541
|
+
});
|
|
542
|
+
|
|
517
543
|
// src/redux/createActionCreator.ts
|
|
518
544
|
function createActionCreator(type) {
|
|
519
545
|
const creator = ((payload) => ({ payload, type }));
|
|
@@ -539,6 +565,29 @@ function createProfileExtensionSlice(namespace) {
|
|
|
539
565
|
return { actions: { remove: remove2, set }, reducer };
|
|
540
566
|
}
|
|
541
567
|
|
|
568
|
+
// src/redux/profileSelectionSlice.ts
|
|
569
|
+
var REHYDRATE = "persist/REHYDRATE";
|
|
570
|
+
function createProfileSelectionSlice(namespace, defaultState, mountKey = "profileSelection") {
|
|
571
|
+
const select = createActionCreator(`profileSelection/${namespace}/select`);
|
|
572
|
+
const clearProfile = createActionCreator(`profileSelection/${namespace}/clearProfile`);
|
|
573
|
+
function reducer(state = defaultState, action) {
|
|
574
|
+
if (action.type === REHYDRATE) {
|
|
575
|
+
const persisted = action.payload?.[mountKey];
|
|
576
|
+
return { ...defaultState, ...state, ...persisted };
|
|
577
|
+
}
|
|
578
|
+
if (select.match(action)) return { ...state, [action.payload.seat]: action.payload.profileId };
|
|
579
|
+
if (clearProfile.match(action)) {
|
|
580
|
+
const next = { ...state };
|
|
581
|
+
for (const seat of Object.keys(state)) {
|
|
582
|
+
if (next[seat] === action.payload) next[seat] = null;
|
|
583
|
+
}
|
|
584
|
+
return next;
|
|
585
|
+
}
|
|
586
|
+
return state;
|
|
587
|
+
}
|
|
588
|
+
return { actions: { clearProfile, select }, reducer };
|
|
589
|
+
}
|
|
590
|
+
|
|
542
591
|
// src/redux/profilesSlice.ts
|
|
543
592
|
var setAll = createActionCreator("profiles/setAll");
|
|
544
593
|
var add = createActionCreator("profiles/add");
|
|
@@ -609,19 +658,38 @@ async function resolveInitialProfiles(groupId, localFallback) {
|
|
|
609
658
|
return shared;
|
|
610
659
|
}
|
|
611
660
|
|
|
661
|
+
// src/redux/seatColorsSlice.ts
|
|
662
|
+
var REHYDRATE2 = "persist/REHYDRATE";
|
|
663
|
+
function createSeatColorsSlice(namespace, defaultState, mountKey = "seatColors") {
|
|
664
|
+
const setLastCpuColor = createActionCreator(`seatColors/${namespace}/setLastCpuColor`);
|
|
665
|
+
const setLastGuestColor = createActionCreator(`seatColors/${namespace}/setLastGuestColor`);
|
|
666
|
+
const setProfileOverride = createActionCreator(`seatColors/${namespace}/setProfileOverride`);
|
|
667
|
+
function reducer(state = defaultState, action) {
|
|
668
|
+
if (action.type === REHYDRATE2) {
|
|
669
|
+
const persisted = action.payload?.[mountKey];
|
|
670
|
+
return { ...defaultState, ...state, ...persisted };
|
|
671
|
+
}
|
|
672
|
+
if (setLastCpuColor.match(action)) return { ...state, lastCpuColor: action.payload };
|
|
673
|
+
if (setLastGuestColor.match(action)) return { ...state, lastGuestColor: { ...state.lastGuestColor, [action.payload.seat]: action.payload.color } };
|
|
674
|
+
if (setProfileOverride.match(action)) return { ...state, profileOverride: { ...state.profileOverride, [action.payload.seat]: action.payload.color } };
|
|
675
|
+
return state;
|
|
676
|
+
}
|
|
677
|
+
return { actions: { setLastCpuColor, setLastGuestColor, setProfileOverride }, reducer };
|
|
678
|
+
}
|
|
679
|
+
|
|
612
680
|
// src/redux/useSharedProfilesSync.ts
|
|
613
|
-
var
|
|
614
|
-
var
|
|
681
|
+
var import_react4 = require("react");
|
|
682
|
+
var import_react_native5 = require("react-native");
|
|
615
683
|
function useSharedProfilesSync({ groupId, onRemoteChange }) {
|
|
616
|
-
const pendingWriteRef = (0,
|
|
617
|
-
const onRemoteChangeRef = (0,
|
|
618
|
-
(0,
|
|
684
|
+
const pendingWriteRef = (0, import_react4.useRef)(false);
|
|
685
|
+
const onRemoteChangeRef = (0, import_react4.useRef)(onRemoteChange);
|
|
686
|
+
(0, import_react4.useEffect)(() => {
|
|
619
687
|
onRemoteChangeRef.current = onRemoteChange;
|
|
620
688
|
}, [onRemoteChange]);
|
|
621
|
-
(0,
|
|
689
|
+
(0, import_react4.useEffect)(() => {
|
|
622
690
|
if (!isSharedProfileStoreAvailable) return;
|
|
623
691
|
let cancelled = false;
|
|
624
|
-
const sub =
|
|
692
|
+
const sub = import_react_native5.AppState.addEventListener("change", (next) => {
|
|
625
693
|
if (next !== "active" || pendingWriteRef.current) return;
|
|
626
694
|
loadSharedProfiles(groupId).then((shared) => {
|
|
627
695
|
if (!cancelled) onRemoteChangeRef.current(shared);
|
|
@@ -632,7 +700,7 @@ function useSharedProfilesSync({ groupId, onRemoteChange }) {
|
|
|
632
700
|
sub.remove();
|
|
633
701
|
};
|
|
634
702
|
}, [groupId]);
|
|
635
|
-
const syncToShared = (0,
|
|
703
|
+
const syncToShared = (0, import_react4.useCallback)(
|
|
636
704
|
(next) => {
|
|
637
705
|
if (!isSharedProfileStoreAvailable) return;
|
|
638
706
|
pendingWriteRef.current = true;
|
|
@@ -652,8 +720,11 @@ function useSharedProfilesSync({ groupId, onRemoteChange }) {
|
|
|
652
720
|
ProfileChip,
|
|
653
721
|
ProfilePicker,
|
|
654
722
|
ProfilesManager,
|
|
723
|
+
ProfilesScreen,
|
|
655
724
|
createProfileExtensionSlice,
|
|
656
725
|
createProfileRecord,
|
|
726
|
+
createProfileSelectionSlice,
|
|
727
|
+
createSeatColorsSlice,
|
|
657
728
|
isSharedProfileStoreAvailable,
|
|
658
729
|
isValidProfile,
|
|
659
730
|
isValidTag,
|
package/dist/index.mjs
CHANGED
|
@@ -480,6 +480,29 @@ var styles3 = StyleSheet3.create({
|
|
|
480
480
|
}
|
|
481
481
|
});
|
|
482
482
|
|
|
483
|
+
// src/ProfilesScreen.tsx
|
|
484
|
+
import { useAutoPaperTheme as useAutoPaperTheme2 } from "@rific/auto-paper";
|
|
485
|
+
import { IconButton } from "@rific/feedback-press";
|
|
486
|
+
import { useRef as useRef2 } from "react";
|
|
487
|
+
import { StyleSheet as StyleSheet4, View as View4 } from "react-native";
|
|
488
|
+
import { jsx as jsx4 } from "react/jsx-runtime";
|
|
489
|
+
function ProfilesScreen({ profiles, defaultColor, onCreate, onSave, onDelete, onBack, fg: fgOverride, fgMuted, bg: bgOverride, cardBg, titleVariant, colorPreview }) {
|
|
490
|
+
const { dark } = useAutoPaperTheme2();
|
|
491
|
+
const managerRef = useRef2(null);
|
|
492
|
+
const fg = fgOverride ?? (dark ? "#FFFFFF" : "#000000");
|
|
493
|
+
const bg = bgOverride ?? (dark ? "#000000" : "#FFFFFF");
|
|
494
|
+
const handleBack = () => {
|
|
495
|
+
managerRef.current?.commitPendingEdit();
|
|
496
|
+
onBack();
|
|
497
|
+
};
|
|
498
|
+
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" }) }) });
|
|
499
|
+
}
|
|
500
|
+
var styles4 = StyleSheet4.create({
|
|
501
|
+
container: {
|
|
502
|
+
flex: 1
|
|
503
|
+
}
|
|
504
|
+
});
|
|
505
|
+
|
|
483
506
|
// src/redux/createActionCreator.ts
|
|
484
507
|
function createActionCreator(type) {
|
|
485
508
|
const creator = ((payload) => ({ payload, type }));
|
|
@@ -505,6 +528,29 @@ function createProfileExtensionSlice(namespace) {
|
|
|
505
528
|
return { actions: { remove: remove2, set }, reducer };
|
|
506
529
|
}
|
|
507
530
|
|
|
531
|
+
// src/redux/profileSelectionSlice.ts
|
|
532
|
+
var REHYDRATE = "persist/REHYDRATE";
|
|
533
|
+
function createProfileSelectionSlice(namespace, defaultState, mountKey = "profileSelection") {
|
|
534
|
+
const select = createActionCreator(`profileSelection/${namespace}/select`);
|
|
535
|
+
const clearProfile = createActionCreator(`profileSelection/${namespace}/clearProfile`);
|
|
536
|
+
function reducer(state = defaultState, action) {
|
|
537
|
+
if (action.type === REHYDRATE) {
|
|
538
|
+
const persisted = action.payload?.[mountKey];
|
|
539
|
+
return { ...defaultState, ...state, ...persisted };
|
|
540
|
+
}
|
|
541
|
+
if (select.match(action)) return { ...state, [action.payload.seat]: action.payload.profileId };
|
|
542
|
+
if (clearProfile.match(action)) {
|
|
543
|
+
const next = { ...state };
|
|
544
|
+
for (const seat of Object.keys(state)) {
|
|
545
|
+
if (next[seat] === action.payload) next[seat] = null;
|
|
546
|
+
}
|
|
547
|
+
return next;
|
|
548
|
+
}
|
|
549
|
+
return state;
|
|
550
|
+
}
|
|
551
|
+
return { actions: { clearProfile, select }, reducer };
|
|
552
|
+
}
|
|
553
|
+
|
|
508
554
|
// src/redux/profilesSlice.ts
|
|
509
555
|
var setAll = createActionCreator("profiles/setAll");
|
|
510
556
|
var add = createActionCreator("profiles/add");
|
|
@@ -575,12 +621,31 @@ async function resolveInitialProfiles(groupId, localFallback) {
|
|
|
575
621
|
return shared;
|
|
576
622
|
}
|
|
577
623
|
|
|
624
|
+
// src/redux/seatColorsSlice.ts
|
|
625
|
+
var REHYDRATE2 = "persist/REHYDRATE";
|
|
626
|
+
function createSeatColorsSlice(namespace, defaultState, mountKey = "seatColors") {
|
|
627
|
+
const setLastCpuColor = createActionCreator(`seatColors/${namespace}/setLastCpuColor`);
|
|
628
|
+
const setLastGuestColor = createActionCreator(`seatColors/${namespace}/setLastGuestColor`);
|
|
629
|
+
const setProfileOverride = createActionCreator(`seatColors/${namespace}/setProfileOverride`);
|
|
630
|
+
function reducer(state = defaultState, action) {
|
|
631
|
+
if (action.type === REHYDRATE2) {
|
|
632
|
+
const persisted = action.payload?.[mountKey];
|
|
633
|
+
return { ...defaultState, ...state, ...persisted };
|
|
634
|
+
}
|
|
635
|
+
if (setLastCpuColor.match(action)) return { ...state, lastCpuColor: action.payload };
|
|
636
|
+
if (setLastGuestColor.match(action)) return { ...state, lastGuestColor: { ...state.lastGuestColor, [action.payload.seat]: action.payload.color } };
|
|
637
|
+
if (setProfileOverride.match(action)) return { ...state, profileOverride: { ...state.profileOverride, [action.payload.seat]: action.payload.color } };
|
|
638
|
+
return state;
|
|
639
|
+
}
|
|
640
|
+
return { actions: { setLastCpuColor, setLastGuestColor, setProfileOverride }, reducer };
|
|
641
|
+
}
|
|
642
|
+
|
|
578
643
|
// src/redux/useSharedProfilesSync.ts
|
|
579
|
-
import { useCallback as useCallback2, useEffect as useEffect2, useRef as
|
|
644
|
+
import { useCallback as useCallback2, useEffect as useEffect2, useRef as useRef3 } from "react";
|
|
580
645
|
import { AppState } from "react-native";
|
|
581
646
|
function useSharedProfilesSync({ groupId, onRemoteChange }) {
|
|
582
|
-
const pendingWriteRef =
|
|
583
|
-
const onRemoteChangeRef =
|
|
647
|
+
const pendingWriteRef = useRef3(false);
|
|
648
|
+
const onRemoteChangeRef = useRef3(onRemoteChange);
|
|
584
649
|
useEffect2(() => {
|
|
585
650
|
onRemoteChangeRef.current = onRemoteChange;
|
|
586
651
|
}, [onRemoteChange]);
|
|
@@ -617,8 +682,11 @@ export {
|
|
|
617
682
|
ProfileChip,
|
|
618
683
|
ProfilePicker,
|
|
619
684
|
ProfilesManager,
|
|
685
|
+
ProfilesScreen,
|
|
620
686
|
createProfileExtensionSlice,
|
|
621
687
|
createProfileRecord,
|
|
688
|
+
createProfileSelectionSlice,
|
|
689
|
+
createSeatColorsSlice,
|
|
622
690
|
isSharedProfileStoreAvailable,
|
|
623
691
|
isValidProfile,
|
|
624
692
|
isValidTag,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tastic/profile",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.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,14 +63,14 @@
|
|
|
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
72
|
"@rific/toaster": "^0.6.3",
|
|
73
|
-
"@tastic/core": "^0.
|
|
73
|
+
"@tastic/core": "^0.8.0",
|
|
74
74
|
"@tastic/hud": "^0.8.5",
|
|
75
75
|
"@testing-library/dom": "^10.4.1",
|
|
76
76
|
"@testing-library/react": "^16.3.2",
|
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
2
|
export { ProfilePicker } from './ProfilePicker'
|
|
3
|
-
export { ProfilesManager, type ProfilesManagerHandle, type ProfilesManagerProps } from './ProfilesManager'
|
|
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
|
+
}
|