@tastic/profile 0.3.8 → 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/dist/index.js CHANGED
@@ -25,11 +25,20 @@ __export(index_exports, {
25
25
  ProfileChip: () => ProfileChip,
26
26
  ProfilePicker: () => ProfilePicker,
27
27
  ProfilesManager: () => ProfilesManager,
28
+ ProfilesScreen: () => ProfilesScreen,
29
+ createProfileExtensionSlice: () => createProfileExtensionSlice,
30
+ createProfileRecord: () => createProfileRecord,
31
+ createProfileSelectionSlice: () => createProfileSelectionSlice,
32
+ createSeatColorsSlice: () => createSeatColorsSlice,
28
33
  isSharedProfileStoreAvailable: () => isSharedProfileStoreAvailable,
29
34
  isValidProfile: () => isValidProfile,
30
35
  isValidTag: () => isValidTag,
31
36
  loadSharedProfiles: () => loadSharedProfiles,
32
- saveSharedProfiles: () => saveSharedProfiles
37
+ profilesActions: () => profilesActions,
38
+ profilesReducer: () => profilesReducer,
39
+ resolveInitialProfiles: () => resolveInitialProfiles,
40
+ saveSharedProfiles: () => saveSharedProfiles,
41
+ useSharedProfilesSync: () => useSharedProfilesSync
33
42
  });
34
43
  module.exports = __toCommonJS(index_exports);
35
44
 
@@ -508,6 +517,101 @@ var styles3 = import_react_native3.StyleSheet.create({
508
517
  }
509
518
  });
510
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
+
543
+ // src/redux/createActionCreator.ts
544
+ function createActionCreator(type) {
545
+ const creator = ((payload) => ({ payload, type }));
546
+ creator.type = type;
547
+ creator.match = (action) => action.type === type;
548
+ return creator;
549
+ }
550
+
551
+ // src/redux/profileExtensionSlice.ts
552
+ function createProfileExtensionSlice(namespace) {
553
+ const set = createActionCreator(`profileExtension/${namespace}/set`);
554
+ const remove2 = createActionCreator(`profileExtension/${namespace}/remove`);
555
+ function reducer(state = {}, action) {
556
+ if (set.match(action)) return { ...state, [action.payload.id]: action.payload.extension };
557
+ if (remove2.match(action)) {
558
+ if (!(action.payload in state)) return state;
559
+ const next = { ...state };
560
+ delete next[action.payload];
561
+ return next;
562
+ }
563
+ return state;
564
+ }
565
+ return { actions: { remove: remove2, set }, reducer };
566
+ }
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
+
591
+ // src/redux/profilesSlice.ts
592
+ var setAll = createActionCreator("profiles/setAll");
593
+ var add = createActionCreator("profiles/add");
594
+ var update = createActionCreator("profiles/update");
595
+ var remove = createActionCreator("profiles/remove");
596
+ var profilesActions = { add, remove, setAll, update };
597
+ function profilesReducer(state = [], action) {
598
+ if (setAll.match(action)) return action.payload;
599
+ if (add.match(action)) return [...state, action.payload];
600
+ if (update.match(action)) {
601
+ const { id, patch } = action.payload;
602
+ return state.map((p) => p.id === id ? { ...p, ...patch, updatedAt: Date.now() } : p);
603
+ }
604
+ if (remove.match(action)) return state.filter((p) => p.id !== action.payload);
605
+ return state;
606
+ }
607
+ function generateProfileId() {
608
+ return `profile-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
609
+ }
610
+ function createProfileRecord(input) {
611
+ const now = Date.now();
612
+ return { color: input.color, createdAt: now, id: generateProfileId(), name: input.name, tag: input.tag, updatedAt: now };
613
+ }
614
+
511
615
  // src/sharedProfileStore.ts
512
616
  var native = null;
513
617
  try {
@@ -541,6 +645,74 @@ async function saveSharedProfiles(groupId, profiles) {
541
645
  } catch {
542
646
  }
543
647
  }
648
+
649
+ // src/redux/resolveInitialProfiles.ts
650
+ async function resolveInitialProfiles(groupId, localFallback) {
651
+ if (!isSharedProfileStoreAvailable) return localFallback;
652
+ const shared = await loadSharedProfiles(groupId);
653
+ if (shared.length === 0 && localFallback.length > 0) {
654
+ saveSharedProfiles(groupId, localFallback).catch(() => {
655
+ });
656
+ return localFallback;
657
+ }
658
+ return shared;
659
+ }
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
+
680
+ // src/redux/useSharedProfilesSync.ts
681
+ var import_react4 = require("react");
682
+ var import_react_native5 = require("react-native");
683
+ function useSharedProfilesSync({ groupId, onRemoteChange }) {
684
+ const pendingWriteRef = (0, import_react4.useRef)(false);
685
+ const onRemoteChangeRef = (0, import_react4.useRef)(onRemoteChange);
686
+ (0, import_react4.useEffect)(() => {
687
+ onRemoteChangeRef.current = onRemoteChange;
688
+ }, [onRemoteChange]);
689
+ (0, import_react4.useEffect)(() => {
690
+ if (!isSharedProfileStoreAvailable) return;
691
+ let cancelled = false;
692
+ const sub = import_react_native5.AppState.addEventListener("change", (next) => {
693
+ if (next !== "active" || pendingWriteRef.current) return;
694
+ loadSharedProfiles(groupId).then((shared) => {
695
+ if (!cancelled) onRemoteChangeRef.current(shared);
696
+ });
697
+ });
698
+ return () => {
699
+ cancelled = true;
700
+ sub.remove();
701
+ };
702
+ }, [groupId]);
703
+ const syncToShared = (0, import_react4.useCallback)(
704
+ (next) => {
705
+ if (!isSharedProfileStoreAvailable) return;
706
+ pendingWriteRef.current = true;
707
+ saveSharedProfiles(groupId, next).catch(() => {
708
+ }).finally(() => {
709
+ pendingWriteRef.current = false;
710
+ });
711
+ },
712
+ [groupId]
713
+ );
714
+ return { syncToShared };
715
+ }
544
716
  // Annotate the CommonJS export names for ESM import in node:
545
717
  0 && (module.exports = {
546
718
  MAX_PROFILE_NAME_LENGTH,
@@ -548,9 +720,18 @@ async function saveSharedProfiles(groupId, profiles) {
548
720
  ProfileChip,
549
721
  ProfilePicker,
550
722
  ProfilesManager,
723
+ ProfilesScreen,
724
+ createProfileExtensionSlice,
725
+ createProfileRecord,
726
+ createProfileSelectionSlice,
727
+ createSeatColorsSlice,
551
728
  isSharedProfileStoreAvailable,
552
729
  isValidProfile,
553
730
  isValidTag,
554
731
  loadSharedProfiles,
555
- saveSharedProfiles
732
+ profilesActions,
733
+ profilesReducer,
734
+ resolveInitialProfiles,
735
+ saveSharedProfiles,
736
+ useSharedProfilesSync
556
737
  });
package/dist/index.mjs CHANGED
@@ -480,6 +480,101 @@ 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
+
506
+ // src/redux/createActionCreator.ts
507
+ function createActionCreator(type) {
508
+ const creator = ((payload) => ({ payload, type }));
509
+ creator.type = type;
510
+ creator.match = (action) => action.type === type;
511
+ return creator;
512
+ }
513
+
514
+ // src/redux/profileExtensionSlice.ts
515
+ function createProfileExtensionSlice(namespace) {
516
+ const set = createActionCreator(`profileExtension/${namespace}/set`);
517
+ const remove2 = createActionCreator(`profileExtension/${namespace}/remove`);
518
+ function reducer(state = {}, action) {
519
+ if (set.match(action)) return { ...state, [action.payload.id]: action.payload.extension };
520
+ if (remove2.match(action)) {
521
+ if (!(action.payload in state)) return state;
522
+ const next = { ...state };
523
+ delete next[action.payload];
524
+ return next;
525
+ }
526
+ return state;
527
+ }
528
+ return { actions: { remove: remove2, set }, reducer };
529
+ }
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
+
554
+ // src/redux/profilesSlice.ts
555
+ var setAll = createActionCreator("profiles/setAll");
556
+ var add = createActionCreator("profiles/add");
557
+ var update = createActionCreator("profiles/update");
558
+ var remove = createActionCreator("profiles/remove");
559
+ var profilesActions = { add, remove, setAll, update };
560
+ function profilesReducer(state = [], action) {
561
+ if (setAll.match(action)) return action.payload;
562
+ if (add.match(action)) return [...state, action.payload];
563
+ if (update.match(action)) {
564
+ const { id, patch } = action.payload;
565
+ return state.map((p) => p.id === id ? { ...p, ...patch, updatedAt: Date.now() } : p);
566
+ }
567
+ if (remove.match(action)) return state.filter((p) => p.id !== action.payload);
568
+ return state;
569
+ }
570
+ function generateProfileId() {
571
+ return `profile-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
572
+ }
573
+ function createProfileRecord(input) {
574
+ const now = Date.now();
575
+ return { color: input.color, createdAt: now, id: generateProfileId(), name: input.name, tag: input.tag, updatedAt: now };
576
+ }
577
+
483
578
  // src/sharedProfileStore.ts
484
579
  var native = null;
485
580
  try {
@@ -513,15 +608,92 @@ async function saveSharedProfiles(groupId, profiles) {
513
608
  } catch {
514
609
  }
515
610
  }
611
+
612
+ // src/redux/resolveInitialProfiles.ts
613
+ async function resolveInitialProfiles(groupId, localFallback) {
614
+ if (!isSharedProfileStoreAvailable) return localFallback;
615
+ const shared = await loadSharedProfiles(groupId);
616
+ if (shared.length === 0 && localFallback.length > 0) {
617
+ saveSharedProfiles(groupId, localFallback).catch(() => {
618
+ });
619
+ return localFallback;
620
+ }
621
+ return shared;
622
+ }
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
+
643
+ // src/redux/useSharedProfilesSync.ts
644
+ import { useCallback as useCallback2, useEffect as useEffect2, useRef as useRef3 } from "react";
645
+ import { AppState } from "react-native";
646
+ function useSharedProfilesSync({ groupId, onRemoteChange }) {
647
+ const pendingWriteRef = useRef3(false);
648
+ const onRemoteChangeRef = useRef3(onRemoteChange);
649
+ useEffect2(() => {
650
+ onRemoteChangeRef.current = onRemoteChange;
651
+ }, [onRemoteChange]);
652
+ useEffect2(() => {
653
+ if (!isSharedProfileStoreAvailable) return;
654
+ let cancelled = false;
655
+ const sub = AppState.addEventListener("change", (next) => {
656
+ if (next !== "active" || pendingWriteRef.current) return;
657
+ loadSharedProfiles(groupId).then((shared) => {
658
+ if (!cancelled) onRemoteChangeRef.current(shared);
659
+ });
660
+ });
661
+ return () => {
662
+ cancelled = true;
663
+ sub.remove();
664
+ };
665
+ }, [groupId]);
666
+ const syncToShared = useCallback2(
667
+ (next) => {
668
+ if (!isSharedProfileStoreAvailable) return;
669
+ pendingWriteRef.current = true;
670
+ saveSharedProfiles(groupId, next).catch(() => {
671
+ }).finally(() => {
672
+ pendingWriteRef.current = false;
673
+ });
674
+ },
675
+ [groupId]
676
+ );
677
+ return { syncToShared };
678
+ }
516
679
  export {
517
680
  MAX_PROFILE_NAME_LENGTH,
518
681
  MAX_TAG_LENGTH,
519
682
  ProfileChip,
520
683
  ProfilePicker,
521
684
  ProfilesManager,
685
+ ProfilesScreen,
686
+ createProfileExtensionSlice,
687
+ createProfileRecord,
688
+ createProfileSelectionSlice,
689
+ createSeatColorsSlice,
522
690
  isSharedProfileStoreAvailable,
523
691
  isValidProfile,
524
692
  isValidTag,
525
693
  loadSharedProfiles,
526
- saveSharedProfiles
694
+ profilesActions,
695
+ profilesReducer,
696
+ resolveInitialProfiles,
697
+ saveSharedProfiles,
698
+ useSharedProfilesSync
527
699
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tastic/profile",
3
- "version": "0.3.8",
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,15 @@
63
63
  "prettier": "@infinitetoken/eslint-config/prettier",
64
64
  "devDependencies": {
65
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.10.3",
69
- "@rific/feedback-press": "^0.10.8",
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",
70
71
  "@rific/focus-chain": "^0.4.7",
71
72
  "@rific/toaster": "^0.6.3",
72
- "@tastic/core": "^0.2.0",
73
- "@tastic/hud": "^0.6.0",
73
+ "@tastic/core": "^0.8.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",
@@ -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
- interface ProfileEditPatch {
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,6 +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'
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'
8
+ export { type CreateProfileInput, createProfileRecord, profilesActions, profilesReducer, type UpdateProfilePayload } from './redux/profilesSlice'
9
+ export { resolveInitialProfiles } from './redux/resolveInitialProfiles'
10
+ export { createSeatColorsSlice, type SeatColorsActions, type SeatColorsSlice, type SeatColorsState, type SetLastGuestColorPayload, type SetProfileOverridePayload } from './redux/seatColorsSlice'
11
+ export { useSharedProfilesSync, type UseSharedProfilesSyncOptions, type UseSharedProfilesSyncResult } from './redux/useSharedProfilesSync'
5
12
  export { isSharedProfileStoreAvailable, loadSharedProfiles, saveSharedProfiles } from './sharedProfileStore'
6
13
  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
+ }