@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/README.md +162 -15
- package/dist/index.d.mts +124 -3
- package/dist/index.d.ts +124 -3
- package/dist/index.js +183 -2
- package/dist/index.mjs +173 -1
- package/package.json +8 -7
- package/src/ProfilesManager.tsx +4 -1
- package/src/ProfilesScreen.tsx +83 -0
- package/src/index.ts +8 -1
- package/src/redux/createActionCreator.ts +21 -0
- package/src/redux/profileExtensionSlice.ts +55 -0
- package/src/redux/profileSelectionSlice.ts +81 -0
- package/src/redux/profilesSlice.ts +47 -0
- package/src/redux/resolveInitialProfiles.ts +26 -0
- package/src/redux/seatColorsSlice.ts +96 -0
- package/src/redux/useSharedProfilesSync.ts +74 -0
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
"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.
|
|
67
|
-
"@infinitetoken/tsconfig": "^0.
|
|
68
|
-
"@rific/auto-paper": "^0.
|
|
69
|
-
"@rific/
|
|
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.
|
|
73
|
-
"@tastic/hud": "^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",
|
package/src/ProfilesManager.tsx
CHANGED
|
@@ -21,7 +21,10 @@ const CHIP_SIZE = 40
|
|
|
21
21
|
// as @tastic/hud's BaseStatsScreen does for its own identical titleVariant prop.
|
|
22
22
|
type MD3TextVariant = 'displayLarge' | 'displayMedium' | 'displaySmall' | 'headlineLarge' | 'headlineMedium' | 'headlineSmall' | 'titleLarge' | 'titleMedium' | 'titleSmall' | 'labelLarge' | 'labelMedium' | 'labelSmall' | 'bodyLarge' | 'bodyMedium' | 'bodySmall'
|
|
23
23
|
|
|
24
|
-
|
|
24
|
+
// Exported (not just used internally) so a host composing a screen around this component — e.g.
|
|
25
|
+
// @tastic/profile's own ProfilesScreen — can reference this exact shape in its own onCreate/onSave
|
|
26
|
+
// prop types without redeclaring it.
|
|
27
|
+
export interface ProfileEditPatch {
|
|
25
28
|
name: string
|
|
26
29
|
color: string
|
|
27
30
|
tag: string
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { useAutoPaperTheme } from '@rific/auto-paper'
|
|
2
|
+
import { IconButton } from '@rific/feedback-press'
|
|
3
|
+
import { useRef } from 'react'
|
|
4
|
+
import { StyleSheet, View } from 'react-native'
|
|
5
|
+
|
|
6
|
+
import { ProfileEditPatch, ProfilesManager, ProfilesManagerHandle } from './ProfilesManager'
|
|
7
|
+
import { Profile } from './types'
|
|
8
|
+
|
|
9
|
+
// react-native-paper's own MD3 Text variant names — mirrored locally rather than importing its
|
|
10
|
+
// internal VariantProp/MD3TypescaleKey (not exported from the package's public entry point), same
|
|
11
|
+
// as ProfilesManager's own identical titleVariant prop does.
|
|
12
|
+
type MD3TextVariant = 'displayLarge' | 'displayMedium' | 'displaySmall' | 'headlineLarge' | 'headlineMedium' | 'headlineSmall' | 'titleLarge' | 'titleMedium' | 'titleSmall' | 'labelLarge' | 'labelMedium' | 'labelSmall' | 'bodyLarge' | 'bodyMedium' | 'bodySmall'
|
|
13
|
+
|
|
14
|
+
export interface ProfilesScreenProps {
|
|
15
|
+
profiles: Profile[]
|
|
16
|
+
// Same reasoning as ProfilesManager's own defaultColor: this component has no opinion on the
|
|
17
|
+
// host's palette, so it stays required rather than defaulted. Callers already differ here today
|
|
18
|
+
// (most apps pass @rific/auto-paper's defaultColors[0].value; one passes its own local
|
|
19
|
+
// DEFAULT_P1_COLOR constant instead) — this prop is exactly what lets both keep working unchanged.
|
|
20
|
+
defaultColor: string
|
|
21
|
+
// Same ProfileEditPatch shape ProfilesManager's own onCreate/onSave already take — deliberately
|
|
22
|
+
// NOT widened to accept a host's own richer CreateProfileInput. A host whose profile shape has
|
|
23
|
+
// extra required fields (e.g. a control/key scheme) composes those in its own closure at the call
|
|
24
|
+
// site, exactly as it already does calling <ProfilesManager> today.
|
|
25
|
+
onCreate: (patch: ProfileEditPatch) => void
|
|
26
|
+
onSave: (id: string, patch: ProfileEditPatch) => void
|
|
27
|
+
// Irreversible, same as ProfilesManager's own onDelete — fires once, after this component's
|
|
28
|
+
// internal confirmation card. If the host tracks anything else keyed by profile id (stats,
|
|
29
|
+
// achievement-unlock history), clear that on its own side inside this callback, same as every
|
|
30
|
+
// current app's `(id) => { deleteProfile(id); removeProfileStats(id) }`.
|
|
31
|
+
onDelete: (id: string) => void
|
|
32
|
+
onBack: () => void
|
|
33
|
+
// Independently optional overrides, each defaulting to the same literal-black/white-by-appearance
|
|
34
|
+
// formula every current caller renders with unchanged (none of them pass these today) — same
|
|
35
|
+
// convention as BaseStatsScreen's and ProfilesManager's own fg/bg-shaped overrides. fg/fgMuted/
|
|
36
|
+
// cardBg/titleVariant/colorPreview are forwarded straight through to the internal ProfilesManager.
|
|
37
|
+
fg?: string
|
|
38
|
+
fgMuted?: string
|
|
39
|
+
bg?: string
|
|
40
|
+
cardBg?: string
|
|
41
|
+
titleVariant?: MD3TextVariant
|
|
42
|
+
colorPreview?: (hex: string) => string
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// The routed profiles-roster screen shell every app in this fleet hand-rolls around ProfilesManager
|
|
46
|
+
// today: the container View + theme-derived bg/fg + back button + commit-pending-edit-before-
|
|
47
|
+
// navigate composition. Lives here rather than in @tastic/hud since it has zero generic/non-profile
|
|
48
|
+
// UI content — it's one layer up from ProfilesManager, not a different kind of thing. Deliberately
|
|
49
|
+
// still doesn't call useProfiles()/useGameStats() (or any host-specific hook) itself — those two
|
|
50
|
+
// hooks' shapes are genuinely app-specific (different CreateProfileInput/GameStatsContextValue per
|
|
51
|
+
// app), so this stays props-in/callbacks-out, exactly like ProfilesManager itself.
|
|
52
|
+
export function ProfilesScreen({ profiles, defaultColor, onCreate, onSave, onDelete, onBack, fg: fgOverride, fgMuted, bg: bgOverride, cardBg, titleVariant, colorPreview }: ProfilesScreenProps) {
|
|
53
|
+
const { dark } = useAutoPaperTheme()
|
|
54
|
+
// Owns the ref itself — nothing outside this component ever needs it, since this is now the one
|
|
55
|
+
// thing that both renders ProfilesManager AND owns the only navigation trigger next to it.
|
|
56
|
+
const managerRef = useRef<ProfilesManagerHandle>(null)
|
|
57
|
+
// Same literal-black/white-by-appearance formula as ProfilesManager's own fg default, resolved
|
|
58
|
+
// here (rather than left to ProfilesManager) so this component's own headerLeft IconButton and
|
|
59
|
+
// ProfilesManager's title always share the identical color.
|
|
60
|
+
const fg = fgOverride ?? (dark ? '#FFFFFF' : '#000000')
|
|
61
|
+
const bg = bgOverride ?? (dark ? '#000000' : '#FFFFFF')
|
|
62
|
+
|
|
63
|
+
// Folds in the exact 2-line composition every app hand-rolls today: a hardware back gesture or
|
|
64
|
+
// swipe-back unmounts ProfilesManager (which flushes on its own), but a host router that keeps a
|
|
65
|
+
// popped screen mounted-but-hidden instead of unmounting it (expo-router's web renderer) needs
|
|
66
|
+
// this explicit flush before navigating away, or an in-progress edit is silently lost.
|
|
67
|
+
const handleBack = () => {
|
|
68
|
+
managerRef.current?.commitPendingEdit()
|
|
69
|
+
onBack()
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return (
|
|
73
|
+
<View style={[styles.container, { backgroundColor: bg }]}>
|
|
74
|
+
<ProfilesManager ref={managerRef} profiles={profiles} defaultColor={defaultColor} onCreate={onCreate} onSave={onSave} onDelete={onDelete} fg={fg} fgMuted={fgMuted} cardBg={cardBg} titleVariant={titleVariant} colorPreview={colorPreview} headerLeft={<IconButton icon='arrow-left' iconColor={fg} size={24} onPress={handleBack} accessibilityLabel='Back' />} />
|
|
75
|
+
</View>
|
|
76
|
+
)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const styles = StyleSheet.create({
|
|
80
|
+
container: {
|
|
81
|
+
flex: 1
|
|
82
|
+
}
|
|
83
|
+
})
|
package/src/index.ts
CHANGED
|
@@ -1,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
|
+
}
|