@tastic/profile 0.1.2 → 0.3.1
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 +32 -0
- package/dist/index.d.mts +5 -1
- package/dist/index.d.ts +5 -1
- package/dist/index.js +48 -6
- package/dist/index.mjs +51 -5
- package/expo-module.config.json +6 -0
- package/ios/TasticProfile.podspec +26 -0
- package/ios/TasticProfileModule.swift +22 -0
- package/package.json +12 -3
- package/src/ProfilesManager.tsx +16 -10
- package/src/index.ts +1 -0
- package/src/sharedProfileStore.ts +65 -0
package/README.md
CHANGED
|
@@ -54,6 +54,38 @@ see its own doc comment for why that component is generic over your own `Profile
|
|
|
54
54
|
inline in the row you tap, with a destructive-delete confirmation. No navigation of its own (no
|
|
55
55
|
router, no back button) — a host app renders this as a routed screen's body and supplies its own
|
|
56
56
|
persistence via `profiles`/`onCreate`/`onSave`/`onDelete`.
|
|
57
|
+
- **`loadSharedProfiles` / `saveSharedProfiles` / `isSharedProfileStoreAvailable`** — an *optional*
|
|
58
|
+
cross-app roster, shared between multiple apps of yours via a native iOS App Group (same Apple
|
|
59
|
+
Developer Team, same `"com.apple.security.application-groups"` entitlement value on every
|
|
60
|
+
participating app). This only ever carries the base `Profile` shape above — a host app's own
|
|
61
|
+
extension fields (a key scheme, a control scheme, anything else) are NOT part of it and have to
|
|
62
|
+
keep living in that app's own *local* storage, keyed by profile id, merged onto the shared base
|
|
63
|
+
roster at read time (falling back to some per-app default whenever a shared profile's id isn't
|
|
64
|
+
in that app's own local extension table yet — e.g. it was created on a different app in the
|
|
65
|
+
group). This split is deliberate, not a limitation to work around: it's this package's own
|
|
66
|
+
base-fields-only `Profile` boundary (see above), just applied to storage instead of just to the
|
|
67
|
+
TypeScript type.
|
|
68
|
+
|
|
69
|
+
```ts
|
|
70
|
+
// App-side sketch — see BoxHockey's or LightCycles' own useProfiles.tsx for a full example.
|
|
71
|
+
const GROUP_ID = 'group.com.yourteam.yourgames'
|
|
72
|
+
const base = isSharedProfileStoreAvailable ? await loadSharedProfiles(GROUP_ID) : localBaseFallback
|
|
73
|
+
const profiles = base.map((p) => ({ ...p, controlScheme: localExtensions[p.id]?.controlScheme ?? DEFAULT_CONTROL_SCHEME }))
|
|
74
|
+
// ...on create/update/delete, write the base fields back with saveSharedProfiles(GROUP_ID, nextBase)
|
|
75
|
+
// and the extension field to the app's own local storage, same as today.
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
Backed by a small native Expo Module (`ios/TasticProfileModule.swift`) wrapping
|
|
79
|
+
`UserDefaults(suiteName:)` — iOS only (see `expo-module.config.json`'s `"apple"`-only platform
|
|
80
|
+
list); `isSharedProfileStoreAvailable` is `false` on Android/web and on any iOS build that hasn't
|
|
81
|
+
run `expo prebuild` since adding this App Group's entitlement, so a host app always needs its own
|
|
82
|
+
local-storage fallback path regardless of platform. Requires the App Group itself to be declared
|
|
83
|
+
in each consuming app's own Expo config, e.g.:
|
|
84
|
+
|
|
85
|
+
```json
|
|
86
|
+
// app.json
|
|
87
|
+
{ "expo": { "ios": { "entitlements": { "com.apple.security.application-groups": ["group.com.yourteam.yourgames"] } } } }
|
|
88
|
+
```
|
|
57
89
|
|
|
58
90
|
## Install (local dev via yalc)
|
|
59
91
|
|
package/dist/index.d.mts
CHANGED
|
@@ -55,4 +55,8 @@ declare const MAX_TAG_LENGTH = 3;
|
|
|
55
55
|
declare function isValidTag(value: unknown): value is string;
|
|
56
56
|
declare function isValidProfile(value: unknown): value is Profile;
|
|
57
57
|
|
|
58
|
-
|
|
58
|
+
declare const isSharedProfileStoreAvailable: boolean;
|
|
59
|
+
declare function loadSharedProfiles(groupId: string): Promise<Profile[]>;
|
|
60
|
+
declare function saveSharedProfiles(groupId: string, profiles: Profile[]): Promise<void>;
|
|
61
|
+
|
|
62
|
+
export { MAX_PROFILE_NAME_LENGTH, MAX_TAG_LENGTH, type Profile, ProfileChip, ProfilePicker, ProfilesManager, type ProfilesManagerProps, isSharedProfileStoreAvailable, isValidProfile, isValidTag, loadSharedProfiles, saveSharedProfiles };
|
package/dist/index.d.ts
CHANGED
|
@@ -55,4 +55,8 @@ declare const MAX_TAG_LENGTH = 3;
|
|
|
55
55
|
declare function isValidTag(value: unknown): value is string;
|
|
56
56
|
declare function isValidProfile(value: unknown): value is Profile;
|
|
57
57
|
|
|
58
|
-
|
|
58
|
+
declare const isSharedProfileStoreAvailable: boolean;
|
|
59
|
+
declare function loadSharedProfiles(groupId: string): Promise<Profile[]>;
|
|
60
|
+
declare function saveSharedProfiles(groupId: string, profiles: Profile[]): Promise<void>;
|
|
61
|
+
|
|
62
|
+
export { MAX_PROFILE_NAME_LENGTH, MAX_TAG_LENGTH, type Profile, ProfileChip, ProfilePicker, ProfilesManager, type ProfilesManagerProps, isSharedProfileStoreAvailable, isValidProfile, isValidTag, loadSharedProfiles, saveSharedProfiles };
|
package/dist/index.js
CHANGED
|
@@ -25,8 +25,11 @@ __export(index_exports, {
|
|
|
25
25
|
ProfileChip: () => ProfileChip,
|
|
26
26
|
ProfilePicker: () => ProfilePicker,
|
|
27
27
|
ProfilesManager: () => ProfilesManager,
|
|
28
|
+
isSharedProfileStoreAvailable: () => isSharedProfileStoreAvailable,
|
|
28
29
|
isValidProfile: () => isValidProfile,
|
|
29
|
-
isValidTag: () => isValidTag
|
|
30
|
+
isValidTag: () => isValidTag,
|
|
31
|
+
loadSharedProfiles: () => loadSharedProfiles,
|
|
32
|
+
saveSharedProfiles: () => saveSharedProfiles
|
|
30
33
|
});
|
|
31
34
|
module.exports = __toCommonJS(index_exports);
|
|
32
35
|
|
|
@@ -237,6 +240,7 @@ var styles2 = import_react_native2.StyleSheet.create({
|
|
|
237
240
|
// src/ProfilesManager.tsx
|
|
238
241
|
var import_auto_paper3 = require("@rific/auto-paper");
|
|
239
242
|
var import_feedback_press2 = require("@rific/feedback-press");
|
|
243
|
+
var import_focus_chain = require("@rific/focus-chain");
|
|
240
244
|
var import_toaster = require("@rific/toaster");
|
|
241
245
|
var import_hud2 = require("@tastic/hud");
|
|
242
246
|
var import_react2 = require("react");
|
|
@@ -383,12 +387,13 @@ function ProfilesManager({ profiles, defaultColor, onCreate, onSave, onDelete, h
|
|
|
383
387
|
] });
|
|
384
388
|
}
|
|
385
389
|
function EditRow({ draftName, onNameChange, draftColor, onColorChange, draftTag, onTagChange, tagInvalid, onSubmit, onDelete, autoFocus, fgMuted, host, dark }) {
|
|
386
|
-
const
|
|
387
|
-
const
|
|
390
|
+
const register = (0, import_focus_chain.useFocusChain)();
|
|
391
|
+
const tag = register();
|
|
392
|
+
const name = register();
|
|
388
393
|
return /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)(import_react_native3.View, { style: styles3.editRow, children: [
|
|
389
394
|
/* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_hud2.InlineColorPicker, { id: "color", host, value: draftColor, onChange: onColorChange, dark }),
|
|
390
|
-
/* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_react_native_paper3.TextInput, { ref:
|
|
391
|
-
/* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_react_native_paper3.TextInput, { ref:
|
|
395
|
+
/* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_react_native_paper3.TextInput, { ref: tag.ref, ...tag.props, mode: "outlined", dense: true, value: draftTag, onChangeText: onTagChange, placeholder: "Tag", autoCapitalize: "characters", selectTextOnFocus: true, error: tagInvalid, returnKeyType: "next", style: styles3.tagInput, contentStyle: styles3.tagInputContent }),
|
|
396
|
+
/* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_react_native_paper3.TextInput, { ref: name.ref, ...name.props, onSubmitEditing: onSubmit, mode: "outlined", dense: true, value: draftName, onChangeText: onNameChange, maxLength: MAX_PROFILE_NAME_LENGTH, placeholder: "Name", autoFocus, returnKeyType: "done", style: styles3.nameInput }),
|
|
392
397
|
onDelete && /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_feedback_press2.TouchableRipple, { onPress: onDelete, style: styles3.iconButton, children: /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_react_native_paper3.Icon, { source: "trash-can-outline", size: 18, color: fgMuted }) })
|
|
393
398
|
] });
|
|
394
399
|
}
|
|
@@ -501,6 +506,40 @@ var styles3 = import_react_native3.StyleSheet.create({
|
|
|
501
506
|
fontWeight: "bold"
|
|
502
507
|
}
|
|
503
508
|
});
|
|
509
|
+
|
|
510
|
+
// src/sharedProfileStore.ts
|
|
511
|
+
var native = null;
|
|
512
|
+
try {
|
|
513
|
+
const { requireOptionalNativeModule } = require("expo-modules-core");
|
|
514
|
+
native = requireOptionalNativeModule("TasticProfile") ?? null;
|
|
515
|
+
} catch {
|
|
516
|
+
native = null;
|
|
517
|
+
}
|
|
518
|
+
var PROFILES_KEY = "sharedProfiles";
|
|
519
|
+
var isSharedProfileStoreAvailable = native !== null;
|
|
520
|
+
async function loadSharedProfiles(groupId) {
|
|
521
|
+
if (!native) return [];
|
|
522
|
+
let json = null;
|
|
523
|
+
try {
|
|
524
|
+
json = await native.getSharedJSON(groupId, PROFILES_KEY);
|
|
525
|
+
} catch {
|
|
526
|
+
return [];
|
|
527
|
+
}
|
|
528
|
+
if (!json) return [];
|
|
529
|
+
try {
|
|
530
|
+
const parsed = JSON.parse(json);
|
|
531
|
+
return Array.isArray(parsed) ? parsed.filter(isValidProfile) : [];
|
|
532
|
+
} catch {
|
|
533
|
+
return [];
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
async function saveSharedProfiles(groupId, profiles) {
|
|
537
|
+
if (!native) return;
|
|
538
|
+
try {
|
|
539
|
+
await native.setSharedJSON(groupId, PROFILES_KEY, JSON.stringify(profiles));
|
|
540
|
+
} catch {
|
|
541
|
+
}
|
|
542
|
+
}
|
|
504
543
|
// Annotate the CommonJS export names for ESM import in node:
|
|
505
544
|
0 && (module.exports = {
|
|
506
545
|
MAX_PROFILE_NAME_LENGTH,
|
|
@@ -508,6 +547,9 @@ var styles3 = import_react_native3.StyleSheet.create({
|
|
|
508
547
|
ProfileChip,
|
|
509
548
|
ProfilePicker,
|
|
510
549
|
ProfilesManager,
|
|
550
|
+
isSharedProfileStoreAvailable,
|
|
511
551
|
isValidProfile,
|
|
512
|
-
isValidTag
|
|
552
|
+
isValidTag,
|
|
553
|
+
loadSharedProfiles,
|
|
554
|
+
saveSharedProfiles
|
|
513
555
|
});
|
package/dist/index.mjs
CHANGED
|
@@ -1,3 +1,10 @@
|
|
|
1
|
+
var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require : typeof Proxy !== "undefined" ? new Proxy(x, {
|
|
2
|
+
get: (a, b) => (typeof require !== "undefined" ? require : a)[b]
|
|
3
|
+
}) : x)(function(x) {
|
|
4
|
+
if (typeof require !== "undefined") return require.apply(this, arguments);
|
|
5
|
+
throw Error('Dynamic require of "' + x + '" is not supported');
|
|
6
|
+
});
|
|
7
|
+
|
|
1
8
|
// src/ProfileChip.tsx
|
|
2
9
|
import { getContrastColor } from "@rific/auto-paper";
|
|
3
10
|
import { StyleSheet, View } from "react-native";
|
|
@@ -205,6 +212,7 @@ var styles2 = StyleSheet2.create({
|
|
|
205
212
|
// src/ProfilesManager.tsx
|
|
206
213
|
import { useAutoPaperTheme } from "@rific/auto-paper";
|
|
207
214
|
import { TouchableRipple as TouchableRipple2 } from "@rific/feedback-press";
|
|
215
|
+
import { useFocusChain } from "@rific/focus-chain";
|
|
208
216
|
import { useToast } from "@rific/toaster";
|
|
209
217
|
import { InlineColorPicker, usePopoverHost } from "@tastic/hud";
|
|
210
218
|
import { useCallback, useEffect, useMemo as useMemo2, useRef, useState } from "react";
|
|
@@ -351,12 +359,13 @@ function ProfilesManager({ profiles, defaultColor, onCreate, onSave, onDelete, h
|
|
|
351
359
|
] });
|
|
352
360
|
}
|
|
353
361
|
function EditRow({ draftName, onNameChange, draftColor, onColorChange, draftTag, onTagChange, tagInvalid, onSubmit, onDelete, autoFocus, fgMuted, host, dark }) {
|
|
354
|
-
const
|
|
355
|
-
const
|
|
362
|
+
const register = useFocusChain();
|
|
363
|
+
const tag = register();
|
|
364
|
+
const name = register();
|
|
356
365
|
return /* @__PURE__ */ jsxs2(View3, { style: styles3.editRow, children: [
|
|
357
366
|
/* @__PURE__ */ jsx3(InlineColorPicker, { id: "color", host, value: draftColor, onChange: onColorChange, dark }),
|
|
358
|
-
/* @__PURE__ */ jsx3(TextInput, { ref:
|
|
359
|
-
/* @__PURE__ */ jsx3(TextInput, { ref:
|
|
367
|
+
/* @__PURE__ */ jsx3(TextInput, { ref: tag.ref, ...tag.props, mode: "outlined", dense: true, value: draftTag, onChangeText: onTagChange, placeholder: "Tag", autoCapitalize: "characters", selectTextOnFocus: true, error: tagInvalid, returnKeyType: "next", style: styles3.tagInput, contentStyle: styles3.tagInputContent }),
|
|
368
|
+
/* @__PURE__ */ jsx3(TextInput, { ref: name.ref, ...name.props, onSubmitEditing: onSubmit, mode: "outlined", dense: true, value: draftName, onChangeText: onNameChange, maxLength: MAX_PROFILE_NAME_LENGTH, placeholder: "Name", autoFocus, returnKeyType: "done", style: styles3.nameInput }),
|
|
360
369
|
onDelete && /* @__PURE__ */ jsx3(TouchableRipple2, { onPress: onDelete, style: styles3.iconButton, children: /* @__PURE__ */ jsx3(Icon3, { source: "trash-can-outline", size: 18, color: fgMuted }) })
|
|
361
370
|
] });
|
|
362
371
|
}
|
|
@@ -469,12 +478,49 @@ var styles3 = StyleSheet3.create({
|
|
|
469
478
|
fontWeight: "bold"
|
|
470
479
|
}
|
|
471
480
|
});
|
|
481
|
+
|
|
482
|
+
// src/sharedProfileStore.ts
|
|
483
|
+
var native = null;
|
|
484
|
+
try {
|
|
485
|
+
const { requireOptionalNativeModule } = __require("expo-modules-core");
|
|
486
|
+
native = requireOptionalNativeModule("TasticProfile") ?? null;
|
|
487
|
+
} catch {
|
|
488
|
+
native = null;
|
|
489
|
+
}
|
|
490
|
+
var PROFILES_KEY = "sharedProfiles";
|
|
491
|
+
var isSharedProfileStoreAvailable = native !== null;
|
|
492
|
+
async function loadSharedProfiles(groupId) {
|
|
493
|
+
if (!native) return [];
|
|
494
|
+
let json = null;
|
|
495
|
+
try {
|
|
496
|
+
json = await native.getSharedJSON(groupId, PROFILES_KEY);
|
|
497
|
+
} catch {
|
|
498
|
+
return [];
|
|
499
|
+
}
|
|
500
|
+
if (!json) return [];
|
|
501
|
+
try {
|
|
502
|
+
const parsed = JSON.parse(json);
|
|
503
|
+
return Array.isArray(parsed) ? parsed.filter(isValidProfile) : [];
|
|
504
|
+
} catch {
|
|
505
|
+
return [];
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
async function saveSharedProfiles(groupId, profiles) {
|
|
509
|
+
if (!native) return;
|
|
510
|
+
try {
|
|
511
|
+
await native.setSharedJSON(groupId, PROFILES_KEY, JSON.stringify(profiles));
|
|
512
|
+
} catch {
|
|
513
|
+
}
|
|
514
|
+
}
|
|
472
515
|
export {
|
|
473
516
|
MAX_PROFILE_NAME_LENGTH,
|
|
474
517
|
MAX_TAG_LENGTH,
|
|
475
518
|
ProfileChip,
|
|
476
519
|
ProfilePicker,
|
|
477
520
|
ProfilesManager,
|
|
521
|
+
isSharedProfileStoreAvailable,
|
|
478
522
|
isValidProfile,
|
|
479
|
-
isValidTag
|
|
523
|
+
isValidTag,
|
|
524
|
+
loadSharedProfiles,
|
|
525
|
+
saveSharedProfiles
|
|
480
526
|
};
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
require 'json'
|
|
2
|
+
|
|
3
|
+
package = JSON.parse(File.read(File.join(__dir__, '..', 'package.json')))
|
|
4
|
+
|
|
5
|
+
Pod::Spec.new do |s|
|
|
6
|
+
s.name = 'TasticProfile'
|
|
7
|
+
s.version = package['version']
|
|
8
|
+
s.summary = package['description']
|
|
9
|
+
s.description = package['description']
|
|
10
|
+
s.license = package['license']
|
|
11
|
+
s.author = package['author']
|
|
12
|
+
s.homepage = package['homepage']
|
|
13
|
+
s.platforms = { :ios => '16.4' }
|
|
14
|
+
s.swift_version = '5.9'
|
|
15
|
+
s.source = { git: package['repository']['url'] }
|
|
16
|
+
s.static_framework = true
|
|
17
|
+
|
|
18
|
+
s.dependency 'ExpoModulesCore'
|
|
19
|
+
|
|
20
|
+
s.pod_target_xcconfig = {
|
|
21
|
+
'DEFINES_MODULE' => 'YES',
|
|
22
|
+
'SWIFT_COMPILATION_MODE' => 'wholemodule'
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
s.source_files = "**/*.{h,m,mm,swift,hpp,cpp}"
|
|
26
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import ExpoModulesCore
|
|
2
|
+
|
|
3
|
+
// Generic group/key -> JSON-string bridge onto a native iOS App Group's shared UserDefaults suite
|
|
4
|
+
// — the mechanism that actually lets two separate apps (same Apple Developer Team, both declaring
|
|
5
|
+
// the same "com.apple.security.application-groups" entitlement) read and write one common blob of
|
|
6
|
+
// data. Nothing here is @tastic/profile-specific — see src/sharedProfileStore.ts, the one caller,
|
|
7
|
+
// for the profile-roster-shaped API built on top of this. Android/web never register this module
|
|
8
|
+
// at all (see expo-module.config.json's "apple"-only platform list), so a consuming app has to
|
|
9
|
+
// treat "module not present" as a normal, expected case rather than an error.
|
|
10
|
+
public final class TasticProfileModule: Module {
|
|
11
|
+
public func definition() -> ModuleDefinition {
|
|
12
|
+
Name("TasticProfile")
|
|
13
|
+
|
|
14
|
+
AsyncFunction("getSharedJSON") { (groupId: String, key: String) -> String? in
|
|
15
|
+
UserDefaults(suiteName: groupId)?.string(forKey: key)
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
AsyncFunction("setSharedJSON") { (groupId: String, key: String, value: String) -> Void in
|
|
19
|
+
UserDefaults(suiteName: groupId)?.set(value, forKey: key)
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tastic/profile",
|
|
3
|
-
"version": "0.1
|
|
3
|
+
"version": "0.3.1",
|
|
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",
|
|
@@ -39,13 +39,14 @@
|
|
|
39
39
|
"files": [
|
|
40
40
|
"dist",
|
|
41
41
|
"src",
|
|
42
|
+
"ios",
|
|
43
|
+
"expo-module.config.json",
|
|
42
44
|
"!src/__tests__",
|
|
43
45
|
"!src/__mocks__"
|
|
44
46
|
],
|
|
45
47
|
"scripts": {
|
|
46
48
|
"build": "tsup src/index.ts --format cjs,esm --dts --clean",
|
|
47
49
|
"build:watch": "tsup src/index.ts --format cjs,esm --dts --watch",
|
|
48
|
-
"ci": "npm run lint && npm test && npm run typecheck && npm run build",
|
|
49
50
|
"fix": "eslint --fix",
|
|
50
51
|
"lint": "eslint",
|
|
51
52
|
"prepublishOnly": "npm run build",
|
|
@@ -56,7 +57,8 @@
|
|
|
56
57
|
"test": "jest",
|
|
57
58
|
"test:watch": "jest --watchAll",
|
|
58
59
|
"typecheck": "tsc --noEmit",
|
|
59
|
-
"
|
|
60
|
+
"verify": "npm run lint && npm test && npm run typecheck && npm run build",
|
|
61
|
+
"preversion": "npm ci && npm run verify"
|
|
60
62
|
},
|
|
61
63
|
"prettier": "@infinitetoken/eslint-config/prettier",
|
|
62
64
|
"devDependencies": {
|
|
@@ -75,6 +77,7 @@
|
|
|
75
77
|
"eslint": "^9.39.4",
|
|
76
78
|
"eslint-plugin-react-hooks": "^7.1.1",
|
|
77
79
|
"eslint-plugin-react-native": "^5.0.0",
|
|
80
|
+
"expo-modules-core": "^57.0.14",
|
|
78
81
|
"jest": "^30.4.2",
|
|
79
82
|
"jest-environment-jsdom": "^30.4.1",
|
|
80
83
|
"prettier": "^3.8.3",
|
|
@@ -94,11 +97,17 @@
|
|
|
94
97
|
"@rific/toaster": ">=0.4.0",
|
|
95
98
|
"@tastic/core": ">=0.1.0",
|
|
96
99
|
"@tastic/hud": ">=0.1.3",
|
|
100
|
+
"expo-modules-core": ">=1.0.0",
|
|
97
101
|
"react": ">=19.0.0",
|
|
98
102
|
"react-native": ">=0.76.0",
|
|
99
103
|
"react-native-paper": ">=5.0.0",
|
|
100
104
|
"react-native-safe-area-context": ">=4.0.0"
|
|
101
105
|
},
|
|
106
|
+
"peerDependenciesMeta": {
|
|
107
|
+
"expo-modules-core": {
|
|
108
|
+
"optional": true
|
|
109
|
+
}
|
|
110
|
+
},
|
|
102
111
|
"publishConfig": {
|
|
103
112
|
"access": "public"
|
|
104
113
|
}
|
package/src/ProfilesManager.tsx
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { useAutoPaperTheme } from '@rific/auto-paper'
|
|
2
2
|
import { TouchableRipple } from '@rific/feedback-press'
|
|
3
|
+
import { useFocusChain } from '@rific/focus-chain'
|
|
3
4
|
import { useToast } from '@rific/toaster'
|
|
4
5
|
import { InlineColorPicker, PopoverHost, usePopoverHost } from '@tastic/hud'
|
|
5
|
-
import {
|
|
6
|
-
import { KeyboardAvoidingView, Platform, ScrollView, StyleSheet,
|
|
6
|
+
import { ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|
7
|
+
import { KeyboardAvoidingView, Platform, ScrollView, StyleSheet, View } from 'react-native'
|
|
7
8
|
import { Button, Icon, Portal, Text, TextInput } from 'react-native-paper'
|
|
8
9
|
import { useSafeAreaInsets } from 'react-native-safe-area-context'
|
|
9
10
|
|
|
@@ -275,21 +276,26 @@ interface EditRowProps {
|
|
|
275
276
|
// color trigger here — it keeps its plain palette icon regardless of the row's own tag field, since
|
|
276
277
|
// the tag is already right next to it in its own field, so repeating it on the color trigger too
|
|
277
278
|
// would be redundant rather than informative.
|
|
278
|
-
// Return-key chains tag -> name via
|
|
279
|
-
// own — submitting the *name* field is what actually commits the row
|
|
280
|
-
// onSubmitEditing
|
|
281
|
-
// order, the last one finishes it.
|
|
279
|
+
// Return-key chains tag -> name via @rific/focus-chain rather than each field submitting on its
|
|
280
|
+
// own — submitting the *name* field is what actually commits the row, so its registration's own
|
|
281
|
+
// onSubmitEditing (a no-op — it's last in the chain) is overridden with onSubmit below, matching
|
|
282
|
+
// how a normal multi-field form reads: fill fields in order, the last one finishes it.
|
|
282
283
|
function EditRow({ draftName, onNameChange, draftColor, onColorChange, draftTag, onTagChange, tagInvalid, onSubmit, onDelete, autoFocus, fgMuted, host, dark }: EditRowProps) {
|
|
283
|
-
const
|
|
284
|
-
const
|
|
284
|
+
const register = useFocusChain()
|
|
285
|
+
const tag = register()
|
|
286
|
+
const name = register()
|
|
285
287
|
|
|
286
288
|
return (
|
|
287
289
|
<View style={styles.editRow}>
|
|
288
290
|
<InlineColorPicker id='color' host={host} value={draftColor} onChange={onColorChange} dark={dark} />
|
|
289
291
|
{/* selectTextOnFocus so tapping into an already-set tag selects it for wholesale replacement
|
|
290
292
|
— the far more common edit than inserting into the middle of a 1-3 character value. */}
|
|
291
|
-
|
|
292
|
-
|
|
293
|
+
{/* eslint-disable-next-line react-hooks/refs -- tag.ref/tag.props come from useFocusChain's
|
|
294
|
+
register(), called during render by design (see the hook's own doc); the actual DOM/native
|
|
295
|
+
focus() call it wraps only ever fires later, from an event handler, never synchronously here */}
|
|
296
|
+
<TextInput ref={tag.ref} {...tag.props} mode='outlined' dense value={draftTag} onChangeText={onTagChange} placeholder='Tag' autoCapitalize='characters' selectTextOnFocus error={tagInvalid} returnKeyType='next' style={styles.tagInput} contentStyle={styles.tagInputContent} />
|
|
297
|
+
{/* eslint-disable-next-line react-hooks/refs -- see the tag field's identical note above */}
|
|
298
|
+
<TextInput ref={name.ref} {...name.props} onSubmitEditing={onSubmit} mode='outlined' dense value={draftName} onChangeText={onNameChange} maxLength={MAX_PROFILE_NAME_LENGTH} placeholder='Name' autoFocus={autoFocus} returnKeyType='done' style={styles.nameInput} />
|
|
293
299
|
{onDelete && (
|
|
294
300
|
<TouchableRipple onPress={onDelete} style={styles.iconButton}>
|
|
295
301
|
<Icon source='trash-can-outline' size={18} color={fgMuted} />
|
package/src/index.ts
CHANGED
|
@@ -2,4 +2,5 @@ export { ProfileChip } from './ProfileChip'
|
|
|
2
2
|
export { ProfilePicker } from './ProfilePicker'
|
|
3
3
|
export { ProfilesManager, type ProfilesManagerProps } from './ProfilesManager'
|
|
4
4
|
export { isValidProfile, isValidTag, MAX_PROFILE_NAME_LENGTH, MAX_TAG_LENGTH } from './profilesValidation'
|
|
5
|
+
export { isSharedProfileStoreAvailable, loadSharedProfiles, saveSharedProfiles } from './sharedProfileStore'
|
|
5
6
|
export type { Profile } from './types'
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { isValidProfile } from './profilesValidation'
|
|
2
|
+
import { Profile } from './types'
|
|
3
|
+
|
|
4
|
+
interface NativeTasticProfile {
|
|
5
|
+
getSharedJSON(groupId: string, key: string): Promise<string | null>
|
|
6
|
+
setSharedJSON(groupId: string, key: string, value: string): Promise<void>
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
// Lazily/optionally resolved — this package has no hard dependency on expo-modules-core (it's a
|
|
10
|
+
// peer, provided by whatever Expo app consumes it), and the native module itself only registers on
|
|
11
|
+
// iOS (see ios/TasticProfileModule.swift and expo-module.config.json's "apple"-only platform list).
|
|
12
|
+
// Guarding both the require and the lookup means Android/web, and an iOS build that hasn't run
|
|
13
|
+
// `expo prebuild` since adding this package yet, both degrade to "sharing unavailable" instead of a
|
|
14
|
+
// hard crash.
|
|
15
|
+
let native: NativeTasticProfile | null = null
|
|
16
|
+
try {
|
|
17
|
+
const { requireOptionalNativeModule } = require('expo-modules-core')
|
|
18
|
+
native = (requireOptionalNativeModule('TasticProfile') ?? null) as NativeTasticProfile | null
|
|
19
|
+
} catch {
|
|
20
|
+
native = null
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const PROFILES_KEY = 'sharedProfiles'
|
|
24
|
+
|
|
25
|
+
// True once at module load — a consuming app uses this to decide whether to fall back to its own
|
|
26
|
+
// local-only storage for the base roster too (see this package's README's App Group section).
|
|
27
|
+
export const isSharedProfileStoreAvailable = native !== null
|
|
28
|
+
|
|
29
|
+
// Reads the shared base-identity roster out of a given App Group suite. Always resolves to an
|
|
30
|
+
// array, never null/throws — an unavailable store, a missing/empty blob, and a corrupt blob all
|
|
31
|
+
// read the same as "nothing shared yet." Entries that fail isValidProfile are dropped individually
|
|
32
|
+
// rather than invalidating the whole roster, since the base fields validated here are the ONLY
|
|
33
|
+
// thing this store is a source of truth for — a host app's own extension fields (a control scheme,
|
|
34
|
+
// anything else) never travel through it at all; see the README for the base/extension split this
|
|
35
|
+
// is meant to be used with.
|
|
36
|
+
export async function loadSharedProfiles(groupId: string): Promise<Profile[]> {
|
|
37
|
+
if (!native) return []
|
|
38
|
+
let json: string | null = null
|
|
39
|
+
try {
|
|
40
|
+
json = await native.getSharedJSON(groupId, PROFILES_KEY)
|
|
41
|
+
} catch {
|
|
42
|
+
return []
|
|
43
|
+
}
|
|
44
|
+
if (!json) return []
|
|
45
|
+
try {
|
|
46
|
+
const parsed: unknown = JSON.parse(json)
|
|
47
|
+
return Array.isArray(parsed) ? parsed.filter(isValidProfile) : []
|
|
48
|
+
} catch {
|
|
49
|
+
return []
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Overwrites the entire shared roster — same whole-blob-write discipline a host app's own
|
|
54
|
+
// AsyncStorage-backed profile store already uses, not an incremental per-profile update. A write
|
|
55
|
+
// failure (store unavailable, or some other native error) is swallowed rather than thrown: the
|
|
56
|
+
// caller's own local state is already the source of truth for what just changed, and this is a
|
|
57
|
+
// best-effort mirror of it, not the other way around.
|
|
58
|
+
export async function saveSharedProfiles(groupId: string, profiles: Profile[]): Promise<void> {
|
|
59
|
+
if (!native) return
|
|
60
|
+
try {
|
|
61
|
+
await native.setSharedJSON(groupId, PROFILES_KEY, JSON.stringify(profiles))
|
|
62
|
+
} catch {
|
|
63
|
+
// Best-effort — see doc comment above.
|
|
64
|
+
}
|
|
65
|
+
}
|