@tastic/profile 0.3.7 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +44 -5
- package/dist/index.d.mts +67 -2
- package/dist/index.d.ts +67 -2
- package/dist/index.js +130 -20
- package/dist/index.mjs +123 -19
- package/package.json +9 -8
- package/src/ProfilesManager.tsx +43 -18
- package/src/index.ts +4 -0
- package/src/redux/createActionCreator.ts +21 -0
- package/src/redux/profileExtensionSlice.ts +55 -0
- package/src/redux/profilesSlice.ts +47 -0
- package/src/redux/resolveInitialProfiles.ts +26 -0
- package/src/redux/useSharedProfilesSync.ts +74 -0
package/README.md
CHANGED
|
@@ -67,7 +67,8 @@ see its own doc comment for why that component is generic over your own `Profile
|
|
|
67
67
|
TypeScript type.
|
|
68
68
|
|
|
69
69
|
```ts
|
|
70
|
-
//
|
|
70
|
+
// Conceptual sketch of the pattern — see "Redux helpers for the shared roster" below for a
|
|
71
|
+
// ready-made version of it.
|
|
71
72
|
const GROUP_ID = 'group.com.yourteam.yourgames'
|
|
72
73
|
const base = isSharedProfileStoreAvailable ? await loadSharedProfiles(GROUP_ID) : localBaseFallback
|
|
73
74
|
const profiles = base.map((p) => ({ ...p, controlScheme: localExtensions[p.id]?.controlScheme ?? DEFAULT_CONTROL_SCHEME }))
|
|
@@ -87,9 +88,42 @@ see its own doc comment for why that component is generic over your own `Profile
|
|
|
87
88
|
{ "expo": { "ios": { "entitlements": { "com.apple.security.application-groups": ["group.com.yourteam.yourgames"] } } } }
|
|
88
89
|
```
|
|
89
90
|
|
|
90
|
-
|
|
91
|
+
### Redux helpers for the shared roster
|
|
92
|
+
|
|
93
|
+
The cross-app-roster sketch above is a pattern every `@tastic` game was hand-rolling on its own
|
|
94
|
+
(a roster reducer, a per-profile extension table, the initial-load decision, a foreground-resync
|
|
95
|
+
listener). These four pieces are that logic extracted into reusable, Redux-shaped building blocks —
|
|
96
|
+
plain TypeScript, no dependency on `redux`/`react-redux` themselves, so a host app plugs the
|
|
97
|
+
reducer(s) into whatever store it already has:
|
|
98
|
+
|
|
99
|
+
- **`profilesActions` / `profilesReducer` / `createProfileRecord`** — the base roster as a reducer:
|
|
100
|
+
`add`/`update`/`remove`/`setAll` actions, plus `createProfileRecord(input)` to mint a complete
|
|
101
|
+
`Profile` (id + timestamps) ready to dispatch via `profilesActions.add`.
|
|
102
|
+
- **`createProfileExtensionSlice<TExtension>(namespace)`** — a factory for a host app's own
|
|
103
|
+
per-profile extension fields (a control scheme, a key scheme, ...): returns a
|
|
104
|
+
`{ actions: { set, remove }, reducer }` pair over a plain `Record<profileId, TExtension>`,
|
|
105
|
+
namespaced so more than one extension slice in the same app never collides. Not auto-cleared when
|
|
106
|
+
`profilesReducer` removes a profile — an app already dispatches its own cleanup action(s) on
|
|
107
|
+
delete, so this stays a self-contained reducer rather than new cross-slice wiring.
|
|
108
|
+
- **`resolveInitialProfiles(groupId, localFallback)`** — the one-time initial-load decision: shared
|
|
109
|
+
store unavailable → `localFallback` verbatim; shared store empty → seed it from `localFallback`;
|
|
110
|
+
otherwise the shared roster always wins over a possibly-stale local snapshot.
|
|
111
|
+
- **`useSharedProfilesSync({ groupId, onRemoteChange })`** — keeps a Redux-backed roster in sync for
|
|
112
|
+
the rest of the app's lifetime: call the returned `syncToShared(next)` right after a local roster
|
|
113
|
+
change to mirror it out, and `onRemoteChange` fires with a freshly-loaded roster whenever the app
|
|
114
|
+
returns to the foreground (skipped while a `syncToShared` write is still in flight, so a remote
|
|
115
|
+
refresh can't clobber a write that hasn't landed yet).
|
|
116
|
+
|
|
117
|
+
## Install
|
|
91
118
|
|
|
92
|
-
|
|
119
|
+
```bash
|
|
120
|
+
npm install @tastic/profile
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
Published to the public npm registry via tag-based CI (OIDC trusted publishing — see
|
|
124
|
+
`npm run release:patch`/`:minor`/`:major`); no local linking needed for normal use.
|
|
125
|
+
|
|
126
|
+
To develop against a local change before it's published, use `yalc` instead:
|
|
93
127
|
|
|
94
128
|
```bash
|
|
95
129
|
cd react-native-game-profile
|
|
@@ -109,5 +143,10 @@ linked consumer at once.
|
|
|
109
143
|
`react`, `react-native`, `react-native-paper`, `react-native-safe-area-context`, `@rific/auto-paper`
|
|
110
144
|
(`getContrastColor`, `useAutoPaperTheme`), `@rific/feedback-press` (`TouchableRipple`),
|
|
111
145
|
`@rific/focus-chain` (`useFocusChain`), `@rific/toaster` (`useToast`), `@tastic/hud`
|
|
112
|
-
(`InlineColorPicker`, `PopoverBody`, `PopoverHost`, `useAutoAlign`, `usePopoverHost`)
|
|
113
|
-
are bundled, so use whatever versions your app
|
|
146
|
+
(`InlineColorPicker`, `PopoverBody`, `PopoverHost`, `useAutoAlign`, `usePopoverHost`), `@tastic/core`
|
|
147
|
+
(a peer of `@tastic/hud` itself) — none of these are bundled, so use whatever versions your app
|
|
148
|
+
already has.
|
|
149
|
+
|
|
150
|
+
`expo-modules-core` is an *optional* peer — only resolved by `loadSharedProfiles`/`saveSharedProfiles`
|
|
151
|
+
for the native App Group bridge described above; safe to omit entirely on an Android/web-only
|
|
152
|
+
consumer.
|
package/dist/index.d.mts
CHANGED
|
@@ -35,6 +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
39
|
interface ProfileEditPatch {
|
|
39
40
|
name: string;
|
|
40
41
|
color: string;
|
|
@@ -51,16 +52,80 @@ interface ProfilesManagerProps {
|
|
|
51
52
|
onDelete: (id: string) => void;
|
|
52
53
|
headerLeft?: ReactNode;
|
|
53
54
|
ref?: Ref<ProfilesManagerHandle>;
|
|
55
|
+
fg?: string;
|
|
56
|
+
fgMuted?: string;
|
|
57
|
+
cardBg?: string;
|
|
58
|
+
titleVariant?: MD3TextVariant;
|
|
59
|
+
colorPreview?: (hex: string) => string;
|
|
54
60
|
}
|
|
55
|
-
declare function ProfilesManager({ profiles, defaultColor, onCreate, onSave, onDelete, headerLeft, ref }: ProfilesManagerProps): react.JSX.Element;
|
|
61
|
+
declare function ProfilesManager({ profiles, defaultColor, onCreate, onSave, onDelete, headerLeft, ref, fg: fgOverride, fgMuted: fgMutedOverride, cardBg: cardBgOverride, titleVariant, colorPreview }: ProfilesManagerProps): react.JSX.Element;
|
|
56
62
|
|
|
57
63
|
declare const MAX_PROFILE_NAME_LENGTH = 20;
|
|
58
64
|
declare const MAX_TAG_LENGTH = 3;
|
|
59
65
|
declare function isValidTag(value: unknown): value is string;
|
|
60
66
|
declare function isValidProfile(value: unknown): value is Profile;
|
|
61
67
|
|
|
68
|
+
interface ReduxAction<P> {
|
|
69
|
+
payload: P;
|
|
70
|
+
type: string;
|
|
71
|
+
}
|
|
72
|
+
type ActionCreator<P> = ((payload: P) => ReduxAction<P>) & {
|
|
73
|
+
type: string;
|
|
74
|
+
match: (action: {
|
|
75
|
+
type: string;
|
|
76
|
+
}) => action is ReduxAction<P>;
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
interface SetProfileExtensionPayload<TExtension> {
|
|
80
|
+
extension: TExtension;
|
|
81
|
+
id: string;
|
|
82
|
+
}
|
|
83
|
+
interface ProfileExtensionActions<TExtension> {
|
|
84
|
+
remove: ActionCreator<string>;
|
|
85
|
+
set: ActionCreator<SetProfileExtensionPayload<TExtension>>;
|
|
86
|
+
}
|
|
87
|
+
type ProfileExtensionState<TExtension> = Record<string, TExtension>;
|
|
88
|
+
interface ProfileExtensionSlice<TExtension> {
|
|
89
|
+
actions: ProfileExtensionActions<TExtension>;
|
|
90
|
+
reducer: (state: ProfileExtensionState<TExtension> | undefined, action: {
|
|
91
|
+
type: string;
|
|
92
|
+
}) => ProfileExtensionState<TExtension>;
|
|
93
|
+
}
|
|
94
|
+
declare function createProfileExtensionSlice<TExtension>(namespace: string): ProfileExtensionSlice<TExtension>;
|
|
95
|
+
|
|
96
|
+
interface UpdateProfilePayload {
|
|
97
|
+
id: string;
|
|
98
|
+
patch: Partial<Pick<Profile, 'name' | 'color' | 'tag'>>;
|
|
99
|
+
}
|
|
100
|
+
declare const profilesActions: {
|
|
101
|
+
add: ActionCreator<Profile>;
|
|
102
|
+
remove: ActionCreator<string>;
|
|
103
|
+
setAll: ActionCreator<Profile[]>;
|
|
104
|
+
update: ActionCreator<UpdateProfilePayload>;
|
|
105
|
+
};
|
|
106
|
+
declare function profilesReducer(state: Profile[] | undefined, action: {
|
|
107
|
+
type: string;
|
|
108
|
+
}): Profile[];
|
|
109
|
+
interface CreateProfileInput {
|
|
110
|
+
name: string;
|
|
111
|
+
color: string;
|
|
112
|
+
tag: string;
|
|
113
|
+
}
|
|
114
|
+
declare function createProfileRecord(input: CreateProfileInput): Profile;
|
|
115
|
+
|
|
116
|
+
declare function resolveInitialProfiles(groupId: string, localFallback: Profile[]): Promise<Profile[]>;
|
|
117
|
+
|
|
118
|
+
interface UseSharedProfilesSyncOptions {
|
|
119
|
+
groupId: string;
|
|
120
|
+
onRemoteChange: (profiles: Profile[]) => void;
|
|
121
|
+
}
|
|
122
|
+
interface UseSharedProfilesSyncResult {
|
|
123
|
+
syncToShared: (next: Profile[]) => void;
|
|
124
|
+
}
|
|
125
|
+
declare function useSharedProfilesSync({ groupId, onRemoteChange }: UseSharedProfilesSyncOptions): UseSharedProfilesSyncResult;
|
|
126
|
+
|
|
62
127
|
declare const isSharedProfileStoreAvailable: boolean;
|
|
63
128
|
declare function loadSharedProfiles(groupId: string): Promise<Profile[]>;
|
|
64
129
|
declare function saveSharedProfiles(groupId: string, profiles: Profile[]): Promise<void>;
|
|
65
130
|
|
|
66
|
-
export { MAX_PROFILE_NAME_LENGTH, MAX_TAG_LENGTH, type Profile, ProfileChip, ProfilePicker, ProfilesManager, type ProfilesManagerHandle, type ProfilesManagerProps, isSharedProfileStoreAvailable, isValidProfile, isValidTag, loadSharedProfiles, saveSharedProfiles };
|
|
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 };
|
package/dist/index.d.ts
CHANGED
|
@@ -35,6 +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
39
|
interface ProfileEditPatch {
|
|
39
40
|
name: string;
|
|
40
41
|
color: string;
|
|
@@ -51,16 +52,80 @@ interface ProfilesManagerProps {
|
|
|
51
52
|
onDelete: (id: string) => void;
|
|
52
53
|
headerLeft?: ReactNode;
|
|
53
54
|
ref?: Ref<ProfilesManagerHandle>;
|
|
55
|
+
fg?: string;
|
|
56
|
+
fgMuted?: string;
|
|
57
|
+
cardBg?: string;
|
|
58
|
+
titleVariant?: MD3TextVariant;
|
|
59
|
+
colorPreview?: (hex: string) => string;
|
|
54
60
|
}
|
|
55
|
-
declare function ProfilesManager({ profiles, defaultColor, onCreate, onSave, onDelete, headerLeft, ref }: ProfilesManagerProps): react.JSX.Element;
|
|
61
|
+
declare function ProfilesManager({ profiles, defaultColor, onCreate, onSave, onDelete, headerLeft, ref, fg: fgOverride, fgMuted: fgMutedOverride, cardBg: cardBgOverride, titleVariant, colorPreview }: ProfilesManagerProps): react.JSX.Element;
|
|
56
62
|
|
|
57
63
|
declare const MAX_PROFILE_NAME_LENGTH = 20;
|
|
58
64
|
declare const MAX_TAG_LENGTH = 3;
|
|
59
65
|
declare function isValidTag(value: unknown): value is string;
|
|
60
66
|
declare function isValidProfile(value: unknown): value is Profile;
|
|
61
67
|
|
|
68
|
+
interface ReduxAction<P> {
|
|
69
|
+
payload: P;
|
|
70
|
+
type: string;
|
|
71
|
+
}
|
|
72
|
+
type ActionCreator<P> = ((payload: P) => ReduxAction<P>) & {
|
|
73
|
+
type: string;
|
|
74
|
+
match: (action: {
|
|
75
|
+
type: string;
|
|
76
|
+
}) => action is ReduxAction<P>;
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
interface SetProfileExtensionPayload<TExtension> {
|
|
80
|
+
extension: TExtension;
|
|
81
|
+
id: string;
|
|
82
|
+
}
|
|
83
|
+
interface ProfileExtensionActions<TExtension> {
|
|
84
|
+
remove: ActionCreator<string>;
|
|
85
|
+
set: ActionCreator<SetProfileExtensionPayload<TExtension>>;
|
|
86
|
+
}
|
|
87
|
+
type ProfileExtensionState<TExtension> = Record<string, TExtension>;
|
|
88
|
+
interface ProfileExtensionSlice<TExtension> {
|
|
89
|
+
actions: ProfileExtensionActions<TExtension>;
|
|
90
|
+
reducer: (state: ProfileExtensionState<TExtension> | undefined, action: {
|
|
91
|
+
type: string;
|
|
92
|
+
}) => ProfileExtensionState<TExtension>;
|
|
93
|
+
}
|
|
94
|
+
declare function createProfileExtensionSlice<TExtension>(namespace: string): ProfileExtensionSlice<TExtension>;
|
|
95
|
+
|
|
96
|
+
interface UpdateProfilePayload {
|
|
97
|
+
id: string;
|
|
98
|
+
patch: Partial<Pick<Profile, 'name' | 'color' | 'tag'>>;
|
|
99
|
+
}
|
|
100
|
+
declare const profilesActions: {
|
|
101
|
+
add: ActionCreator<Profile>;
|
|
102
|
+
remove: ActionCreator<string>;
|
|
103
|
+
setAll: ActionCreator<Profile[]>;
|
|
104
|
+
update: ActionCreator<UpdateProfilePayload>;
|
|
105
|
+
};
|
|
106
|
+
declare function profilesReducer(state: Profile[] | undefined, action: {
|
|
107
|
+
type: string;
|
|
108
|
+
}): Profile[];
|
|
109
|
+
interface CreateProfileInput {
|
|
110
|
+
name: string;
|
|
111
|
+
color: string;
|
|
112
|
+
tag: string;
|
|
113
|
+
}
|
|
114
|
+
declare function createProfileRecord(input: CreateProfileInput): Profile;
|
|
115
|
+
|
|
116
|
+
declare function resolveInitialProfiles(groupId: string, localFallback: Profile[]): Promise<Profile[]>;
|
|
117
|
+
|
|
118
|
+
interface UseSharedProfilesSyncOptions {
|
|
119
|
+
groupId: string;
|
|
120
|
+
onRemoteChange: (profiles: Profile[]) => void;
|
|
121
|
+
}
|
|
122
|
+
interface UseSharedProfilesSyncResult {
|
|
123
|
+
syncToShared: (next: Profile[]) => void;
|
|
124
|
+
}
|
|
125
|
+
declare function useSharedProfilesSync({ groupId, onRemoteChange }: UseSharedProfilesSyncOptions): UseSharedProfilesSyncResult;
|
|
126
|
+
|
|
62
127
|
declare const isSharedProfileStoreAvailable: boolean;
|
|
63
128
|
declare function loadSharedProfiles(groupId: string): Promise<Profile[]>;
|
|
64
129
|
declare function saveSharedProfiles(groupId: string, profiles: Profile[]): Promise<void>;
|
|
65
130
|
|
|
66
|
-
export { MAX_PROFILE_NAME_LENGTH, MAX_TAG_LENGTH, type Profile, ProfileChip, ProfilePicker, ProfilesManager, type ProfilesManagerHandle, type ProfilesManagerProps, isSharedProfileStoreAvailable, isValidProfile, isValidTag, loadSharedProfiles, saveSharedProfiles };
|
|
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 };
|
package/dist/index.js
CHANGED
|
@@ -25,11 +25,17 @@ __export(index_exports, {
|
|
|
25
25
|
ProfileChip: () => ProfileChip,
|
|
26
26
|
ProfilePicker: () => ProfilePicker,
|
|
27
27
|
ProfilesManager: () => ProfilesManager,
|
|
28
|
+
createProfileExtensionSlice: () => createProfileExtensionSlice,
|
|
29
|
+
createProfileRecord: () => createProfileRecord,
|
|
28
30
|
isSharedProfileStoreAvailable: () => isSharedProfileStoreAvailable,
|
|
29
31
|
isValidProfile: () => isValidProfile,
|
|
30
32
|
isValidTag: () => isValidTag,
|
|
31
33
|
loadSharedProfiles: () => loadSharedProfiles,
|
|
32
|
-
|
|
34
|
+
profilesActions: () => profilesActions,
|
|
35
|
+
profilesReducer: () => profilesReducer,
|
|
36
|
+
resolveInitialProfiles: () => resolveInitialProfiles,
|
|
37
|
+
saveSharedProfiles: () => saveSharedProfiles,
|
|
38
|
+
useSharedProfilesSync: () => useSharedProfilesSync
|
|
33
39
|
});
|
|
34
40
|
module.exports = __toCommonJS(index_exports);
|
|
35
41
|
|
|
@@ -287,14 +293,14 @@ function deriveTag(name) {
|
|
|
287
293
|
const trimmed = name.trim();
|
|
288
294
|
return trimmed ? trimmed[0].toUpperCase() : "";
|
|
289
295
|
}
|
|
290
|
-
function ProfilesManager({ profiles, defaultColor, onCreate, onSave, onDelete, headerLeft, ref }) {
|
|
296
|
+
function ProfilesManager({ profiles, defaultColor, onCreate, onSave, onDelete, headerLeft, ref, fg: fgOverride, fgMuted: fgMutedOverride, cardBg: cardBgOverride, titleVariant = "headlineSmall", colorPreview }) {
|
|
291
297
|
const { dark, colors } = (0, import_auto_paper3.useAutoPaperTheme)();
|
|
292
298
|
const insets = (0, import_react_native_safe_area_context.useSafeAreaInsets)();
|
|
293
299
|
const { error: showErrorToast } = (0, import_toaster.useToast)();
|
|
294
300
|
const host = (0, import_hud2.usePopoverHost)();
|
|
295
|
-
const fg = dark ? "#FFFFFF" : "#000000";
|
|
296
|
-
const fgMuted = dark ? "rgba(255,255,255,0.5)" : "rgba(0,0,0,0.5)";
|
|
297
|
-
const cardBg = dark ? "#111111" : "#F2F2F2";
|
|
301
|
+
const fg = fgOverride ?? (dark ? "#FFFFFF" : "#000000");
|
|
302
|
+
const fgMuted = fgMutedOverride ?? (dark ? "rgba(255,255,255,0.5)" : "rgba(0,0,0,0.5)");
|
|
303
|
+
const cardBg = cardBgOverride ?? (dark ? "#111111" : "#F2F2F2");
|
|
298
304
|
const cardBorder = dark ? "rgba(255,255,255,0.2)" : "rgba(0,0,0,0.2)";
|
|
299
305
|
const cancelBg = dark ? "#2A2A2A" : "#E0E0E0";
|
|
300
306
|
const cancelFg = dark ? "#FFFFFF" : "#000000";
|
|
@@ -363,7 +369,7 @@ function ProfilesManager({ profiles, defaultColor, onCreate, onSave, onDelete, h
|
|
|
363
369
|
return /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)(import_jsx_runtime3.Fragment, { children: [
|
|
364
370
|
/* @__PURE__ */ (0, import_jsx_runtime3.jsxs)(import_react_native3.View, { style: [styles3.header, { paddingTop: 8 + insets.top, paddingLeft: 8 + insets.left }], children: [
|
|
365
371
|
headerLeft,
|
|
366
|
-
/* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_react_native_paper3.Text, { variant:
|
|
372
|
+
/* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_react_native_paper3.Text, { variant: titleVariant, style: [styles3.title, { color: fg }], children: "Profiles" })
|
|
367
373
|
] }),
|
|
368
374
|
/* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_react_native3.KeyboardAvoidingView, { behavior: import_react_native3.Platform.OS === "ios" ? "padding" : void 0, style: styles3.keyboardAvoiding, children: /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_react_native3.ScrollView, { style: styles3.scrollView, contentContainerStyle: [styles3.content, { paddingBottom: 32 + insets.bottom }], showsVerticalScrollIndicator: false, keyboardShouldPersistTaps: "handled", children: rows.map((row) => {
|
|
369
375
|
if (row.kind === "new") {
|
|
@@ -373,11 +379,11 @@ function ProfilesManager({ profiles, defaultColor, onCreate, onSave, onDelete, h
|
|
|
373
379
|
/* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_react_native_paper3.Text, { variant: "bodyLarge", style: { color: fg }, children: "New Profile" })
|
|
374
380
|
] }) }, "new");
|
|
375
381
|
}
|
|
376
|
-
return /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(EditRow, { draftName, onNameChange: handleNameChange, draftColor, onColorChange: setDraftColor, draftTag, onTagChange: handleTagChange, onSubmit: commitEdit, autoFocus: true, fgMuted, host, dark }, "new");
|
|
382
|
+
return /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(EditRow, { draftName, onNameChange: handleNameChange, draftColor, onColorChange: setDraftColor, draftTag, onTagChange: handleTagChange, onSubmit: commitEdit, autoFocus: true, fgMuted, host, dark, colorPreview }, "new");
|
|
377
383
|
}
|
|
378
384
|
const { profile } = row;
|
|
379
385
|
if (editingId === profile.id) {
|
|
380
|
-
return /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(EditRow, { draftName, onNameChange: handleNameChange, draftColor, onColorChange: setDraftColor, draftTag, onTagChange: handleTagChange, onSubmit: commitEdit, onDelete: () => setConfirmDeleteId(profile.id), fgMuted, host, dark }, profile.id);
|
|
386
|
+
return /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(EditRow, { draftName, onNameChange: handleNameChange, draftColor, onColorChange: setDraftColor, draftTag, onTagChange: handleTagChange, onSubmit: commitEdit, onDelete: () => setConfirmDeleteId(profile.id), fgMuted, host, dark, colorPreview }, profile.id);
|
|
381
387
|
}
|
|
382
388
|
return /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_feedback_press2.TouchableRipple, { onPress: () => startEdit(profile), style: styles3.row, children: /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)(import_react_native3.View, { style: styles3.rowInner, children: [
|
|
383
389
|
/* @__PURE__ */ (0, import_jsx_runtime3.jsx)(ProfileChip, { profile, size: CHIP_SIZE }),
|
|
@@ -397,7 +403,7 @@ function ProfilesManager({ profiles, defaultColor, onCreate, onSave, onDelete, h
|
|
|
397
403
|
] }) }) })
|
|
398
404
|
] });
|
|
399
405
|
}
|
|
400
|
-
function EditRow({ draftName, onNameChange, draftColor, onColorChange, draftTag, onTagChange, onSubmit, onDelete, autoFocus, fgMuted, host, dark }) {
|
|
406
|
+
function EditRow({ draftName, onNameChange, draftColor, onColorChange, draftTag, onTagChange, onSubmit, onDelete, autoFocus, fgMuted, host, dark, colorPreview }) {
|
|
401
407
|
const register = (0, import_focus_chain.useFocusChain)();
|
|
402
408
|
const tag = register();
|
|
403
409
|
const name = register();
|
|
@@ -409,7 +415,7 @@ function EditRow({ draftName, onNameChange, draftColor, onColorChange, draftTag,
|
|
|
409
415
|
if (host.openId === "color") tagFocusRef.current();
|
|
410
416
|
}, [host.openId]);
|
|
411
417
|
return /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)(import_react_native3.View, { style: styles3.editRow, children: [
|
|
412
|
-
/* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_hud2.InlineColorPicker, { id: "color", host, value: draftColor, onChange: onColorChange, tag: draftTag, dark }),
|
|
418
|
+
/* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_hud2.InlineColorPicker, { id: "color", host, value: draftColor, onChange: onColorChange, previewValue: colorPreview, tag: draftTag, dark }),
|
|
413
419
|
/* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_react_native_paper3.TextInput, { ref: tag.ref, ...tag.props, value: draftTag, onChangeText: onTagChange, accessibilityLabel: "Tag", autoCapitalize: "characters", selectTextOnFocus: true, returnKeyType: "next", style: styles3.hiddenTagInput, pointerEvents: "none" }),
|
|
414
420
|
/* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_react_native_paper3.TextInput, { ref: name.ref, ...name.props, onSubmitEditing: onSubmit, mode: "outlined", dense: true, value: draftName, onChangeText: onNameChange, maxLength: MAX_PROFILE_NAME_LENGTH, placeholder: "Name", autoFocus, returnKeyType: "done", style: styles3.nameInput }),
|
|
415
421
|
onDelete && /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_feedback_press2.TouchableRipple, { onPress: onDelete, style: styles3.iconButton, children: /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_react_native_paper3.Icon, { source: "trash-can-outline", size: 18, color: fgMuted }) })
|
|
@@ -432,6 +438,15 @@ var styles3 = import_react_native3.StyleSheet.create({
|
|
|
432
438
|
gap: 4,
|
|
433
439
|
paddingBottom: 8
|
|
434
440
|
},
|
|
441
|
+
// 1x1 and fully transparent — there's nothing to see here on purpose (see EditRow's own comment
|
|
442
|
+
// above the field this styles). position: 'absolute' pulls it out of editRow's flex flow
|
|
443
|
+
// entirely, so it doesn't reserve a gap in the row the way a normal flex sibling would.
|
|
444
|
+
hiddenTagInput: {
|
|
445
|
+
height: 1,
|
|
446
|
+
opacity: 0,
|
|
447
|
+
position: "absolute",
|
|
448
|
+
width: 1
|
|
449
|
+
},
|
|
435
450
|
iconButton: {
|
|
436
451
|
alignItems: "center",
|
|
437
452
|
borderRadius: 8,
|
|
@@ -493,21 +508,61 @@ var styles3 = import_react_native3.StyleSheet.create({
|
|
|
493
508
|
scrollView: {
|
|
494
509
|
flex: 1
|
|
495
510
|
},
|
|
496
|
-
// 1x1 and fully transparent — there's nothing to see here on purpose (see EditRow's own comment
|
|
497
|
-
// above the field this styles). position: 'absolute' pulls it out of editRow's flex flow
|
|
498
|
-
// entirely, so it doesn't reserve a gap in the row the way a normal flex sibling would.
|
|
499
|
-
hiddenTagInput: {
|
|
500
|
-
height: 1,
|
|
501
|
-
opacity: 0,
|
|
502
|
-
position: "absolute",
|
|
503
|
-
width: 1
|
|
504
|
-
},
|
|
505
511
|
title: {
|
|
506
512
|
flexShrink: 1,
|
|
507
513
|
fontWeight: "bold"
|
|
508
514
|
}
|
|
509
515
|
});
|
|
510
516
|
|
|
517
|
+
// src/redux/createActionCreator.ts
|
|
518
|
+
function createActionCreator(type) {
|
|
519
|
+
const creator = ((payload) => ({ payload, type }));
|
|
520
|
+
creator.type = type;
|
|
521
|
+
creator.match = (action) => action.type === type;
|
|
522
|
+
return creator;
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
// src/redux/profileExtensionSlice.ts
|
|
526
|
+
function createProfileExtensionSlice(namespace) {
|
|
527
|
+
const set = createActionCreator(`profileExtension/${namespace}/set`);
|
|
528
|
+
const remove2 = createActionCreator(`profileExtension/${namespace}/remove`);
|
|
529
|
+
function reducer(state = {}, action) {
|
|
530
|
+
if (set.match(action)) return { ...state, [action.payload.id]: action.payload.extension };
|
|
531
|
+
if (remove2.match(action)) {
|
|
532
|
+
if (!(action.payload in state)) return state;
|
|
533
|
+
const next = { ...state };
|
|
534
|
+
delete next[action.payload];
|
|
535
|
+
return next;
|
|
536
|
+
}
|
|
537
|
+
return state;
|
|
538
|
+
}
|
|
539
|
+
return { actions: { remove: remove2, set }, reducer };
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
// src/redux/profilesSlice.ts
|
|
543
|
+
var setAll = createActionCreator("profiles/setAll");
|
|
544
|
+
var add = createActionCreator("profiles/add");
|
|
545
|
+
var update = createActionCreator("profiles/update");
|
|
546
|
+
var remove = createActionCreator("profiles/remove");
|
|
547
|
+
var profilesActions = { add, remove, setAll, update };
|
|
548
|
+
function profilesReducer(state = [], action) {
|
|
549
|
+
if (setAll.match(action)) return action.payload;
|
|
550
|
+
if (add.match(action)) return [...state, action.payload];
|
|
551
|
+
if (update.match(action)) {
|
|
552
|
+
const { id, patch } = action.payload;
|
|
553
|
+
return state.map((p) => p.id === id ? { ...p, ...patch, updatedAt: Date.now() } : p);
|
|
554
|
+
}
|
|
555
|
+
if (remove.match(action)) return state.filter((p) => p.id !== action.payload);
|
|
556
|
+
return state;
|
|
557
|
+
}
|
|
558
|
+
function generateProfileId() {
|
|
559
|
+
return `profile-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
560
|
+
}
|
|
561
|
+
function createProfileRecord(input) {
|
|
562
|
+
const now = Date.now();
|
|
563
|
+
return { color: input.color, createdAt: now, id: generateProfileId(), name: input.name, tag: input.tag, updatedAt: now };
|
|
564
|
+
}
|
|
565
|
+
|
|
511
566
|
// src/sharedProfileStore.ts
|
|
512
567
|
var native = null;
|
|
513
568
|
try {
|
|
@@ -541,6 +596,55 @@ async function saveSharedProfiles(groupId, profiles) {
|
|
|
541
596
|
} catch {
|
|
542
597
|
}
|
|
543
598
|
}
|
|
599
|
+
|
|
600
|
+
// src/redux/resolveInitialProfiles.ts
|
|
601
|
+
async function resolveInitialProfiles(groupId, localFallback) {
|
|
602
|
+
if (!isSharedProfileStoreAvailable) return localFallback;
|
|
603
|
+
const shared = await loadSharedProfiles(groupId);
|
|
604
|
+
if (shared.length === 0 && localFallback.length > 0) {
|
|
605
|
+
saveSharedProfiles(groupId, localFallback).catch(() => {
|
|
606
|
+
});
|
|
607
|
+
return localFallback;
|
|
608
|
+
}
|
|
609
|
+
return shared;
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
// src/redux/useSharedProfilesSync.ts
|
|
613
|
+
var import_react3 = require("react");
|
|
614
|
+
var import_react_native4 = require("react-native");
|
|
615
|
+
function useSharedProfilesSync({ groupId, onRemoteChange }) {
|
|
616
|
+
const pendingWriteRef = (0, import_react3.useRef)(false);
|
|
617
|
+
const onRemoteChangeRef = (0, import_react3.useRef)(onRemoteChange);
|
|
618
|
+
(0, import_react3.useEffect)(() => {
|
|
619
|
+
onRemoteChangeRef.current = onRemoteChange;
|
|
620
|
+
}, [onRemoteChange]);
|
|
621
|
+
(0, import_react3.useEffect)(() => {
|
|
622
|
+
if (!isSharedProfileStoreAvailable) return;
|
|
623
|
+
let cancelled = false;
|
|
624
|
+
const sub = import_react_native4.AppState.addEventListener("change", (next) => {
|
|
625
|
+
if (next !== "active" || pendingWriteRef.current) return;
|
|
626
|
+
loadSharedProfiles(groupId).then((shared) => {
|
|
627
|
+
if (!cancelled) onRemoteChangeRef.current(shared);
|
|
628
|
+
});
|
|
629
|
+
});
|
|
630
|
+
return () => {
|
|
631
|
+
cancelled = true;
|
|
632
|
+
sub.remove();
|
|
633
|
+
};
|
|
634
|
+
}, [groupId]);
|
|
635
|
+
const syncToShared = (0, import_react3.useCallback)(
|
|
636
|
+
(next) => {
|
|
637
|
+
if (!isSharedProfileStoreAvailable) return;
|
|
638
|
+
pendingWriteRef.current = true;
|
|
639
|
+
saveSharedProfiles(groupId, next).catch(() => {
|
|
640
|
+
}).finally(() => {
|
|
641
|
+
pendingWriteRef.current = false;
|
|
642
|
+
});
|
|
643
|
+
},
|
|
644
|
+
[groupId]
|
|
645
|
+
);
|
|
646
|
+
return { syncToShared };
|
|
647
|
+
}
|
|
544
648
|
// Annotate the CommonJS export names for ESM import in node:
|
|
545
649
|
0 && (module.exports = {
|
|
546
650
|
MAX_PROFILE_NAME_LENGTH,
|
|
@@ -548,9 +652,15 @@ async function saveSharedProfiles(groupId, profiles) {
|
|
|
548
652
|
ProfileChip,
|
|
549
653
|
ProfilePicker,
|
|
550
654
|
ProfilesManager,
|
|
655
|
+
createProfileExtensionSlice,
|
|
656
|
+
createProfileRecord,
|
|
551
657
|
isSharedProfileStoreAvailable,
|
|
552
658
|
isValidProfile,
|
|
553
659
|
isValidTag,
|
|
554
660
|
loadSharedProfiles,
|
|
555
|
-
|
|
661
|
+
profilesActions,
|
|
662
|
+
profilesReducer,
|
|
663
|
+
resolveInitialProfiles,
|
|
664
|
+
saveSharedProfiles,
|
|
665
|
+
useSharedProfilesSync
|
|
556
666
|
});
|
package/dist/index.mjs
CHANGED
|
@@ -259,14 +259,14 @@ function deriveTag(name) {
|
|
|
259
259
|
const trimmed = name.trim();
|
|
260
260
|
return trimmed ? trimmed[0].toUpperCase() : "";
|
|
261
261
|
}
|
|
262
|
-
function ProfilesManager({ profiles, defaultColor, onCreate, onSave, onDelete, headerLeft, ref }) {
|
|
262
|
+
function ProfilesManager({ profiles, defaultColor, onCreate, onSave, onDelete, headerLeft, ref, fg: fgOverride, fgMuted: fgMutedOverride, cardBg: cardBgOverride, titleVariant = "headlineSmall", colorPreview }) {
|
|
263
263
|
const { dark, colors } = useAutoPaperTheme();
|
|
264
264
|
const insets = useSafeAreaInsets();
|
|
265
265
|
const { error: showErrorToast } = useToast();
|
|
266
266
|
const host = usePopoverHost();
|
|
267
|
-
const fg = dark ? "#FFFFFF" : "#000000";
|
|
268
|
-
const fgMuted = dark ? "rgba(255,255,255,0.5)" : "rgba(0,0,0,0.5)";
|
|
269
|
-
const cardBg = dark ? "#111111" : "#F2F2F2";
|
|
267
|
+
const fg = fgOverride ?? (dark ? "#FFFFFF" : "#000000");
|
|
268
|
+
const fgMuted = fgMutedOverride ?? (dark ? "rgba(255,255,255,0.5)" : "rgba(0,0,0,0.5)");
|
|
269
|
+
const cardBg = cardBgOverride ?? (dark ? "#111111" : "#F2F2F2");
|
|
270
270
|
const cardBorder = dark ? "rgba(255,255,255,0.2)" : "rgba(0,0,0,0.2)";
|
|
271
271
|
const cancelBg = dark ? "#2A2A2A" : "#E0E0E0";
|
|
272
272
|
const cancelFg = dark ? "#FFFFFF" : "#000000";
|
|
@@ -335,7 +335,7 @@ function ProfilesManager({ profiles, defaultColor, onCreate, onSave, onDelete, h
|
|
|
335
335
|
return /* @__PURE__ */ jsxs2(Fragment2, { children: [
|
|
336
336
|
/* @__PURE__ */ jsxs2(View3, { style: [styles3.header, { paddingTop: 8 + insets.top, paddingLeft: 8 + insets.left }], children: [
|
|
337
337
|
headerLeft,
|
|
338
|
-
/* @__PURE__ */ jsx3(Text3, { variant:
|
|
338
|
+
/* @__PURE__ */ jsx3(Text3, { variant: titleVariant, style: [styles3.title, { color: fg }], children: "Profiles" })
|
|
339
339
|
] }),
|
|
340
340
|
/* @__PURE__ */ jsx3(KeyboardAvoidingView, { behavior: Platform.OS === "ios" ? "padding" : void 0, style: styles3.keyboardAvoiding, children: /* @__PURE__ */ jsx3(ScrollView2, { style: styles3.scrollView, contentContainerStyle: [styles3.content, { paddingBottom: 32 + insets.bottom }], showsVerticalScrollIndicator: false, keyboardShouldPersistTaps: "handled", children: rows.map((row) => {
|
|
341
341
|
if (row.kind === "new") {
|
|
@@ -345,11 +345,11 @@ function ProfilesManager({ profiles, defaultColor, onCreate, onSave, onDelete, h
|
|
|
345
345
|
/* @__PURE__ */ jsx3(Text3, { variant: "bodyLarge", style: { color: fg }, children: "New Profile" })
|
|
346
346
|
] }) }, "new");
|
|
347
347
|
}
|
|
348
|
-
return /* @__PURE__ */ jsx3(EditRow, { draftName, onNameChange: handleNameChange, draftColor, onColorChange: setDraftColor, draftTag, onTagChange: handleTagChange, onSubmit: commitEdit, autoFocus: true, fgMuted, host, dark }, "new");
|
|
348
|
+
return /* @__PURE__ */ jsx3(EditRow, { draftName, onNameChange: handleNameChange, draftColor, onColorChange: setDraftColor, draftTag, onTagChange: handleTagChange, onSubmit: commitEdit, autoFocus: true, fgMuted, host, dark, colorPreview }, "new");
|
|
349
349
|
}
|
|
350
350
|
const { profile } = row;
|
|
351
351
|
if (editingId === profile.id) {
|
|
352
|
-
return /* @__PURE__ */ jsx3(EditRow, { draftName, onNameChange: handleNameChange, draftColor, onColorChange: setDraftColor, draftTag, onTagChange: handleTagChange, onSubmit: commitEdit, onDelete: () => setConfirmDeleteId(profile.id), fgMuted, host, dark }, profile.id);
|
|
352
|
+
return /* @__PURE__ */ jsx3(EditRow, { draftName, onNameChange: handleNameChange, draftColor, onColorChange: setDraftColor, draftTag, onTagChange: handleTagChange, onSubmit: commitEdit, onDelete: () => setConfirmDeleteId(profile.id), fgMuted, host, dark, colorPreview }, profile.id);
|
|
353
353
|
}
|
|
354
354
|
return /* @__PURE__ */ jsx3(TouchableRipple2, { onPress: () => startEdit(profile), style: styles3.row, children: /* @__PURE__ */ jsxs2(View3, { style: styles3.rowInner, children: [
|
|
355
355
|
/* @__PURE__ */ jsx3(ProfileChip, { profile, size: CHIP_SIZE }),
|
|
@@ -369,7 +369,7 @@ function ProfilesManager({ profiles, defaultColor, onCreate, onSave, onDelete, h
|
|
|
369
369
|
] }) }) })
|
|
370
370
|
] });
|
|
371
371
|
}
|
|
372
|
-
function EditRow({ draftName, onNameChange, draftColor, onColorChange, draftTag, onTagChange, onSubmit, onDelete, autoFocus, fgMuted, host, dark }) {
|
|
372
|
+
function EditRow({ draftName, onNameChange, draftColor, onColorChange, draftTag, onTagChange, onSubmit, onDelete, autoFocus, fgMuted, host, dark, colorPreview }) {
|
|
373
373
|
const register = useFocusChain();
|
|
374
374
|
const tag = register();
|
|
375
375
|
const name = register();
|
|
@@ -381,7 +381,7 @@ function EditRow({ draftName, onNameChange, draftColor, onColorChange, draftTag,
|
|
|
381
381
|
if (host.openId === "color") tagFocusRef.current();
|
|
382
382
|
}, [host.openId]);
|
|
383
383
|
return /* @__PURE__ */ jsxs2(View3, { style: styles3.editRow, children: [
|
|
384
|
-
/* @__PURE__ */ jsx3(InlineColorPicker, { id: "color", host, value: draftColor, onChange: onColorChange, tag: draftTag, dark }),
|
|
384
|
+
/* @__PURE__ */ jsx3(InlineColorPicker, { id: "color", host, value: draftColor, onChange: onColorChange, previewValue: colorPreview, tag: draftTag, dark }),
|
|
385
385
|
/* @__PURE__ */ jsx3(TextInput, { ref: tag.ref, ...tag.props, value: draftTag, onChangeText: onTagChange, accessibilityLabel: "Tag", autoCapitalize: "characters", selectTextOnFocus: true, returnKeyType: "next", style: styles3.hiddenTagInput, pointerEvents: "none" }),
|
|
386
386
|
/* @__PURE__ */ jsx3(TextInput, { ref: name.ref, ...name.props, onSubmitEditing: onSubmit, mode: "outlined", dense: true, value: draftName, onChangeText: onNameChange, maxLength: MAX_PROFILE_NAME_LENGTH, placeholder: "Name", autoFocus, returnKeyType: "done", style: styles3.nameInput }),
|
|
387
387
|
onDelete && /* @__PURE__ */ jsx3(TouchableRipple2, { onPress: onDelete, style: styles3.iconButton, children: /* @__PURE__ */ jsx3(Icon3, { source: "trash-can-outline", size: 18, color: fgMuted }) })
|
|
@@ -404,6 +404,15 @@ var styles3 = StyleSheet3.create({
|
|
|
404
404
|
gap: 4,
|
|
405
405
|
paddingBottom: 8
|
|
406
406
|
},
|
|
407
|
+
// 1x1 and fully transparent — there's nothing to see here on purpose (see EditRow's own comment
|
|
408
|
+
// above the field this styles). position: 'absolute' pulls it out of editRow's flex flow
|
|
409
|
+
// entirely, so it doesn't reserve a gap in the row the way a normal flex sibling would.
|
|
410
|
+
hiddenTagInput: {
|
|
411
|
+
height: 1,
|
|
412
|
+
opacity: 0,
|
|
413
|
+
position: "absolute",
|
|
414
|
+
width: 1
|
|
415
|
+
},
|
|
407
416
|
iconButton: {
|
|
408
417
|
alignItems: "center",
|
|
409
418
|
borderRadius: 8,
|
|
@@ -465,21 +474,61 @@ var styles3 = StyleSheet3.create({
|
|
|
465
474
|
scrollView: {
|
|
466
475
|
flex: 1
|
|
467
476
|
},
|
|
468
|
-
// 1x1 and fully transparent — there's nothing to see here on purpose (see EditRow's own comment
|
|
469
|
-
// above the field this styles). position: 'absolute' pulls it out of editRow's flex flow
|
|
470
|
-
// entirely, so it doesn't reserve a gap in the row the way a normal flex sibling would.
|
|
471
|
-
hiddenTagInput: {
|
|
472
|
-
height: 1,
|
|
473
|
-
opacity: 0,
|
|
474
|
-
position: "absolute",
|
|
475
|
-
width: 1
|
|
476
|
-
},
|
|
477
477
|
title: {
|
|
478
478
|
flexShrink: 1,
|
|
479
479
|
fontWeight: "bold"
|
|
480
480
|
}
|
|
481
481
|
});
|
|
482
482
|
|
|
483
|
+
// src/redux/createActionCreator.ts
|
|
484
|
+
function createActionCreator(type) {
|
|
485
|
+
const creator = ((payload) => ({ payload, type }));
|
|
486
|
+
creator.type = type;
|
|
487
|
+
creator.match = (action) => action.type === type;
|
|
488
|
+
return creator;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
// src/redux/profileExtensionSlice.ts
|
|
492
|
+
function createProfileExtensionSlice(namespace) {
|
|
493
|
+
const set = createActionCreator(`profileExtension/${namespace}/set`);
|
|
494
|
+
const remove2 = createActionCreator(`profileExtension/${namespace}/remove`);
|
|
495
|
+
function reducer(state = {}, action) {
|
|
496
|
+
if (set.match(action)) return { ...state, [action.payload.id]: action.payload.extension };
|
|
497
|
+
if (remove2.match(action)) {
|
|
498
|
+
if (!(action.payload in state)) return state;
|
|
499
|
+
const next = { ...state };
|
|
500
|
+
delete next[action.payload];
|
|
501
|
+
return next;
|
|
502
|
+
}
|
|
503
|
+
return state;
|
|
504
|
+
}
|
|
505
|
+
return { actions: { remove: remove2, set }, reducer };
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
// src/redux/profilesSlice.ts
|
|
509
|
+
var setAll = createActionCreator("profiles/setAll");
|
|
510
|
+
var add = createActionCreator("profiles/add");
|
|
511
|
+
var update = createActionCreator("profiles/update");
|
|
512
|
+
var remove = createActionCreator("profiles/remove");
|
|
513
|
+
var profilesActions = { add, remove, setAll, update };
|
|
514
|
+
function profilesReducer(state = [], action) {
|
|
515
|
+
if (setAll.match(action)) return action.payload;
|
|
516
|
+
if (add.match(action)) return [...state, action.payload];
|
|
517
|
+
if (update.match(action)) {
|
|
518
|
+
const { id, patch } = action.payload;
|
|
519
|
+
return state.map((p) => p.id === id ? { ...p, ...patch, updatedAt: Date.now() } : p);
|
|
520
|
+
}
|
|
521
|
+
if (remove.match(action)) return state.filter((p) => p.id !== action.payload);
|
|
522
|
+
return state;
|
|
523
|
+
}
|
|
524
|
+
function generateProfileId() {
|
|
525
|
+
return `profile-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
526
|
+
}
|
|
527
|
+
function createProfileRecord(input) {
|
|
528
|
+
const now = Date.now();
|
|
529
|
+
return { color: input.color, createdAt: now, id: generateProfileId(), name: input.name, tag: input.tag, updatedAt: now };
|
|
530
|
+
}
|
|
531
|
+
|
|
483
532
|
// src/sharedProfileStore.ts
|
|
484
533
|
var native = null;
|
|
485
534
|
try {
|
|
@@ -513,15 +562,70 @@ async function saveSharedProfiles(groupId, profiles) {
|
|
|
513
562
|
} catch {
|
|
514
563
|
}
|
|
515
564
|
}
|
|
565
|
+
|
|
566
|
+
// src/redux/resolveInitialProfiles.ts
|
|
567
|
+
async function resolveInitialProfiles(groupId, localFallback) {
|
|
568
|
+
if (!isSharedProfileStoreAvailable) return localFallback;
|
|
569
|
+
const shared = await loadSharedProfiles(groupId);
|
|
570
|
+
if (shared.length === 0 && localFallback.length > 0) {
|
|
571
|
+
saveSharedProfiles(groupId, localFallback).catch(() => {
|
|
572
|
+
});
|
|
573
|
+
return localFallback;
|
|
574
|
+
}
|
|
575
|
+
return shared;
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
// src/redux/useSharedProfilesSync.ts
|
|
579
|
+
import { useCallback as useCallback2, useEffect as useEffect2, useRef as useRef2 } from "react";
|
|
580
|
+
import { AppState } from "react-native";
|
|
581
|
+
function useSharedProfilesSync({ groupId, onRemoteChange }) {
|
|
582
|
+
const pendingWriteRef = useRef2(false);
|
|
583
|
+
const onRemoteChangeRef = useRef2(onRemoteChange);
|
|
584
|
+
useEffect2(() => {
|
|
585
|
+
onRemoteChangeRef.current = onRemoteChange;
|
|
586
|
+
}, [onRemoteChange]);
|
|
587
|
+
useEffect2(() => {
|
|
588
|
+
if (!isSharedProfileStoreAvailable) return;
|
|
589
|
+
let cancelled = false;
|
|
590
|
+
const sub = AppState.addEventListener("change", (next) => {
|
|
591
|
+
if (next !== "active" || pendingWriteRef.current) return;
|
|
592
|
+
loadSharedProfiles(groupId).then((shared) => {
|
|
593
|
+
if (!cancelled) onRemoteChangeRef.current(shared);
|
|
594
|
+
});
|
|
595
|
+
});
|
|
596
|
+
return () => {
|
|
597
|
+
cancelled = true;
|
|
598
|
+
sub.remove();
|
|
599
|
+
};
|
|
600
|
+
}, [groupId]);
|
|
601
|
+
const syncToShared = useCallback2(
|
|
602
|
+
(next) => {
|
|
603
|
+
if (!isSharedProfileStoreAvailable) return;
|
|
604
|
+
pendingWriteRef.current = true;
|
|
605
|
+
saveSharedProfiles(groupId, next).catch(() => {
|
|
606
|
+
}).finally(() => {
|
|
607
|
+
pendingWriteRef.current = false;
|
|
608
|
+
});
|
|
609
|
+
},
|
|
610
|
+
[groupId]
|
|
611
|
+
);
|
|
612
|
+
return { syncToShared };
|
|
613
|
+
}
|
|
516
614
|
export {
|
|
517
615
|
MAX_PROFILE_NAME_LENGTH,
|
|
518
616
|
MAX_TAG_LENGTH,
|
|
519
617
|
ProfileChip,
|
|
520
618
|
ProfilePicker,
|
|
521
619
|
ProfilesManager,
|
|
620
|
+
createProfileExtensionSlice,
|
|
621
|
+
createProfileRecord,
|
|
522
622
|
isSharedProfileStoreAvailable,
|
|
523
623
|
isValidProfile,
|
|
524
624
|
isValidTag,
|
|
525
625
|
loadSharedProfiles,
|
|
526
|
-
|
|
626
|
+
profilesActions,
|
|
627
|
+
profilesReducer,
|
|
628
|
+
resolveInitialProfiles,
|
|
629
|
+
saveSharedProfiles,
|
|
630
|
+
useSharedProfilesSync
|
|
527
631
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tastic/profile",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.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",
|
|
@@ -62,15 +62,16 @@
|
|
|
62
62
|
},
|
|
63
63
|
"prettier": "@infinitetoken/eslint-config/prettier",
|
|
64
64
|
"devDependencies": {
|
|
65
|
-
"@infinitetoken/eslint-config": "
|
|
66
|
-
"@infinitetoken/jest-config": "^0.2.
|
|
67
|
-
"@infinitetoken/tsconfig": "^0.
|
|
68
|
-
"@rific/auto-paper": "^0.
|
|
69
|
-
"@rific/
|
|
65
|
+
"@infinitetoken/eslint-config": "0.2.0",
|
|
66
|
+
"@infinitetoken/jest-config": "^0.2.4",
|
|
67
|
+
"@infinitetoken/tsconfig": "^0.4.1",
|
|
68
|
+
"@rific/auto-paper": "^0.11.0",
|
|
69
|
+
"@rific/core": "^0.1.1",
|
|
70
|
+
"@rific/feedback-press": "^0.11.1",
|
|
70
71
|
"@rific/focus-chain": "^0.4.7",
|
|
71
72
|
"@rific/toaster": "^0.6.3",
|
|
72
|
-
"@tastic/core": "^0.
|
|
73
|
-
"@tastic/hud": "^0.
|
|
73
|
+
"@tastic/core": "^0.2.0",
|
|
74
|
+
"@tastic/hud": "^0.8.5",
|
|
74
75
|
"@testing-library/dom": "^10.4.1",
|
|
75
76
|
"@testing-library/react": "^16.3.2",
|
|
76
77
|
"@types/jest": "^30.0.0",
|
package/src/ProfilesManager.tsx
CHANGED
|
@@ -16,6 +16,11 @@ import { Profile } from './types'
|
|
|
16
16
|
// size for every circle on this screen, rather than each picking its own.
|
|
17
17
|
const CHIP_SIZE = 40
|
|
18
18
|
|
|
19
|
+
// react-native-paper's own MD3 Text variant names — mirrored locally rather than importing its
|
|
20
|
+
// internal VariantProp/MD3TypescaleKey (not exported from the package's public entry point), same
|
|
21
|
+
// as @tastic/hud's BaseStatsScreen does for its own identical titleVariant prop.
|
|
22
|
+
type MD3TextVariant = 'displayLarge' | 'displayMedium' | 'displaySmall' | 'headlineLarge' | 'headlineMedium' | 'headlineSmall' | 'titleLarge' | 'titleMedium' | 'titleSmall' | 'labelLarge' | 'labelMedium' | 'labelSmall' | 'bodyLarge' | 'bodyMedium' | 'bodySmall'
|
|
23
|
+
|
|
19
24
|
interface ProfileEditPatch {
|
|
20
25
|
name: string
|
|
21
26
|
color: string
|
|
@@ -52,6 +57,25 @@ export interface ProfilesManagerProps {
|
|
|
52
57
|
// React 19 accepts `ref` as a plain prop on a function component (no forwardRef needed) — see
|
|
53
58
|
// ProfilesManagerHandle's own doc for why a host may need this.
|
|
54
59
|
ref?: Ref<ProfilesManagerHandle>
|
|
60
|
+
// Independently optional overrides for this screen's text/card colors — each defaults to the same
|
|
61
|
+
// literal-black/white-by-appearance formula as before when omitted, so every existing caller
|
|
62
|
+
// (none of which pass these) renders identically to before. Same convention as @tastic/hud's
|
|
63
|
+
// BaseStatsScreen fg/cardBg overrides, for a caller with its own app-wide chrome palette instead
|
|
64
|
+
// of that literal black/white convention. cardBorder/cancelBg/cancelFg have no override, matching
|
|
65
|
+
// BaseStatsScreen's identical reasoning for cardBorder: their low-alpha/neutral values already
|
|
66
|
+
// read fine against any cardBg, and (for Cancel specifically) are deliberately neutral rather than
|
|
67
|
+
// theme-derived so a host's own live gameplay/seat color can't land there by coincidence.
|
|
68
|
+
fg?: string
|
|
69
|
+
fgMuted?: string
|
|
70
|
+
cardBg?: string
|
|
71
|
+
// 'headlineSmall' (this screen's own default, below), matching BaseStatsScreen's own settled
|
|
72
|
+
// default and the reasoning behind it — this title sits next to a small headerLeft back-button
|
|
73
|
+
// glyph the same way, and the previous hardcoded 'displaySmall' read as oversized there too.
|
|
74
|
+
titleVariant?: MD3TextVariant
|
|
75
|
+
// Transforms the color swatch grid's own preview per-swatch — forwarded straight to the internal
|
|
76
|
+
// InlineColorPicker's identical prop (see its own doc). Leaves draftColor/onCreate/onSave's actual
|
|
77
|
+
// color value untouched; only what's rendered changes.
|
|
78
|
+
colorPreview?: (hex: string) => string
|
|
55
79
|
}
|
|
56
80
|
|
|
57
81
|
type Row = { kind: 'profile'; profile: Profile } | { kind: 'new' }
|
|
@@ -74,16 +98,16 @@ function deriveTag(name: string): string {
|
|
|
74
98
|
// really unmounts it on a given host's router — see ProfilesManagerHandle's own doc for the
|
|
75
99
|
// react-navigation-web case where it doesn't, and why a host there needs the `ref` escape hatch
|
|
76
100
|
// instead of relying on the unmount fallback alone.
|
|
77
|
-
export function ProfilesManager({ profiles, defaultColor, onCreate, onSave, onDelete, headerLeft, ref }: ProfilesManagerProps) {
|
|
101
|
+
export function ProfilesManager({ profiles, defaultColor, onCreate, onSave, onDelete, headerLeft, ref, fg: fgOverride, fgMuted: fgMutedOverride, cardBg: cardBgOverride, titleVariant = 'headlineSmall', colorPreview }: ProfilesManagerProps) {
|
|
78
102
|
const { dark, colors } = useAutoPaperTheme()
|
|
79
103
|
const insets = useSafeAreaInsets()
|
|
80
104
|
const { error: showErrorToast } = useToast()
|
|
81
105
|
// Only one row is ever in edit mode at a time (see commitEdit, called before any new row opens),
|
|
82
106
|
// so every EditRow instance can safely share this one host instead of each needing its own.
|
|
83
107
|
const host = usePopoverHost()
|
|
84
|
-
const fg = dark ? '#FFFFFF' : '#000000'
|
|
85
|
-
const fgMuted = dark ? 'rgba(255,255,255,0.5)' : 'rgba(0,0,0,0.5)'
|
|
86
|
-
const cardBg = dark ? '#111111' : '#F2F2F2'
|
|
108
|
+
const fg = fgOverride ?? (dark ? '#FFFFFF' : '#000000')
|
|
109
|
+
const fgMuted = fgMutedOverride ?? (dark ? 'rgba(255,255,255,0.5)' : 'rgba(0,0,0,0.5)')
|
|
110
|
+
const cardBg = cardBgOverride ?? (dark ? '#111111' : '#F2F2F2')
|
|
87
111
|
const cardBorder = dark ? 'rgba(255,255,255,0.2)' : 'rgba(0,0,0,0.2)'
|
|
88
112
|
// A contained neutral, not an outlined one — reads as "the other button" next to Delete's solid
|
|
89
113
|
// danger fill, rather than looking unfinished/secondary beside it.
|
|
@@ -198,7 +222,7 @@ export function ProfilesManager({ profiles, defaultColor, onCreate, onSave, onDe
|
|
|
198
222
|
<>
|
|
199
223
|
<View style={[styles.header, { paddingTop: 8 + insets.top, paddingLeft: 8 + insets.left }]}>
|
|
200
224
|
{headerLeft}
|
|
201
|
-
<Text variant=
|
|
225
|
+
<Text variant={titleVariant} style={[styles.title, { color: fg }]}>
|
|
202
226
|
Profiles
|
|
203
227
|
</Text>
|
|
204
228
|
</View>
|
|
@@ -226,12 +250,12 @@ export function ProfilesManager({ profiles, defaultColor, onCreate, onSave, onDe
|
|
|
226
250
|
</TouchableRipple>
|
|
227
251
|
)
|
|
228
252
|
}
|
|
229
|
-
return <EditRow key='new' draftName={draftName} onNameChange={handleNameChange} draftColor={draftColor} onColorChange={setDraftColor} draftTag={draftTag} onTagChange={handleTagChange} onSubmit={commitEdit} autoFocus fgMuted={fgMuted} host={host} dark={dark} />
|
|
253
|
+
return <EditRow key='new' draftName={draftName} onNameChange={handleNameChange} draftColor={draftColor} onColorChange={setDraftColor} draftTag={draftTag} onTagChange={handleTagChange} onSubmit={commitEdit} autoFocus fgMuted={fgMuted} host={host} dark={dark} colorPreview={colorPreview} />
|
|
230
254
|
}
|
|
231
255
|
|
|
232
256
|
const { profile } = row
|
|
233
257
|
if (editingId === profile.id) {
|
|
234
|
-
return <EditRow key={profile.id} draftName={draftName} onNameChange={handleNameChange} draftColor={draftColor} onColorChange={setDraftColor} draftTag={draftTag} onTagChange={handleTagChange} onSubmit={commitEdit} onDelete={() => setConfirmDeleteId(profile.id)} fgMuted={fgMuted} host={host} dark={dark} />
|
|
258
|
+
return <EditRow key={profile.id} draftName={draftName} onNameChange={handleNameChange} draftColor={draftColor} onColorChange={setDraftColor} draftTag={draftTag} onTagChange={handleTagChange} onSubmit={commitEdit} onDelete={() => setConfirmDeleteId(profile.id)} fgMuted={fgMuted} host={host} dark={dark} colorPreview={colorPreview} />
|
|
235
259
|
}
|
|
236
260
|
|
|
237
261
|
// No delete affordance at rest — tap the row to start editing, which is the one place
|
|
@@ -295,6 +319,7 @@ interface EditRowProps {
|
|
|
295
319
|
fgMuted: string
|
|
296
320
|
host: PopoverHost
|
|
297
321
|
dark: boolean
|
|
322
|
+
colorPreview?: (hex: string) => string
|
|
298
323
|
}
|
|
299
324
|
|
|
300
325
|
// One row, expanded: the color trigger (@tastic/hud's InlineColorPicker) — doubling as the tag's
|
|
@@ -309,7 +334,7 @@ interface EditRowProps {
|
|
|
309
334
|
// own — submitting the *name* field is what actually commits the row, so its registration's own
|
|
310
335
|
// onSubmitEditing (a no-op — it's last in the chain) is overridden with onSubmit below, matching
|
|
311
336
|
// how a normal multi-field form reads: fill fields in order, the last one finishes it.
|
|
312
|
-
function EditRow({ draftName, onNameChange, draftColor, onColorChange, draftTag, onTagChange, onSubmit, onDelete, autoFocus, fgMuted, host, dark }: EditRowProps) {
|
|
337
|
+
function EditRow({ draftName, onNameChange, draftColor, onColorChange, draftTag, onTagChange, onSubmit, onDelete, autoFocus, fgMuted, host, dark, colorPreview }: EditRowProps) {
|
|
313
338
|
const register = useFocusChain()
|
|
314
339
|
const tag = register()
|
|
315
340
|
const name = register()
|
|
@@ -336,7 +361,7 @@ function EditRow({ draftName, onNameChange, draftColor, onColorChange, draftTag,
|
|
|
336
361
|
|
|
337
362
|
return (
|
|
338
363
|
<View style={styles.editRow}>
|
|
339
|
-
<InlineColorPicker id='color' host={host} value={draftColor} onChange={onColorChange} tag={draftTag} dark={dark} />
|
|
364
|
+
<InlineColorPicker id='color' host={host} value={draftColor} onChange={onColorChange} previewValue={colorPreview} tag={draftTag} dark={dark} />
|
|
340
365
|
{/* Invisible on purpose (styles.hiddenTagInput: zero footprint, position: 'absolute' so it
|
|
341
366
|
doesn't reserve space in the row's own flex layout) — the swatch above is the only place this
|
|
342
367
|
value is ever meant to be seen. pointerEvents='none' is what actually keeps it out of the
|
|
@@ -380,6 +405,15 @@ const styles = StyleSheet.create({
|
|
|
380
405
|
gap: 4,
|
|
381
406
|
paddingBottom: 8
|
|
382
407
|
},
|
|
408
|
+
// 1x1 and fully transparent — there's nothing to see here on purpose (see EditRow's own comment
|
|
409
|
+
// above the field this styles). position: 'absolute' pulls it out of editRow's flex flow
|
|
410
|
+
// entirely, so it doesn't reserve a gap in the row the way a normal flex sibling would.
|
|
411
|
+
hiddenTagInput: {
|
|
412
|
+
height: 1,
|
|
413
|
+
opacity: 0,
|
|
414
|
+
position: 'absolute',
|
|
415
|
+
width: 1
|
|
416
|
+
},
|
|
383
417
|
iconButton: {
|
|
384
418
|
alignItems: 'center',
|
|
385
419
|
borderRadius: 8,
|
|
@@ -441,15 +475,6 @@ const styles = StyleSheet.create({
|
|
|
441
475
|
scrollView: {
|
|
442
476
|
flex: 1
|
|
443
477
|
},
|
|
444
|
-
// 1x1 and fully transparent — there's nothing to see here on purpose (see EditRow's own comment
|
|
445
|
-
// above the field this styles). position: 'absolute' pulls it out of editRow's flex flow
|
|
446
|
-
// entirely, so it doesn't reserve a gap in the row the way a normal flex sibling would.
|
|
447
|
-
hiddenTagInput: {
|
|
448
|
-
height: 1,
|
|
449
|
-
opacity: 0,
|
|
450
|
-
position: 'absolute',
|
|
451
|
-
width: 1
|
|
452
|
-
},
|
|
453
478
|
title: {
|
|
454
479
|
flexShrink: 1,
|
|
455
480
|
fontWeight: 'bold'
|
package/src/index.ts
CHANGED
|
@@ -2,5 +2,9 @@ export { ProfileChip } from './ProfileChip'
|
|
|
2
2
|
export { ProfilePicker } from './ProfilePicker'
|
|
3
3
|
export { ProfilesManager, type ProfilesManagerHandle, type ProfilesManagerProps } from './ProfilesManager'
|
|
4
4
|
export { isValidProfile, isValidTag, MAX_PROFILE_NAME_LENGTH, MAX_TAG_LENGTH } from './profilesValidation'
|
|
5
|
+
export { createProfileExtensionSlice, type ProfileExtensionActions, type ProfileExtensionSlice, type ProfileExtensionState, type SetProfileExtensionPayload } from './redux/profileExtensionSlice'
|
|
6
|
+
export { type CreateProfileInput, createProfileRecord, profilesActions, profilesReducer, type UpdateProfilePayload } from './redux/profilesSlice'
|
|
7
|
+
export { resolveInitialProfiles } from './redux/resolveInitialProfiles'
|
|
8
|
+
export { useSharedProfilesSync, type UseSharedProfilesSyncOptions, type UseSharedProfilesSyncResult } from './redux/useSharedProfilesSync'
|
|
5
9
|
export { isSharedProfileStoreAvailable, loadSharedProfiles, saveSharedProfiles } from './sharedProfileStore'
|
|
6
10
|
export type { Profile } from './types'
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export interface ReduxAction<P> {
|
|
2
|
+
payload: P
|
|
3
|
+
type: string
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export type ActionCreator<P> = ((payload: P) => ReduxAction<P>) & {
|
|
7
|
+
type: string
|
|
8
|
+
match: (action: { type: string }) => action is ReduxAction<P>
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
// Same low-level shape @rific/core's createSettingsSlice gives every other slice in the fleet
|
|
12
|
+
// (namespaced type string, .type, .match). Shared internally between profilesSlice.ts and
|
|
13
|
+
// profileExtensionSlice.ts rather than each hand-rolling its own copy — not exported from
|
|
14
|
+
// index.ts, since it's an implementation detail of how this package's own reducers are built, not
|
|
15
|
+
// something a consuming app needs.
|
|
16
|
+
export function createActionCreator<P>(type: string): ActionCreator<P> {
|
|
17
|
+
const creator = ((payload: P) => ({ payload, type })) as ActionCreator<P>
|
|
18
|
+
creator.type = type
|
|
19
|
+
creator.match = (action: { type: string }): action is ReduxAction<P> => action.type === type
|
|
20
|
+
return creator
|
|
21
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { ActionCreator, createActionCreator } from './createActionCreator'
|
|
2
|
+
|
|
3
|
+
export interface SetProfileExtensionPayload<TExtension> {
|
|
4
|
+
extension: TExtension
|
|
5
|
+
id: string
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface ProfileExtensionActions<TExtension> {
|
|
9
|
+
remove: ActionCreator<string>
|
|
10
|
+
set: ActionCreator<SetProfileExtensionPayload<TExtension>>
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export type ProfileExtensionState<TExtension> = Record<string, TExtension>
|
|
14
|
+
|
|
15
|
+
export interface ProfileExtensionSlice<TExtension> {
|
|
16
|
+
actions: ProfileExtensionActions<TExtension>
|
|
17
|
+
reducer: (state: ProfileExtensionState<TExtension> | undefined, action: { type: string }) => ProfileExtensionState<TExtension>
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// A per-profile extension is any app-specific field that doesn't belong on the shared base Profile
|
|
21
|
+
// type (a control scheme, a key-binding preference, a difficulty setting, ...) but still needs to
|
|
22
|
+
// travel WITH a specific profile id, entirely locally — never synced to the shared App Group
|
|
23
|
+
// roster (a profile created on a sibling @tastic game has no idea this extension exists at all,
|
|
24
|
+
// same as this package's own README describes for any app-specific field). Two real @tastic games
|
|
25
|
+
// (BoxHockey's controlScheme, LightCycles' keyScheme) each hand-rolled an almost-identical
|
|
26
|
+
// Record<profileId, TExtension> reducer before this factory existed; this is that shape,
|
|
27
|
+
// generalized over the extension's own type so a future game's own per-profile preference doesn't
|
|
28
|
+
// need a third copy.
|
|
29
|
+
//
|
|
30
|
+
// `namespace` prefixes every action type (e.g. 'profileExtension/controlScheme/set') so an app with
|
|
31
|
+
// more than one independent per-profile extension never collides between them — same convention
|
|
32
|
+
// @rific/core's createSettingsSlice uses its own namespace argument for.
|
|
33
|
+
//
|
|
34
|
+
// Deliberately NOT auto-cleared when the base `profiles` slice removes an id — this stays a plain,
|
|
35
|
+
// self-contained reducer rather than importing profilesActions.remove's action type to listen for.
|
|
36
|
+
// An app already dispatches multiple independent actions when deleting a profile (profilesActions.
|
|
37
|
+
// remove, and typically its own local selection-slice cleanup); calling this slice's own `remove`
|
|
38
|
+
// alongside those is one more such dispatch, not new cross-slice wiring.
|
|
39
|
+
export function createProfileExtensionSlice<TExtension>(namespace: string): ProfileExtensionSlice<TExtension> {
|
|
40
|
+
const set = createActionCreator<SetProfileExtensionPayload<TExtension>>(`profileExtension/${namespace}/set`)
|
|
41
|
+
const remove = createActionCreator<string>(`profileExtension/${namespace}/remove`)
|
|
42
|
+
|
|
43
|
+
function reducer(state: ProfileExtensionState<TExtension> = {}, action: { type: string }): ProfileExtensionState<TExtension> {
|
|
44
|
+
if (set.match(action)) return { ...state, [action.payload.id]: action.payload.extension }
|
|
45
|
+
if (remove.match(action)) {
|
|
46
|
+
if (!(action.payload in state)) return state
|
|
47
|
+
const next = { ...state }
|
|
48
|
+
delete next[action.payload]
|
|
49
|
+
return next
|
|
50
|
+
}
|
|
51
|
+
return state
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return { actions: { remove, set }, reducer }
|
|
55
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { Profile } from '../types'
|
|
2
|
+
import { createActionCreator } from './createActionCreator'
|
|
3
|
+
|
|
4
|
+
export interface UpdateProfilePayload {
|
|
5
|
+
id: string
|
|
6
|
+
patch: Partial<Pick<Profile, 'name' | 'color' | 'tag'>>
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
// setAll: full-replace, for hydrating from resolveInitialProfiles or a remote refresh
|
|
10
|
+
// (useSharedProfilesSync's onRemoteChange). add/update/remove: the actual roster CRUD every
|
|
11
|
+
// @tastic game already hand-rolled an identical version of in its own useProfiles.tsx.
|
|
12
|
+
const setAll = createActionCreator<Profile[]>('profiles/setAll')
|
|
13
|
+
const add = createActionCreator<Profile>('profiles/add')
|
|
14
|
+
const update = createActionCreator<UpdateProfilePayload>('profiles/update')
|
|
15
|
+
const remove = createActionCreator<string>('profiles/remove')
|
|
16
|
+
|
|
17
|
+
export const profilesActions = { add, remove, setAll, update }
|
|
18
|
+
|
|
19
|
+
export function profilesReducer(state: Profile[] = [], action: { type: string }): Profile[] {
|
|
20
|
+
if (setAll.match(action)) return action.payload
|
|
21
|
+
if (add.match(action)) return [...state, action.payload]
|
|
22
|
+
if (update.match(action)) {
|
|
23
|
+
const { id, patch } = action.payload
|
|
24
|
+
return state.map((p) => (p.id === id ? { ...p, ...patch, updatedAt: Date.now() } : p))
|
|
25
|
+
}
|
|
26
|
+
if (remove.match(action)) return state.filter((p) => p.id !== action.payload)
|
|
27
|
+
return state
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function generateProfileId(): string {
|
|
31
|
+
return `profile-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface CreateProfileInput {
|
|
35
|
+
name: string
|
|
36
|
+
color: string
|
|
37
|
+
tag: string
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Builds a complete Profile (id + timestamps) ready to dispatch via profilesActions.add. Kept as
|
|
41
|
+
// its own plain function rather than folded into the `add` action creator, so `add` itself stays a
|
|
42
|
+
// simple "append this exact record" primitive (useful for rehydration/tests too, where the record
|
|
43
|
+
// already exists and shouldn't get a second, different id/timestamp minted for it).
|
|
44
|
+
export function createProfileRecord(input: CreateProfileInput): Profile {
|
|
45
|
+
const now = Date.now()
|
|
46
|
+
return { color: input.color, createdAt: now, id: generateProfileId(), name: input.name, tag: input.tag, updatedAt: now }
|
|
47
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { isSharedProfileStoreAvailable, loadSharedProfiles, saveSharedProfiles } from '../sharedProfileStore'
|
|
2
|
+
import { Profile } from '../types'
|
|
3
|
+
|
|
4
|
+
// Resolves the roster an app should start with, given whatever it already had saved in its own
|
|
5
|
+
// local fallback storage (however that app stores it — this never touches AsyncStorage itself,
|
|
6
|
+
// only the shared App Group store via loadSharedProfiles/saveSharedProfiles). Every @tastic game
|
|
7
|
+
// hand-rolled an identical version of this exact decision inline in its own useProfiles.tsx; this
|
|
8
|
+
// is that logic, extracted verbatim.
|
|
9
|
+
//
|
|
10
|
+
// - Shared store unavailable (Android/web, or an iOS build that hasn't run `expo prebuild` since
|
|
11
|
+
// the entitlement was added): `localFallback` IS the roster, verbatim.
|
|
12
|
+
// - Shared store available, and genuinely empty: either a first launch, or the first time THIS
|
|
13
|
+
// app has synced since the App Group entitlement was added — seed it from `localFallback` (so
|
|
14
|
+
// an upgrading user's own profiles don't just vanish), and mirror that seed to the shared store
|
|
15
|
+
// (fire-and-forget; a failed seed write just means the next successful write catches up).
|
|
16
|
+
// - Shared store available and non-empty: shared always wins over a stale local snapshot, never
|
|
17
|
+
// merged with it — avoids resurrecting a profile that was deleted from another app.
|
|
18
|
+
export async function resolveInitialProfiles(groupId: string, localFallback: Profile[]): Promise<Profile[]> {
|
|
19
|
+
if (!isSharedProfileStoreAvailable) return localFallback
|
|
20
|
+
const shared = await loadSharedProfiles(groupId)
|
|
21
|
+
if (shared.length === 0 && localFallback.length > 0) {
|
|
22
|
+
saveSharedProfiles(groupId, localFallback).catch(() => {})
|
|
23
|
+
return localFallback
|
|
24
|
+
}
|
|
25
|
+
return shared
|
|
26
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef } from 'react'
|
|
2
|
+
import { AppState } from 'react-native'
|
|
3
|
+
|
|
4
|
+
import { isSharedProfileStoreAvailable, loadSharedProfiles, saveSharedProfiles } from '../sharedProfileStore'
|
|
5
|
+
import { Profile } from '../types'
|
|
6
|
+
|
|
7
|
+
export interface UseSharedProfilesSyncOptions {
|
|
8
|
+
groupId: string
|
|
9
|
+
// Called with a freshly-read shared roster whenever the app returns to the foreground — the
|
|
10
|
+
// shared store has no cross-process change notification, so this is what catches "edited a
|
|
11
|
+
// profile in a different @tastic app, then switched back to this one." Skipped while a
|
|
12
|
+
// syncToShared write is still in flight (see pendingWriteRef below), and never called at all
|
|
13
|
+
// when the shared store isn't available.
|
|
14
|
+
onRemoteChange: (profiles: Profile[]) => void
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface UseSharedProfilesSyncResult {
|
|
18
|
+
// Call right after applying a roster change locally (dispatching profilesActions.setAll/add/
|
|
19
|
+
// update/remove) to mirror it to the shared store. No-ops when the shared store isn't available
|
|
20
|
+
// — the caller is responsible for its own local persistence in that case, same as before this
|
|
21
|
+
// hook existed. Fire-and-forget: never throws, never awaited by this hook itself.
|
|
22
|
+
syncToShared: (next: Profile[]) => void
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Bundles the two things every @tastic game needs around the shared App Group roster once
|
|
26
|
+
// `profiles` lives in its own Redux store: mirroring local writes out to the shared store, and
|
|
27
|
+
// picking up remote writes a sibling app made while this one was backgrounded. Deliberately
|
|
28
|
+
// separate from resolveInitialProfiles — that one's for the one-time initial load, this one's for
|
|
29
|
+
// the app's whole lifetime after that.
|
|
30
|
+
export function useSharedProfilesSync({ groupId, onRemoteChange }: UseSharedProfilesSyncOptions): UseSharedProfilesSyncResult {
|
|
31
|
+
// True for the duration of an in-flight syncToShared write — the foreground-refresh effect below
|
|
32
|
+
// skips a refresh while this is true, rather than risk reading back a pre-write snapshot and
|
|
33
|
+
// handing the caller an onRemoteChange that reverts the write still landing. A ref, not state:
|
|
34
|
+
// read from inside the AppState listener's closure, which is only created once per subscription
|
|
35
|
+
// and would otherwise see whatever this was at mount time, forever.
|
|
36
|
+
const pendingWriteRef = useRef(false)
|
|
37
|
+
// Same reasoning, for onRemoteChange itself — an app typically passes an inline callback that
|
|
38
|
+
// closes over its own latest dispatch/state, so the listener must always call the current one,
|
|
39
|
+
// not whichever was in scope when the effect below first subscribed.
|
|
40
|
+
const onRemoteChangeRef = useRef(onRemoteChange)
|
|
41
|
+
useEffect(() => {
|
|
42
|
+
onRemoteChangeRef.current = onRemoteChange
|
|
43
|
+
}, [onRemoteChange])
|
|
44
|
+
|
|
45
|
+
useEffect(() => {
|
|
46
|
+
if (!isSharedProfileStoreAvailable) return
|
|
47
|
+
let cancelled = false
|
|
48
|
+
const sub = AppState.addEventListener('change', (next) => {
|
|
49
|
+
if (next !== 'active' || pendingWriteRef.current) return
|
|
50
|
+
loadSharedProfiles(groupId).then((shared) => {
|
|
51
|
+
if (!cancelled) onRemoteChangeRef.current(shared)
|
|
52
|
+
})
|
|
53
|
+
})
|
|
54
|
+
return () => {
|
|
55
|
+
cancelled = true
|
|
56
|
+
sub.remove()
|
|
57
|
+
}
|
|
58
|
+
}, [groupId])
|
|
59
|
+
|
|
60
|
+
const syncToShared = useCallback(
|
|
61
|
+
(next: Profile[]) => {
|
|
62
|
+
if (!isSharedProfileStoreAvailable) return
|
|
63
|
+
pendingWriteRef.current = true
|
|
64
|
+
saveSharedProfiles(groupId, next)
|
|
65
|
+
.catch(() => {})
|
|
66
|
+
.finally(() => {
|
|
67
|
+
pendingWriteRef.current = false
|
|
68
|
+
})
|
|
69
|
+
},
|
|
70
|
+
[groupId]
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
return { syncToShared }
|
|
74
|
+
}
|