@tastic/profile 0.11.0 → 0.13.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/LICENSE +21 -0
- package/README.md +91 -70
- package/dist/index.d.mts +6 -5
- package/dist/index.d.ts +6 -5
- package/dist/index.js +55 -55
- package/dist/index.mjs +55 -57
- package/ios/TasticProfileModule.swift +2 -2
- package/package.json +10 -10
- package/src/ProfileChip.tsx +5 -5
- package/src/ProfilePicker.tsx +31 -31
- package/src/ProfilesManager.tsx +55 -107
- package/src/ProfilesScreen.tsx +11 -12
- package/src/index.ts +2 -1
- package/src/profilesValidation.ts +9 -9
- package/src/redux/createActionCreator.ts +1 -1
- package/src/redux/profileExtensionSlice.ts +3 -3
- package/src/redux/profileSelectionSlice.ts +39 -11
- package/src/redux/resolveInitialProfiles.ts +10 -6
- package/src/redux/seatColorsSlice.ts +10 -10
- package/src/redux/useSharedProfilesSync.ts +11 -10
- package/src/seatColorClash.ts +13 -0
- package/src/sharedProfileStore.ts +27 -12
- package/src/types.ts +3 -3
package/dist/index.js
CHANGED
|
@@ -27,6 +27,7 @@ __export(index_exports, {
|
|
|
27
27
|
ProfilePicker: () => ProfilePicker,
|
|
28
28
|
ProfilesManager: () => ProfilesManager,
|
|
29
29
|
ProfilesScreen: () => ProfilesScreen,
|
|
30
|
+
avoidSeatColorClash: () => avoidSeatColorClash,
|
|
30
31
|
createProfileExtensionSlice: () => createProfileExtensionSlice,
|
|
31
32
|
createProfileRecord: () => createProfileRecord,
|
|
32
33
|
createProfileSelectionSlice: () => createProfileSelectionSlice,
|
|
@@ -38,6 +39,7 @@ __export(index_exports, {
|
|
|
38
39
|
loadSharedProfiles: () => loadSharedProfiles,
|
|
39
40
|
profilesActions: () => profilesActions,
|
|
40
41
|
profilesReducer: () => profilesReducer,
|
|
42
|
+
readSharedProfiles: () => readSharedProfiles,
|
|
41
43
|
resolveInitialProfiles: () => resolveInitialProfiles,
|
|
42
44
|
saveSharedProfiles: () => saveSharedProfiles,
|
|
43
45
|
useProfileColorPreview: () => useProfileColorPreview,
|
|
@@ -189,12 +191,12 @@ var styles2 = import_react_native2.StyleSheet.create({
|
|
|
189
191
|
anchor: {
|
|
190
192
|
position: "relative"
|
|
191
193
|
},
|
|
192
|
-
// See InlineColorPicker's identical comment
|
|
194
|
+
// See InlineColorPicker's identical comment - React Native Web gives every position:'relative'
|
|
193
195
|
// view its own stacking context, so the elevation has to live on the anchor itself.
|
|
194
196
|
anchorOpen: {
|
|
195
197
|
zIndex: 100
|
|
196
198
|
},
|
|
197
|
-
// Separates Manage (a navigation action) from the selectable rows above it
|
|
199
|
+
// Separates Manage (a navigation action) from the selectable rows above it - different kind of
|
|
198
200
|
// action, not just another row in the same list. Tinted with the seat's own color (passed in as
|
|
199
201
|
// backgroundColor) rather than a flat gray, matching the border/caret's own accent-tinted look.
|
|
200
202
|
divider: {
|
|
@@ -203,7 +205,7 @@ var styles2 = import_react_native2.StyleSheet.create({
|
|
|
203
205
|
opacity: 0.25
|
|
204
206
|
},
|
|
205
207
|
// Matches ProfileChip's own default size/shape exactly (see this row's own render-site comment)
|
|
206
|
-
//
|
|
208
|
+
// - no backgroundColor, unlike ProfileChip's own default: this row has no identity color of its
|
|
207
209
|
// own to fill it with, and the render site now colors the icon directly instead.
|
|
208
210
|
guestChip: {
|
|
209
211
|
alignItems: "center",
|
|
@@ -217,7 +219,7 @@ var styles2 = import_react_native2.StyleSheet.create({
|
|
|
217
219
|
fontWeight: "700",
|
|
218
220
|
letterSpacing: 0.5
|
|
219
221
|
},
|
|
220
|
-
// Centered rather than left-aligned like a normal row
|
|
222
|
+
// Centered rather than left-aligned like a normal row - see this row's own render-site comment
|
|
221
223
|
// for why (a footer action, not another selectable identity).
|
|
222
224
|
manageRowInner: {
|
|
223
225
|
alignItems: "center",
|
|
@@ -235,7 +237,7 @@ var styles2 = import_react_native2.StyleSheet.create({
|
|
|
235
237
|
shadowOpacity: 0.3,
|
|
236
238
|
shadowRadius: 8
|
|
237
239
|
},
|
|
238
|
-
// Padded on every side (not just top/bottom)
|
|
240
|
+
// Padded on every side (not just top/bottom) - a selected row's own full-color background (see
|
|
239
241
|
// `row`'s borderRadius) needs real margin from the menu's own straight edges to read as a rounded
|
|
240
242
|
// pill instead of looking clipped against them.
|
|
241
243
|
menuContent: {
|
|
@@ -325,19 +327,15 @@ function deriveTag(name) {
|
|
|
325
327
|
const trimmed = name.trim();
|
|
326
328
|
return trimmed ? trimmed[0].toUpperCase() : "";
|
|
327
329
|
}
|
|
328
|
-
function ProfilesManager({ profiles, defaultColor, onCreate, onSave, onDelete, headerLeft, ref, fg: fgOverride, fgMuted: fgMutedOverride,
|
|
330
|
+
function ProfilesManager({ profiles, defaultColor, onCreate, onSave, onDelete, headerLeft, ref, fg: fgOverride, fgMuted: fgMutedOverride, titleVariant = "headlineSmall", colorPreview: colorPreviewProp, useColorPickerAlign }) {
|
|
329
331
|
const contextColorPreview = useProfileColorPreview();
|
|
330
332
|
const colorPreview = colorPreviewProp ?? contextColorPreview;
|
|
331
|
-
const { dark
|
|
333
|
+
const { dark } = (0, import_auto_paper3.useAutoPaperTheme)();
|
|
332
334
|
const insets = (0, import_react_native_safe_area_context.useSafeAreaInsets)();
|
|
333
335
|
const { error: showErrorToast } = (0, import_toaster.useToast)();
|
|
334
336
|
const host = (0, import_hud2.usePopoverHost)();
|
|
335
337
|
const fg = fgOverride ?? (dark ? "#FFFFFF" : "#000000");
|
|
336
338
|
const fgMuted = fgMutedOverride ?? (dark ? "rgba(255,255,255,0.5)" : "rgba(0,0,0,0.5)");
|
|
337
|
-
const cardBg = cardBgOverride ?? (dark ? "#111111" : "#F2F2F2");
|
|
338
|
-
const cardBorder = dark ? "rgba(255,255,255,0.2)" : "rgba(0,0,0,0.2)";
|
|
339
|
-
const cancelBg = dark ? "#2A2A2A" : "#E0E0E0";
|
|
340
|
-
const cancelFg = dark ? "#FFFFFF" : "#000000";
|
|
341
339
|
const [editingId, setEditingId] = (0, import_react3.useState)(null);
|
|
342
340
|
const [draftName, setDraftName] = (0, import_react3.useState)("");
|
|
343
341
|
const [draftColor, setDraftColor] = (0, import_react3.useState)(defaultColor);
|
|
@@ -424,17 +422,7 @@ function ProfilesManager({ profiles, defaultColor, onCreate, onSave, onDelete, h
|
|
|
424
422
|
/* @__PURE__ */ (0, import_jsx_runtime4.jsx)(import_react_native_paper3.Text, { variant: "bodyLarge", style: [styles3.rowName, { color: fg }], numberOfLines: 1, children: profile.name })
|
|
425
423
|
] }) }, profile.id);
|
|
426
424
|
}) }) }),
|
|
427
|
-
|
|
428
|
-
/* @__PURE__ */ (0, import_jsx_runtime4.jsx)(import_react_native_paper3.Icon, { source: "alert-outline", size: 64, color: colors.danger }),
|
|
429
|
-
/* @__PURE__ */ (0, import_jsx_runtime4.jsx)(import_react_native_paper3.Text, { variant: "headlineLarge", style: [styles3.overlayTitle, { color: colors.danger }], children: "Delete Profile?" }),
|
|
430
|
-
/* @__PURE__ */ (0, import_jsx_runtime4.jsxs)(import_react_native_paper3.Text, { variant: "bodyLarge", style: [styles3.overlayBody, { color: fg }], children: [
|
|
431
|
-
"This permanently deletes ",
|
|
432
|
-
confirmDeleteProfile.name,
|
|
433
|
-
"\u2019s profile. This cannot be undone."
|
|
434
|
-
] }),
|
|
435
|
-
/* @__PURE__ */ (0, import_jsx_runtime4.jsx)(import_react_native_paper3.Button, { mode: "contained", onPress: () => setConfirmDeleteId(null), style: styles3.overlayButton, buttonColor: cancelBg, textColor: cancelFg, children: "Cancel" }),
|
|
436
|
-
/* @__PURE__ */ (0, import_jsx_runtime4.jsx)(import_react_native_paper3.Button, { mode: "contained", onPress: handleConfirmDelete, style: styles3.overlayButton, buttonColor: colors.danger, textColor: colors.onDanger, children: "Delete" })
|
|
437
|
-
] }) }) })
|
|
425
|
+
/* @__PURE__ */ (0, import_jsx_runtime4.jsx)(import_hud2.ConfirmDialog, { visible: confirmDeleteProfile !== null, title: "Delete Profile?", message: confirmDeleteProfile ? `This permanently deletes ${confirmDeleteProfile.name}\u2019s profile. This cannot be undone.` : "", confirmLabel: "Delete", cancelLabel: "Cancel", icon: "alert-outline", destructive: true, onConfirm: handleConfirmDelete, onCancel: () => setConfirmDeleteId(null) })
|
|
438
426
|
] });
|
|
439
427
|
}
|
|
440
428
|
function AlignedColorPicker({ useAlign, ...props }) {
|
|
@@ -478,7 +466,7 @@ var styles3 = import_react_native3.StyleSheet.create({
|
|
|
478
466
|
gap: 4,
|
|
479
467
|
paddingBottom: 8
|
|
480
468
|
},
|
|
481
|
-
// 1x1 and fully transparent
|
|
469
|
+
// 1x1 and fully transparent - there's nothing to see here on purpose (see EditRow's own comment
|
|
482
470
|
// above the field this styles). position: 'absolute' pulls it out of editRow's flex flow
|
|
483
471
|
// entirely, so it doesn't reserve a gap in the row the way a normal flex sibling would.
|
|
484
472
|
hiddenTagInput: {
|
|
@@ -505,27 +493,6 @@ var styles3 = import_react_native3.StyleSheet.create({
|
|
|
505
493
|
borderStyle: "dashed",
|
|
506
494
|
borderWidth: 2
|
|
507
495
|
},
|
|
508
|
-
overlay: {
|
|
509
|
-
alignItems: "center",
|
|
510
|
-
backgroundColor: "rgba(0,0,0,0.72)",
|
|
511
|
-
bottom: 0,
|
|
512
|
-
justifyContent: "center",
|
|
513
|
-
left: 0,
|
|
514
|
-
position: "absolute",
|
|
515
|
-
right: 0,
|
|
516
|
-
top: 0
|
|
517
|
-
},
|
|
518
|
-
overlayBody: { textAlign: "center" },
|
|
519
|
-
overlayButton: { width: 160 },
|
|
520
|
-
overlayCard: {
|
|
521
|
-
alignItems: "center",
|
|
522
|
-
borderRadius: 20,
|
|
523
|
-
borderWidth: 1,
|
|
524
|
-
gap: 16,
|
|
525
|
-
maxWidth: 360,
|
|
526
|
-
padding: 32
|
|
527
|
-
},
|
|
528
|
-
overlayTitle: { fontWeight: "bold", marginBottom: -8 },
|
|
529
496
|
row: {
|
|
530
497
|
borderRadius: 8,
|
|
531
498
|
paddingVertical: 8
|
|
@@ -560,7 +527,7 @@ var import_feedback_press3 = require("@rific/feedback-press");
|
|
|
560
527
|
var import_react4 = require("react");
|
|
561
528
|
var import_react_native4 = require("react-native");
|
|
562
529
|
var import_jsx_runtime5 = require("react/jsx-runtime");
|
|
563
|
-
function ProfilesScreen({ profiles, defaultColor, onCreate, onSave, onDelete, onBack, fg: fgOverride, fgMuted, bg: bgOverride,
|
|
530
|
+
function ProfilesScreen({ profiles, defaultColor, onCreate, onSave, onDelete, onBack, fg: fgOverride, fgMuted, bg: bgOverride, titleVariant, colorPreview, useColorPickerAlign }) {
|
|
564
531
|
const { dark } = (0, import_auto_paper4.useAutoPaperTheme)();
|
|
565
532
|
const managerRef = (0, import_react4.useRef)(null);
|
|
566
533
|
const fg = fgOverride ?? (dark ? "#FFFFFF" : "#000000");
|
|
@@ -569,7 +536,7 @@ function ProfilesScreen({ profiles, defaultColor, onCreate, onSave, onDelete, on
|
|
|
569
536
|
managerRef.current?.commitPendingEdit();
|
|
570
537
|
onBack();
|
|
571
538
|
};
|
|
572
|
-
return /* @__PURE__ */ (0, import_jsx_runtime5.jsx)(import_react_native4.View, { style: [styles4.container, { backgroundColor: bg }], children: /* @__PURE__ */ (0, import_jsx_runtime5.jsx)(ProfilesManager, { ref: managerRef, profiles, defaultColor, onCreate, onSave, onDelete, fg, fgMuted,
|
|
539
|
+
return /* @__PURE__ */ (0, import_jsx_runtime5.jsx)(import_react_native4.View, { style: [styles4.container, { backgroundColor: bg }], children: /* @__PURE__ */ (0, import_jsx_runtime5.jsx)(ProfilesManager, { ref: managerRef, profiles, defaultColor, onCreate, onSave, onDelete, fg, fgMuted, titleVariant, colorPreview, useColorPickerAlign, headerLeft: /* @__PURE__ */ (0, import_jsx_runtime5.jsx)(import_feedback_press3.IconButton, { icon: "arrow-left", iconColor: fg, size: 24, onPress: handleBack, accessibilityLabel: "Back" }) }) });
|
|
573
540
|
}
|
|
574
541
|
var styles4 = import_react_native4.StyleSheet.create({
|
|
575
542
|
container: {
|
|
@@ -611,9 +578,27 @@ function createProfileSelectionSlice(namespace, defaultState, mountKey = "profil
|
|
|
611
578
|
function reducer(state = defaultState, action) {
|
|
612
579
|
if (action.type === REHYDRATE) {
|
|
613
580
|
const persisted = action.payload?.[mountKey];
|
|
614
|
-
|
|
581
|
+
const merged = { ...defaultState, ...state, ...persisted };
|
|
582
|
+
const seen = /* @__PURE__ */ new Set();
|
|
583
|
+
for (const seat of Object.keys(merged)) {
|
|
584
|
+
const profileId = merged[seat];
|
|
585
|
+
if (profileId === null) continue;
|
|
586
|
+
if (seen.has(profileId)) merged[seat] = null;
|
|
587
|
+
else seen.add(profileId);
|
|
588
|
+
}
|
|
589
|
+
return merged;
|
|
590
|
+
}
|
|
591
|
+
if (select.match(action)) {
|
|
592
|
+
const { profileId, seat } = action.payload;
|
|
593
|
+
const previous = state[seat] ?? null;
|
|
594
|
+
const next = { ...state, [seat]: profileId };
|
|
595
|
+
if (profileId !== null) {
|
|
596
|
+
for (const other of Object.keys(state)) {
|
|
597
|
+
if (String(other) !== String(seat) && state[other] === profileId) next[other] = previous === profileId ? null : previous;
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
return next;
|
|
615
601
|
}
|
|
616
|
-
if (select.match(action)) return { ...state, [action.payload.seat]: action.payload.profileId };
|
|
617
602
|
if (clearProfile.match(action)) {
|
|
618
603
|
const next = { ...state };
|
|
619
604
|
for (const seat of Object.keys(state)) {
|
|
@@ -673,22 +658,27 @@ try {
|
|
|
673
658
|
}
|
|
674
659
|
var PROFILES_KEY = "sharedProfiles";
|
|
675
660
|
var isSharedProfileStoreAvailable = native !== null;
|
|
676
|
-
async function
|
|
661
|
+
async function readSharedProfiles(groupId) {
|
|
677
662
|
if (!native) return [];
|
|
678
663
|
let json = null;
|
|
679
664
|
try {
|
|
680
665
|
json = await native.getSharedJSON(groupId, PROFILES_KEY);
|
|
681
666
|
} catch {
|
|
682
|
-
return
|
|
667
|
+
return null;
|
|
683
668
|
}
|
|
684
669
|
if (!json) return [];
|
|
685
670
|
try {
|
|
686
671
|
const parsed = JSON.parse(json);
|
|
687
|
-
|
|
672
|
+
if (!Array.isArray(parsed)) return null;
|
|
673
|
+
const profiles = parsed.filter(isValidProfile);
|
|
674
|
+
return parsed.length > 0 && profiles.length === 0 ? null : profiles;
|
|
688
675
|
} catch {
|
|
689
|
-
return
|
|
676
|
+
return null;
|
|
690
677
|
}
|
|
691
678
|
}
|
|
679
|
+
async function loadSharedProfiles(groupId) {
|
|
680
|
+
return await readSharedProfiles(groupId) ?? [];
|
|
681
|
+
}
|
|
692
682
|
async function saveSharedProfiles(groupId, profiles) {
|
|
693
683
|
if (!native) return;
|
|
694
684
|
try {
|
|
@@ -700,7 +690,8 @@ async function saveSharedProfiles(groupId, profiles) {
|
|
|
700
690
|
// src/redux/resolveInitialProfiles.ts
|
|
701
691
|
async function resolveInitialProfiles(groupId, localFallback) {
|
|
702
692
|
if (!isSharedProfileStoreAvailable) return localFallback;
|
|
703
|
-
const shared = await
|
|
693
|
+
const shared = await readSharedProfiles(groupId);
|
|
694
|
+
if (shared === null) return localFallback;
|
|
704
695
|
if (shared.length === 0 && localFallback.length > 0) {
|
|
705
696
|
saveSharedProfiles(groupId, localFallback).catch(() => {
|
|
706
697
|
});
|
|
@@ -742,8 +733,8 @@ function useSharedProfilesSync({ groupId, onRemoteChange }) {
|
|
|
742
733
|
let cancelled = false;
|
|
743
734
|
const sub = import_react_native5.AppState.addEventListener("change", (next) => {
|
|
744
735
|
if (next !== "active" || pendingWriteRef.current) return;
|
|
745
|
-
|
|
746
|
-
if (!cancelled) onRemoteChangeRef.current(shared);
|
|
736
|
+
readSharedProfiles(groupId).then((shared) => {
|
|
737
|
+
if (!cancelled && shared !== null) onRemoteChangeRef.current(shared);
|
|
747
738
|
});
|
|
748
739
|
});
|
|
749
740
|
return () => {
|
|
@@ -764,6 +755,13 @@ function useSharedProfilesSync({ groupId, onRemoteChange }) {
|
|
|
764
755
|
);
|
|
765
756
|
return { syncToShared };
|
|
766
757
|
}
|
|
758
|
+
|
|
759
|
+
// src/seatColorClash.ts
|
|
760
|
+
function avoidSeatColorClash(p1Color, p2Color, defaults) {
|
|
761
|
+
if (p2Color.toLowerCase() !== p1Color.toLowerCase()) return p2Color;
|
|
762
|
+
const [p1Default, p2Default] = defaults;
|
|
763
|
+
return p2Color.toLowerCase() === p1Default.toLowerCase() ? p2Default : p1Default;
|
|
764
|
+
}
|
|
767
765
|
// Annotate the CommonJS export names for ESM import in node:
|
|
768
766
|
0 && (module.exports = {
|
|
769
767
|
MAX_PROFILE_NAME_LENGTH,
|
|
@@ -773,6 +771,7 @@ function useSharedProfilesSync({ groupId, onRemoteChange }) {
|
|
|
773
771
|
ProfilePicker,
|
|
774
772
|
ProfilesManager,
|
|
775
773
|
ProfilesScreen,
|
|
774
|
+
avoidSeatColorClash,
|
|
776
775
|
createProfileExtensionSlice,
|
|
777
776
|
createProfileRecord,
|
|
778
777
|
createProfileSelectionSlice,
|
|
@@ -784,6 +783,7 @@ function useSharedProfilesSync({ groupId, onRemoteChange }) {
|
|
|
784
783
|
loadSharedProfiles,
|
|
785
784
|
profilesActions,
|
|
786
785
|
profilesReducer,
|
|
786
|
+
readSharedProfiles,
|
|
787
787
|
resolveInitialProfiles,
|
|
788
788
|
saveSharedProfiles,
|
|
789
789
|
useProfileColorPreview,
|
package/dist/index.mjs
CHANGED
|
@@ -148,12 +148,12 @@ var styles2 = StyleSheet2.create({
|
|
|
148
148
|
anchor: {
|
|
149
149
|
position: "relative"
|
|
150
150
|
},
|
|
151
|
-
// See InlineColorPicker's identical comment
|
|
151
|
+
// See InlineColorPicker's identical comment - React Native Web gives every position:'relative'
|
|
152
152
|
// view its own stacking context, so the elevation has to live on the anchor itself.
|
|
153
153
|
anchorOpen: {
|
|
154
154
|
zIndex: 100
|
|
155
155
|
},
|
|
156
|
-
// Separates Manage (a navigation action) from the selectable rows above it
|
|
156
|
+
// Separates Manage (a navigation action) from the selectable rows above it - different kind of
|
|
157
157
|
// action, not just another row in the same list. Tinted with the seat's own color (passed in as
|
|
158
158
|
// backgroundColor) rather than a flat gray, matching the border/caret's own accent-tinted look.
|
|
159
159
|
divider: {
|
|
@@ -162,7 +162,7 @@ var styles2 = StyleSheet2.create({
|
|
|
162
162
|
opacity: 0.25
|
|
163
163
|
},
|
|
164
164
|
// Matches ProfileChip's own default size/shape exactly (see this row's own render-site comment)
|
|
165
|
-
//
|
|
165
|
+
// - no backgroundColor, unlike ProfileChip's own default: this row has no identity color of its
|
|
166
166
|
// own to fill it with, and the render site now colors the icon directly instead.
|
|
167
167
|
guestChip: {
|
|
168
168
|
alignItems: "center",
|
|
@@ -176,7 +176,7 @@ var styles2 = StyleSheet2.create({
|
|
|
176
176
|
fontWeight: "700",
|
|
177
177
|
letterSpacing: 0.5
|
|
178
178
|
},
|
|
179
|
-
// Centered rather than left-aligned like a normal row
|
|
179
|
+
// Centered rather than left-aligned like a normal row - see this row's own render-site comment
|
|
180
180
|
// for why (a footer action, not another selectable identity).
|
|
181
181
|
manageRowInner: {
|
|
182
182
|
alignItems: "center",
|
|
@@ -194,7 +194,7 @@ var styles2 = StyleSheet2.create({
|
|
|
194
194
|
shadowOpacity: 0.3,
|
|
195
195
|
shadowRadius: 8
|
|
196
196
|
},
|
|
197
|
-
// Padded on every side (not just top/bottom)
|
|
197
|
+
// Padded on every side (not just top/bottom) - a selected row's own full-color background (see
|
|
198
198
|
// `row`'s borderRadius) needs real margin from the menu's own straight edges to read as a rounded
|
|
199
199
|
// pill instead of looking clipped against them.
|
|
200
200
|
menuContent: {
|
|
@@ -239,10 +239,10 @@ import { defaultColors, useAutoPaperTheme } from "@rific/auto-paper";
|
|
|
239
239
|
import { TouchableRipple as TouchableRipple2 } from "@rific/feedback-press";
|
|
240
240
|
import { useFocusChain } from "@rific/focus-chain";
|
|
241
241
|
import { useToast } from "@rific/toaster";
|
|
242
|
-
import { getInlineColorPickerContentSize, InlineColorPicker, usePopoverHost } from "@tastic/hud";
|
|
242
|
+
import { ConfirmDialog, getInlineColorPickerContentSize, InlineColorPicker, usePopoverHost } from "@tastic/hud";
|
|
243
243
|
import { useCallback, useEffect, useImperativeHandle, useMemo as useMemo2, useRef, useState } from "react";
|
|
244
244
|
import { KeyboardAvoidingView, Platform, ScrollView as ScrollView2, StyleSheet as StyleSheet3, useWindowDimensions, View as View3 } from "react-native";
|
|
245
|
-
import {
|
|
245
|
+
import { Icon as Icon3, Text as Text3, TextInput } from "react-native-paper";
|
|
246
246
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
|
247
247
|
|
|
248
248
|
// src/profilesValidation.ts
|
|
@@ -284,19 +284,15 @@ function deriveTag(name) {
|
|
|
284
284
|
const trimmed = name.trim();
|
|
285
285
|
return trimmed ? trimmed[0].toUpperCase() : "";
|
|
286
286
|
}
|
|
287
|
-
function ProfilesManager({ profiles, defaultColor, onCreate, onSave, onDelete, headerLeft, ref, fg: fgOverride, fgMuted: fgMutedOverride,
|
|
287
|
+
function ProfilesManager({ profiles, defaultColor, onCreate, onSave, onDelete, headerLeft, ref, fg: fgOverride, fgMuted: fgMutedOverride, titleVariant = "headlineSmall", colorPreview: colorPreviewProp, useColorPickerAlign }) {
|
|
288
288
|
const contextColorPreview = useProfileColorPreview();
|
|
289
289
|
const colorPreview = colorPreviewProp ?? contextColorPreview;
|
|
290
|
-
const { dark
|
|
290
|
+
const { dark } = useAutoPaperTheme();
|
|
291
291
|
const insets = useSafeAreaInsets();
|
|
292
292
|
const { error: showErrorToast } = useToast();
|
|
293
293
|
const host = usePopoverHost();
|
|
294
294
|
const fg = fgOverride ?? (dark ? "#FFFFFF" : "#000000");
|
|
295
295
|
const fgMuted = fgMutedOverride ?? (dark ? "rgba(255,255,255,0.5)" : "rgba(0,0,0,0.5)");
|
|
296
|
-
const cardBg = cardBgOverride ?? (dark ? "#111111" : "#F2F2F2");
|
|
297
|
-
const cardBorder = dark ? "rgba(255,255,255,0.2)" : "rgba(0,0,0,0.2)";
|
|
298
|
-
const cancelBg = dark ? "#2A2A2A" : "#E0E0E0";
|
|
299
|
-
const cancelFg = dark ? "#FFFFFF" : "#000000";
|
|
300
296
|
const [editingId, setEditingId] = useState(null);
|
|
301
297
|
const [draftName, setDraftName] = useState("");
|
|
302
298
|
const [draftColor, setDraftColor] = useState(defaultColor);
|
|
@@ -383,17 +379,7 @@ function ProfilesManager({ profiles, defaultColor, onCreate, onSave, onDelete, h
|
|
|
383
379
|
/* @__PURE__ */ jsx4(Text3, { variant: "bodyLarge", style: [styles3.rowName, { color: fg }], numberOfLines: 1, children: profile.name })
|
|
384
380
|
] }) }, profile.id);
|
|
385
381
|
}) }) }),
|
|
386
|
-
|
|
387
|
-
/* @__PURE__ */ jsx4(Icon3, { source: "alert-outline", size: 64, color: colors.danger }),
|
|
388
|
-
/* @__PURE__ */ jsx4(Text3, { variant: "headlineLarge", style: [styles3.overlayTitle, { color: colors.danger }], children: "Delete Profile?" }),
|
|
389
|
-
/* @__PURE__ */ jsxs2(Text3, { variant: "bodyLarge", style: [styles3.overlayBody, { color: fg }], children: [
|
|
390
|
-
"This permanently deletes ",
|
|
391
|
-
confirmDeleteProfile.name,
|
|
392
|
-
"\u2019s profile. This cannot be undone."
|
|
393
|
-
] }),
|
|
394
|
-
/* @__PURE__ */ jsx4(Button, { mode: "contained", onPress: () => setConfirmDeleteId(null), style: styles3.overlayButton, buttonColor: cancelBg, textColor: cancelFg, children: "Cancel" }),
|
|
395
|
-
/* @__PURE__ */ jsx4(Button, { mode: "contained", onPress: handleConfirmDelete, style: styles3.overlayButton, buttonColor: colors.danger, textColor: colors.onDanger, children: "Delete" })
|
|
396
|
-
] }) }) })
|
|
382
|
+
/* @__PURE__ */ jsx4(ConfirmDialog, { visible: confirmDeleteProfile !== null, title: "Delete Profile?", message: confirmDeleteProfile ? `This permanently deletes ${confirmDeleteProfile.name}\u2019s profile. This cannot be undone.` : "", confirmLabel: "Delete", cancelLabel: "Cancel", icon: "alert-outline", destructive: true, onConfirm: handleConfirmDelete, onCancel: () => setConfirmDeleteId(null) })
|
|
397
383
|
] });
|
|
398
384
|
}
|
|
399
385
|
function AlignedColorPicker({ useAlign, ...props }) {
|
|
@@ -437,7 +423,7 @@ var styles3 = StyleSheet3.create({
|
|
|
437
423
|
gap: 4,
|
|
438
424
|
paddingBottom: 8
|
|
439
425
|
},
|
|
440
|
-
// 1x1 and fully transparent
|
|
426
|
+
// 1x1 and fully transparent - there's nothing to see here on purpose (see EditRow's own comment
|
|
441
427
|
// above the field this styles). position: 'absolute' pulls it out of editRow's flex flow
|
|
442
428
|
// entirely, so it doesn't reserve a gap in the row the way a normal flex sibling would.
|
|
443
429
|
hiddenTagInput: {
|
|
@@ -464,27 +450,6 @@ var styles3 = StyleSheet3.create({
|
|
|
464
450
|
borderStyle: "dashed",
|
|
465
451
|
borderWidth: 2
|
|
466
452
|
},
|
|
467
|
-
overlay: {
|
|
468
|
-
alignItems: "center",
|
|
469
|
-
backgroundColor: "rgba(0,0,0,0.72)",
|
|
470
|
-
bottom: 0,
|
|
471
|
-
justifyContent: "center",
|
|
472
|
-
left: 0,
|
|
473
|
-
position: "absolute",
|
|
474
|
-
right: 0,
|
|
475
|
-
top: 0
|
|
476
|
-
},
|
|
477
|
-
overlayBody: { textAlign: "center" },
|
|
478
|
-
overlayButton: { width: 160 },
|
|
479
|
-
overlayCard: {
|
|
480
|
-
alignItems: "center",
|
|
481
|
-
borderRadius: 20,
|
|
482
|
-
borderWidth: 1,
|
|
483
|
-
gap: 16,
|
|
484
|
-
maxWidth: 360,
|
|
485
|
-
padding: 32
|
|
486
|
-
},
|
|
487
|
-
overlayTitle: { fontWeight: "bold", marginBottom: -8 },
|
|
488
453
|
row: {
|
|
489
454
|
borderRadius: 8,
|
|
490
455
|
paddingVertical: 8
|
|
@@ -519,7 +484,7 @@ import { IconButton } from "@rific/feedback-press";
|
|
|
519
484
|
import { useRef as useRef2 } from "react";
|
|
520
485
|
import { StyleSheet as StyleSheet4, View as View4 } from "react-native";
|
|
521
486
|
import { jsx as jsx5 } from "react/jsx-runtime";
|
|
522
|
-
function ProfilesScreen({ profiles, defaultColor, onCreate, onSave, onDelete, onBack, fg: fgOverride, fgMuted, bg: bgOverride,
|
|
487
|
+
function ProfilesScreen({ profiles, defaultColor, onCreate, onSave, onDelete, onBack, fg: fgOverride, fgMuted, bg: bgOverride, titleVariant, colorPreview, useColorPickerAlign }) {
|
|
523
488
|
const { dark } = useAutoPaperTheme2();
|
|
524
489
|
const managerRef = useRef2(null);
|
|
525
490
|
const fg = fgOverride ?? (dark ? "#FFFFFF" : "#000000");
|
|
@@ -528,7 +493,7 @@ function ProfilesScreen({ profiles, defaultColor, onCreate, onSave, onDelete, on
|
|
|
528
493
|
managerRef.current?.commitPendingEdit();
|
|
529
494
|
onBack();
|
|
530
495
|
};
|
|
531
|
-
return /* @__PURE__ */ jsx5(View4, { style: [styles4.container, { backgroundColor: bg }], children: /* @__PURE__ */ jsx5(ProfilesManager, { ref: managerRef, profiles, defaultColor, onCreate, onSave, onDelete, fg, fgMuted,
|
|
496
|
+
return /* @__PURE__ */ jsx5(View4, { style: [styles4.container, { backgroundColor: bg }], children: /* @__PURE__ */ jsx5(ProfilesManager, { ref: managerRef, profiles, defaultColor, onCreate, onSave, onDelete, fg, fgMuted, titleVariant, colorPreview, useColorPickerAlign, headerLeft: /* @__PURE__ */ jsx5(IconButton, { icon: "arrow-left", iconColor: fg, size: 24, onPress: handleBack, accessibilityLabel: "Back" }) }) });
|
|
532
497
|
}
|
|
533
498
|
var styles4 = StyleSheet4.create({
|
|
534
499
|
container: {
|
|
@@ -570,9 +535,27 @@ function createProfileSelectionSlice(namespace, defaultState, mountKey = "profil
|
|
|
570
535
|
function reducer(state = defaultState, action) {
|
|
571
536
|
if (action.type === REHYDRATE) {
|
|
572
537
|
const persisted = action.payload?.[mountKey];
|
|
573
|
-
|
|
538
|
+
const merged = { ...defaultState, ...state, ...persisted };
|
|
539
|
+
const seen = /* @__PURE__ */ new Set();
|
|
540
|
+
for (const seat of Object.keys(merged)) {
|
|
541
|
+
const profileId = merged[seat];
|
|
542
|
+
if (profileId === null) continue;
|
|
543
|
+
if (seen.has(profileId)) merged[seat] = null;
|
|
544
|
+
else seen.add(profileId);
|
|
545
|
+
}
|
|
546
|
+
return merged;
|
|
547
|
+
}
|
|
548
|
+
if (select.match(action)) {
|
|
549
|
+
const { profileId, seat } = action.payload;
|
|
550
|
+
const previous = state[seat] ?? null;
|
|
551
|
+
const next = { ...state, [seat]: profileId };
|
|
552
|
+
if (profileId !== null) {
|
|
553
|
+
for (const other of Object.keys(state)) {
|
|
554
|
+
if (String(other) !== String(seat) && state[other] === profileId) next[other] = previous === profileId ? null : previous;
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
return next;
|
|
574
558
|
}
|
|
575
|
-
if (select.match(action)) return { ...state, [action.payload.seat]: action.payload.profileId };
|
|
576
559
|
if (clearProfile.match(action)) {
|
|
577
560
|
const next = { ...state };
|
|
578
561
|
for (const seat of Object.keys(state)) {
|
|
@@ -632,22 +615,27 @@ try {
|
|
|
632
615
|
}
|
|
633
616
|
var PROFILES_KEY = "sharedProfiles";
|
|
634
617
|
var isSharedProfileStoreAvailable = native !== null;
|
|
635
|
-
async function
|
|
618
|
+
async function readSharedProfiles(groupId) {
|
|
636
619
|
if (!native) return [];
|
|
637
620
|
let json = null;
|
|
638
621
|
try {
|
|
639
622
|
json = await native.getSharedJSON(groupId, PROFILES_KEY);
|
|
640
623
|
} catch {
|
|
641
|
-
return
|
|
624
|
+
return null;
|
|
642
625
|
}
|
|
643
626
|
if (!json) return [];
|
|
644
627
|
try {
|
|
645
628
|
const parsed = JSON.parse(json);
|
|
646
|
-
|
|
629
|
+
if (!Array.isArray(parsed)) return null;
|
|
630
|
+
const profiles = parsed.filter(isValidProfile);
|
|
631
|
+
return parsed.length > 0 && profiles.length === 0 ? null : profiles;
|
|
647
632
|
} catch {
|
|
648
|
-
return
|
|
633
|
+
return null;
|
|
649
634
|
}
|
|
650
635
|
}
|
|
636
|
+
async function loadSharedProfiles(groupId) {
|
|
637
|
+
return await readSharedProfiles(groupId) ?? [];
|
|
638
|
+
}
|
|
651
639
|
async function saveSharedProfiles(groupId, profiles) {
|
|
652
640
|
if (!native) return;
|
|
653
641
|
try {
|
|
@@ -659,7 +647,8 @@ async function saveSharedProfiles(groupId, profiles) {
|
|
|
659
647
|
// src/redux/resolveInitialProfiles.ts
|
|
660
648
|
async function resolveInitialProfiles(groupId, localFallback) {
|
|
661
649
|
if (!isSharedProfileStoreAvailable) return localFallback;
|
|
662
|
-
const shared = await
|
|
650
|
+
const shared = await readSharedProfiles(groupId);
|
|
651
|
+
if (shared === null) return localFallback;
|
|
663
652
|
if (shared.length === 0 && localFallback.length > 0) {
|
|
664
653
|
saveSharedProfiles(groupId, localFallback).catch(() => {
|
|
665
654
|
});
|
|
@@ -701,8 +690,8 @@ function useSharedProfilesSync({ groupId, onRemoteChange }) {
|
|
|
701
690
|
let cancelled = false;
|
|
702
691
|
const sub = AppState.addEventListener("change", (next) => {
|
|
703
692
|
if (next !== "active" || pendingWriteRef.current) return;
|
|
704
|
-
|
|
705
|
-
if (!cancelled) onRemoteChangeRef.current(shared);
|
|
693
|
+
readSharedProfiles(groupId).then((shared) => {
|
|
694
|
+
if (!cancelled && shared !== null) onRemoteChangeRef.current(shared);
|
|
706
695
|
});
|
|
707
696
|
});
|
|
708
697
|
return () => {
|
|
@@ -723,6 +712,13 @@ function useSharedProfilesSync({ groupId, onRemoteChange }) {
|
|
|
723
712
|
);
|
|
724
713
|
return { syncToShared };
|
|
725
714
|
}
|
|
715
|
+
|
|
716
|
+
// src/seatColorClash.ts
|
|
717
|
+
function avoidSeatColorClash(p1Color, p2Color, defaults) {
|
|
718
|
+
if (p2Color.toLowerCase() !== p1Color.toLowerCase()) return p2Color;
|
|
719
|
+
const [p1Default, p2Default] = defaults;
|
|
720
|
+
return p2Color.toLowerCase() === p1Default.toLowerCase() ? p2Default : p1Default;
|
|
721
|
+
}
|
|
726
722
|
export {
|
|
727
723
|
MAX_PROFILE_NAME_LENGTH,
|
|
728
724
|
MAX_TAG_LENGTH,
|
|
@@ -731,6 +727,7 @@ export {
|
|
|
731
727
|
ProfilePicker,
|
|
732
728
|
ProfilesManager,
|
|
733
729
|
ProfilesScreen,
|
|
730
|
+
avoidSeatColorClash,
|
|
734
731
|
createProfileExtensionSlice,
|
|
735
732
|
createProfileRecord,
|
|
736
733
|
createProfileSelectionSlice,
|
|
@@ -742,6 +739,7 @@ export {
|
|
|
742
739
|
loadSharedProfiles,
|
|
743
740
|
profilesActions,
|
|
744
741
|
profilesReducer,
|
|
742
|
+
readSharedProfiles,
|
|
745
743
|
resolveInitialProfiles,
|
|
746
744
|
saveSharedProfiles,
|
|
747
745
|
useProfileColorPreview,
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import ExpoModulesCore
|
|
2
2
|
|
|
3
3
|
// Generic group/key -> JSON-string bridge onto a native iOS App Group's shared UserDefaults suite
|
|
4
|
-
//
|
|
4
|
+
// - the mechanism that actually lets two separate apps (same Apple Developer Team, both declaring
|
|
5
5
|
// the same "com.apple.security.application-groups" entitlement) read and write one common blob of
|
|
6
|
-
// data. Nothing here is @tastic/profile-specific
|
|
6
|
+
// data. Nothing here is @tastic/profile-specific - see src/sharedProfileStore.ts, the one caller,
|
|
7
7
|
// for the profile-roster-shaped API built on top of this. Android/web never register this module
|
|
8
8
|
// at all (see expo-module.config.json's "apple"-only platform list), so a consuming app has to
|
|
9
9
|
// treat "module not present" as a normal, expected case rather than an error.
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tastic/profile",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Player profile identity for local-multiplayer React Native games
|
|
3
|
+
"version": "0.13.0",
|
|
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",
|
|
7
7
|
"expo",
|
|
@@ -65,13 +65,13 @@
|
|
|
65
65
|
"@infinitetoken/eslint-config": "0.2.0",
|
|
66
66
|
"@infinitetoken/jest-config": "^0.4.0",
|
|
67
67
|
"@infinitetoken/tsconfig": "^0.5.0",
|
|
68
|
-
"@rific/auto-paper": "^0.
|
|
69
|
-
"@rific/core": "^0.
|
|
70
|
-
"@rific/feedback-press": "^0.
|
|
71
|
-
"@rific/focus-chain": "^0.4.
|
|
72
|
-
"@rific/toaster": "^0.
|
|
73
|
-
"@tastic/core": "^0.
|
|
74
|
-
"@tastic/hud": "^0.
|
|
68
|
+
"@rific/auto-paper": "^0.13.2",
|
|
69
|
+
"@rific/core": "^0.3.1",
|
|
70
|
+
"@rific/feedback-press": "^0.13.0",
|
|
71
|
+
"@rific/focus-chain": "^0.4.8",
|
|
72
|
+
"@rific/toaster": "^0.8.0",
|
|
73
|
+
"@tastic/core": "^0.14.0",
|
|
74
|
+
"@tastic/hud": "^0.19.0",
|
|
75
75
|
"@testing-library/dom": "^10.4.1",
|
|
76
76
|
"@testing-library/react": "^16.3.2",
|
|
77
77
|
"@types/jest": "^30.0.0",
|
|
@@ -85,7 +85,7 @@
|
|
|
85
85
|
"prettier": "^3.8.3",
|
|
86
86
|
"react": "^19.0.0",
|
|
87
87
|
"react-dom": "^19.0.0",
|
|
88
|
-
"react-native": "^0.
|
|
88
|
+
"react-native": "^0.86.3",
|
|
89
89
|
"react-native-paper": "^5.15.2",
|
|
90
90
|
"react-native-safe-area-context": "^5.0.0",
|
|
91
91
|
"tsup": "^8.0.0",
|
package/src/ProfileChip.tsx
CHANGED
|
@@ -5,7 +5,7 @@ import { Icon, Text } from 'react-native-paper'
|
|
|
5
5
|
import { useProfileDisplayColor } from './ProfileColorContext'
|
|
6
6
|
import { Profile } from './types'
|
|
7
7
|
|
|
8
|
-
// Presence check, not isValidTag's own full-string emoji validation (profilesValidation.ts)
|
|
8
|
+
// Presence check, not isValidTag's own full-string emoji validation (profilesValidation.ts) - this
|
|
9
9
|
// only decides a font-size ratio, so it doesn't need that check's ZWJ/variation-selector precision,
|
|
10
10
|
// just "does this tag contain a pictograph at all."
|
|
11
11
|
const EMOJI_PATTERN = /\p{Extended_Pictographic}/u
|
|
@@ -14,7 +14,7 @@ interface Props {
|
|
|
14
14
|
profile: Profile
|
|
15
15
|
size?: number
|
|
16
16
|
// False on a selected row, whose own background already fills with the profile's color (see
|
|
17
|
-
// ProfilePicker's own selected-row handling)
|
|
17
|
+
// ProfilePicker's own selected-row handling) - a second circle behind the tag there would either
|
|
18
18
|
// double up decoratively or, filled some other flat color, look like a stray badge next to the
|
|
19
19
|
// plain icon+text every other selected row in the app uses. Contrast is still computed against
|
|
20
20
|
// the profile's own color either way (that's what's actually behind the glyph whether this chip
|
|
@@ -24,18 +24,18 @@ interface Props {
|
|
|
24
24
|
filled?: boolean
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
-
// A profile's identity at a glance
|
|
27
|
+
// A profile's identity at a glance - its own color as a filled circle, with its tag (or a generic
|
|
28
28
|
// fallback icon, for a profile with none set) in a contrast-safe color on top. The same combination
|
|
29
29
|
// @tastic/hud's InlineColorPicker trigger shows for live color selection, just as a small static
|
|
30
30
|
// badge for a list row (ProfilePicker's dropdown, ProfilesManager's roster) rather than an
|
|
31
31
|
// interactive picker trigger.
|
|
32
32
|
export function ProfileChip({ profile, size = 22, filled = true }: Props) {
|
|
33
|
-
// The drawn color, not necessarily the saved one
|
|
33
|
+
// The drawn color, not necessarily the saved one - see ProfileColorProvider.
|
|
34
34
|
const color = useProfileDisplayColor(profile.color)
|
|
35
35
|
const contrastColor = getContrastColor(color)
|
|
36
36
|
// An emoji glyph reads visually smaller than a bold letter at the same fontSize (the system emoji
|
|
37
37
|
// font leaves more of its own em-box empty), so a plain-text ratio that looks right for "J" still
|
|
38
|
-
// looks small for "🎮" at the identical size
|
|
38
|
+
// looks small for "🎮" at the identical size - this bumps emoji up to the icon's own ratio instead,
|
|
39
39
|
// since a single emoji is closer in visual weight to an icon glyph than to a line of text.
|
|
40
40
|
const tagFontSize = size * (profile.tag && EMOJI_PATTERN.test(profile.tag) ? 0.6 : 0.4)
|
|
41
41
|
return (
|