umpordez 1.3.2 → 1.5.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/.claude/settings.local.json +9 -1
- package/cli.mjs +1 -1
- package/create.mjs +194 -116
- package/package.json +1 -1
- package/template/AGENTS.md +1 -1
- package/template/CLAUDE.md +6 -5
- package/template/README.md +4 -4
- package/template/doc/index.html +5 -2
- package/template/mobile/{{PROJECT_SLUG}}/CLAUDE.md +18 -6
- package/template/mobile/{{PROJECT_SLUG}}/app.json +14 -34
- package/template/mobile/{{PROJECT_SLUG}}/eslint.config.js +12 -1
- package/template/mobile/{{PROJECT_SLUG}}/native/README.md +3 -3
- package/template/mobile/{{PROJECT_SLUG}}/package.json +22 -22
- package/template/mobile/{{PROJECT_SLUG}}/src/components/AnimatedSplash.tsx +10 -2
- package/template/mobile/{{PROJECT_SLUG}}/src/components/PremiumGate.tsx +4 -1
- package/template/mobile/{{PROJECT_SLUG}}/src/config.ts +21 -0
- package/template/mobile/{{PROJECT_SLUG}}/src/pages/auth/AuthLanding.tsx +55 -50
- package/template/mobile/{{PROJECT_SLUG}}/src/services/content/signing.ts +2 -1
- package/template/mobile/{{PROJECT_SLUG}}/src/services/iap/index.ts +17 -22
- package/template-variables.mjs +183 -0
- package/update.mjs +52 -48
- package/template/mobile/{{PROJECT_SLUG}}/src/services/health/index.ts +0 -238
package/template-variables.mjs
CHANGED
|
@@ -39,6 +39,42 @@
|
|
|
39
39
|
// plays the AnimatedSplash on boot or shows
|
|
40
40
|
// up instantly (native splash hides on font
|
|
41
41
|
// load). Consumed by mobile src/config.ts.
|
|
42
|
+
// {{AUTH_GOOGLE_ENABLED}} — "true"/"false" — Google sign-in button in
|
|
43
|
+
// the mobile app. Consumed by src/config.ts.
|
|
44
|
+
// {{AUTH_APPLE_ENABLED}} — "true"/"false" — Apple sign-in button in
|
|
45
|
+
// the mobile app. Consumed by src/config.ts.
|
|
46
|
+
// {{IOS_APPLE_SIGNIN_JSON}} — app.json fragment: `"usesAppleSignIn":
|
|
47
|
+
// true,` (with leading newline+indent) when
|
|
48
|
+
// Apple sign-in is on, empty when off.
|
|
49
|
+
// {{APPLE_AUTH_PLUGIN_JSON}} — app.json fragment: the
|
|
50
|
+
// `"expo-apple-authentication",` plugins
|
|
51
|
+
// entry when Apple sign-in is on, empty
|
|
52
|
+
// when off (the plugin adds the Sign in
|
|
53
|
+
// with Apple entitlement unconditionally,
|
|
54
|
+
// so it must not ship when disabled).
|
|
55
|
+
// {{PROJECT_SLUG_UNDERSCORE}} — Slug with dashes → underscores. Used
|
|
56
|
+
// for IAP product IDs (Apple rejects
|
|
57
|
+
// dashes in product identifiers).
|
|
58
|
+
// {{IAP_ENABLED}} — "true"/"false" — in-app purchases picked
|
|
59
|
+
// at scaffold time. Consumed by mobile
|
|
60
|
+
// src/config.ts; gates PremiumGate and the
|
|
61
|
+
// iap service.
|
|
62
|
+
// {{IAP_DEP_JSON}} — package.json fragment: the `"expo-iap"`
|
|
63
|
+
// dependency line when IAP is on, empty
|
|
64
|
+
// when off (expo-iap autolinks its native
|
|
65
|
+
// billing code whenever installed, so the
|
|
66
|
+
// dep itself must not ship when disabled).
|
|
67
|
+
// {{IAP_PLUGIN_JSON}} — app.json fragment: the `"expo-iap"`
|
|
68
|
+
// plugins entry when IAP is on, empty
|
|
69
|
+
// when off.
|
|
70
|
+
// {{IAP_BILLING_PERMISSION_JSON}} — app.json fragment: the Android
|
|
71
|
+
// `com.android.vending.BILLING` permission
|
|
72
|
+
// when IAP is on, empty when off.
|
|
73
|
+
// {{IAP_REQUIRE_BLOCK}} — services/iap/index.ts fragment: the lazy
|
|
74
|
+
// try/require of expo-iap when IAP is on;
|
|
75
|
+
// a stub comment when off (Metro resolves
|
|
76
|
+
// require() statically, so the literal
|
|
77
|
+
// must not appear when the dep is absent).
|
|
42
78
|
|
|
43
79
|
// ─── Site landing variants ────────────────────────────────────────────────────
|
|
44
80
|
// The public site ships two landing flavors. The SaaS landing (CTAs →
|
|
@@ -52,6 +88,101 @@
|
|
|
52
88
|
|
|
53
89
|
export const SITE_VARIANT_DIR = 'ui/site/_variants';
|
|
54
90
|
|
|
91
|
+
// ─── Components ──────────────────────────────────────────────────────────────
|
|
92
|
+
// A project is any combination of four components, each picked with a
|
|
93
|
+
// yes/no at create time:
|
|
94
|
+
//
|
|
95
|
+
// server — Admin API + Site API (Express + PostgreSQL)
|
|
96
|
+
// adminUi — React admin webapp (requires server)
|
|
97
|
+
// site — Public site / landing page (EJS + i18n)
|
|
98
|
+
// mobile — Expo + React Native app
|
|
99
|
+
//
|
|
100
|
+
// Both create.mjs and update.mjs derive "which template paths ship"
|
|
101
|
+
// from these flags via the helpers below, so the two tools can never
|
|
102
|
+
// drift. The legacy preset names are kept as manifest labels (old
|
|
103
|
+
// projects only recorded a preset string).
|
|
104
|
+
|
|
105
|
+
export const LEGACY_PRESET_COMPONENTS = {
|
|
106
|
+
everything: { server: true, adminUi: true,
|
|
107
|
+
site: true, mobile: true },
|
|
108
|
+
'web-only': { server: true, adminUi: true,
|
|
109
|
+
site: true, mobile: false },
|
|
110
|
+
'mobile-and-site': { server: false, adminUi: false,
|
|
111
|
+
site: true, mobile: true },
|
|
112
|
+
'mobile-and-server': { server: true, adminUi: false,
|
|
113
|
+
site: false, mobile: true },
|
|
114
|
+
'just-mobile': { server: false, adminUi: false,
|
|
115
|
+
site: false, mobile: true }
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
/** Components for a manifest that only recorded a preset name. */
|
|
119
|
+
export function componentsForPreset(preset) {
|
|
120
|
+
return LEGACY_PRESET_COMPONENTS[preset]
|
|
121
|
+
?? LEGACY_PRESET_COMPONENTS.everything;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Manifest label for a component combo. Combos matching a legacy
|
|
126
|
+
* preset keep its exact name (older CLI versions still understand
|
|
127
|
+
* the manifest); anything else is "custom".
|
|
128
|
+
*/
|
|
129
|
+
export function presetNameForComponents(c) {
|
|
130
|
+
for (const [name, def] of
|
|
131
|
+
Object.entries(LEGACY_PRESET_COMPONENTS)) {
|
|
132
|
+
if (def.server === !!c.server
|
|
133
|
+
&& def.adminUi === !!c.adminUi
|
|
134
|
+
&& def.site === !!c.site
|
|
135
|
+
&& def.mobile === !!c.mobile) {
|
|
136
|
+
return name;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
return 'custom';
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Top-level template dirs a component combo ships. */
|
|
143
|
+
export function includesForComponents(c) {
|
|
144
|
+
const includes = ['doc'];
|
|
145
|
+
if (c.server) {
|
|
146
|
+
includes.push('server', 'misc');
|
|
147
|
+
}
|
|
148
|
+
if (c.adminUi) {
|
|
149
|
+
includes.push('ui/admin');
|
|
150
|
+
}
|
|
151
|
+
if (c.site) {
|
|
152
|
+
includes.push('ui/site');
|
|
153
|
+
}
|
|
154
|
+
if (c.mobile) {
|
|
155
|
+
includes.push('mobile', 'scripts');
|
|
156
|
+
}
|
|
157
|
+
return includes;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Root-level template files a component combo ships. */
|
|
161
|
+
export function rootFilesForComponents(c) {
|
|
162
|
+
const files = ['CLAUDE.md', 'README.md', 'code.md',
|
|
163
|
+
'install.sh', 'dev.sh'];
|
|
164
|
+
if (c.server) {
|
|
165
|
+
files.push('architecture.md', 'seed.sh');
|
|
166
|
+
}
|
|
167
|
+
if (c.server || c.site || c.adminUi) {
|
|
168
|
+
files.push('AGENTS.md');
|
|
169
|
+
}
|
|
170
|
+
return files;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Whether the public site ships the app-marketing landing (store
|
|
175
|
+
* buttons + download CTA) instead of the SaaS landing (signup CTA).
|
|
176
|
+
* New projects store the decision in details.appLanding; legacy
|
|
177
|
+
* manifests fall back to the preset that implied it.
|
|
178
|
+
*/
|
|
179
|
+
export function wantsAppLanding(details, preset) {
|
|
180
|
+
if (typeof details?.appLanding === 'boolean') {
|
|
181
|
+
return details.appLanding;
|
|
182
|
+
}
|
|
183
|
+
return preset === 'mobile-and-site';
|
|
184
|
+
}
|
|
185
|
+
|
|
55
186
|
// [variantPath, canonicalPath] — relative to the project root.
|
|
56
187
|
export const APP_LANDING_SWAPS = [
|
|
57
188
|
['ui/site/_variants/app/home.ejs',
|
|
@@ -247,6 +378,7 @@ export function buildReplacements(details) {
|
|
|
247
378
|
'{{PROJECT_SLUG_UPPER}}': details.slug
|
|
248
379
|
.toUpperCase()
|
|
249
380
|
.replace(/-/g, '_'),
|
|
381
|
+
'{{PROJECT_SLUG_UNDERSCORE}}': details.slug.replace(/-/g, '_'),
|
|
250
382
|
'{{PROJECT_DESCRIPTION}}': details.description,
|
|
251
383
|
'{{DOMAIN}}': details.domain,
|
|
252
384
|
'{{DB_NAME}}': details.dbName,
|
|
@@ -275,5 +407,56 @@ export function buildReplacements(details) {
|
|
|
275
407
|
'{{JWT_TOKEN_SECRET}}': details.jwtTokenSecret ?? randomSecret(),
|
|
276
408
|
'{{SPLASH_ANIMATED}}':
|
|
277
409
|
details.splashStyle === 'animated' ? 'true' : 'false',
|
|
410
|
+
// Sign-in providers chosen at scaffold time. Email+password
|
|
411
|
+
// always ships; these gate the extra buttons in the mobile
|
|
412
|
+
// app. Default to enabled so legacy manifests (which never
|
|
413
|
+
// recorded a choice) keep their current behavior on update.
|
|
414
|
+
'{{AUTH_GOOGLE_ENABLED}}':
|
|
415
|
+
details.authGoogle === false ? 'false' : 'true',
|
|
416
|
+
'{{AUTH_APPLE_ENABLED}}':
|
|
417
|
+
details.authApple === false ? 'false' : 'true',
|
|
418
|
+
// app.json fragments — the expo-apple-authentication config
|
|
419
|
+
// plugin adds the Sign in with Apple entitlement
|
|
420
|
+
// unconditionally when listed, so when Apple is disabled the
|
|
421
|
+
// plugin entry (and usesAppleSignIn) must disappear from the
|
|
422
|
+
// JSON entirely, not just flip to false.
|
|
423
|
+
'{{IOS_APPLE_SIGNIN_JSON}}': details.authApple === false
|
|
424
|
+
? ''
|
|
425
|
+
: '\n "usesAppleSignIn": true,',
|
|
426
|
+
'{{APPLE_AUTH_PLUGIN_JSON}}': details.authApple === false
|
|
427
|
+
? ''
|
|
428
|
+
: '\n "expo-apple-authentication",',
|
|
429
|
+
// In-app purchases chosen at scaffold time. Defaults to
|
|
430
|
+
// enabled so legacy manifests (which never recorded a
|
|
431
|
+
// choice) keep their current behavior on update. When
|
|
432
|
+
// disabled, the expo-iap dependency / config plugin /
|
|
433
|
+
// BILLING permission / require() all disappear — the iap
|
|
434
|
+
// service and PremiumGate degrade to graceful no-ops.
|
|
435
|
+
'{{IAP_ENABLED}}': details.iap === false ? 'false' : 'true',
|
|
436
|
+
'{{IAP_DEP_JSON}}': details.iap === false
|
|
437
|
+
? ''
|
|
438
|
+
: '\n "expo-iap": "^4.4.0",',
|
|
439
|
+
'{{IAP_PLUGIN_JSON}}': details.iap === false
|
|
440
|
+
? ''
|
|
441
|
+
: ',\n "expo-iap"',
|
|
442
|
+
'{{IAP_BILLING_PERMISSION_JSON}}': details.iap === false
|
|
443
|
+
? ''
|
|
444
|
+
: ',\n "com.android.vending.BILLING"',
|
|
445
|
+
'{{IAP_REQUIRE_BLOCK}}': details.iap === false
|
|
446
|
+
? '// IAP disabled at scaffold time (`umpordez create`\n'
|
|
447
|
+
+ '// without in-app purchases) — expo-iap is not\n'
|
|
448
|
+
+ '// installed and every function below no-ops.\n'
|
|
449
|
+
+ 'const _iap: IAPNative | null = null;'
|
|
450
|
+
: 'let _iap: IAPNative | null = null;\n'
|
|
451
|
+
+ 'try {\n'
|
|
452
|
+
+ ' // eslint-disable-next-line '
|
|
453
|
+
+ '@typescript-eslint/no-require-imports\n'
|
|
454
|
+
+ ' _iap = require(\'expo-iap\') as IAPNative;\n'
|
|
455
|
+
+ '} catch {\n'
|
|
456
|
+
+ ' console.info(\n'
|
|
457
|
+
+ ' \'[iap] expo-iap not available (Expo Go)'
|
|
458
|
+
+ ' — IAP disabled\'\n'
|
|
459
|
+
+ ' );\n'
|
|
460
|
+
+ '}',
|
|
278
461
|
};
|
|
279
462
|
}
|
package/update.mjs
CHANGED
|
@@ -34,7 +34,11 @@ import { fileURLToPath } from 'node:url';
|
|
|
34
34
|
import chalk from 'chalk';
|
|
35
35
|
import {
|
|
36
36
|
buildReplacements,
|
|
37
|
-
APP_LANDING_SWAPS
|
|
37
|
+
APP_LANDING_SWAPS,
|
|
38
|
+
componentsForPreset,
|
|
39
|
+
includesForComponents,
|
|
40
|
+
rootFilesForComponents,
|
|
41
|
+
wantsAppLanding
|
|
38
42
|
} from './template-variables.mjs';
|
|
39
43
|
import { generateIcons, ICON_TARGETS } from './icons.mjs';
|
|
40
44
|
|
|
@@ -42,23 +46,15 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
|
42
46
|
const TEMPLATE_DIR = path.join(__dirname, 'template');
|
|
43
47
|
const MANIFEST_PATH = '.umpordez/manifest.json';
|
|
44
48
|
|
|
45
|
-
//
|
|
46
|
-
//
|
|
47
|
-
//
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
'doc'],
|
|
55
|
-
'just-mobile': ['mobile', 'scripts', 'doc']
|
|
56
|
-
};
|
|
57
|
-
|
|
58
|
-
// Presets that use the app-marketing landing instead of the SaaS one.
|
|
59
|
-
// Mirror of create.mjs's `appLanding` preset flag — kept in sync
|
|
60
|
-
// manually (see PRESET_INCLUDES note above).
|
|
61
|
-
const APP_LANDING_PRESETS = new Set(['mobile-and-site']);
|
|
49
|
+
// Which components a project shipped. New manifests record the
|
|
50
|
+
// components object in details; legacy ones only stored a preset
|
|
51
|
+
// name — map it through the shared table in template-variables.mjs.
|
|
52
|
+
function componentsFromManifest(manifest) {
|
|
53
|
+
if (manifest.details?.components) {
|
|
54
|
+
return manifest.details.components;
|
|
55
|
+
}
|
|
56
|
+
return componentsForPreset(manifest.preset);
|
|
57
|
+
}
|
|
62
58
|
|
|
63
59
|
const green = chalk.hex('#02e027');
|
|
64
60
|
const cyan = chalk.hex('#00fff9');
|
|
@@ -153,30 +149,26 @@ function applyPathPlaceholders(p, slug) {
|
|
|
153
149
|
}
|
|
154
150
|
|
|
155
151
|
/**
|
|
156
|
-
*
|
|
157
|
-
*
|
|
158
|
-
*
|
|
159
|
-
*
|
|
160
|
-
* project's existing top-level layout — we only want to update what
|
|
161
|
-
* the project actually shipped.
|
|
162
|
-
*/
|
|
163
|
-
/**
|
|
164
|
-
* Whether a relative path belongs to the preset. Same prune rules
|
|
165
|
-
* as create.mjs's copyTemplate, expressed as a predicate so we can
|
|
166
|
-
* evaluate per-file instead of pruning a directory.
|
|
152
|
+
* Whether a relative path belongs to the project's component combo.
|
|
153
|
+
* Same prune rules as create.mjs's copyTemplate, expressed as a
|
|
154
|
+
* predicate so we can evaluate per-file instead of pruning a
|
|
155
|
+
* directory.
|
|
167
156
|
*/
|
|
168
|
-
// Top-level
|
|
157
|
+
// Top-level dirs/files that ship with every combo (project config
|
|
169
158
|
// like .claude/ that doesn't belong to any single sub-app). Mirrors
|
|
170
|
-
// the ALWAYS_KEEP
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
159
|
+
// the ALWAYS_KEEP sets in create.mjs. Note: rel arrives post
|
|
160
|
+
// path-placeholder pass, so the dot-less `gitignore` the template
|
|
161
|
+
// ships is already `.gitignore` here.
|
|
162
|
+
const ALWAYS_IN_PROJECT = new Set(['.claude']);
|
|
163
|
+
const ALWAYS_ROOT_FILES = new Set(['.gitignore', '.editorconfig']);
|
|
164
|
+
|
|
165
|
+
function isPathInComponents(rel, components) {
|
|
166
|
+
const includes = includesForComponents(components);
|
|
175
167
|
const top = rel.split('/')[0];
|
|
176
|
-
if (
|
|
168
|
+
if (ALWAYS_IN_PROJECT.has(top)) {
|
|
177
169
|
return true;
|
|
178
170
|
}
|
|
179
|
-
// ui is special: only the chosen sub-apps
|
|
171
|
+
// ui is special: only the chosen sub-apps ship.
|
|
180
172
|
if (top === 'ui') {
|
|
181
173
|
const sub = rel.split('/')[1];
|
|
182
174
|
return includes.includes(`ui/${sub}`);
|
|
@@ -184,22 +176,34 @@ function isPathInPreset(rel, preset) {
|
|
|
184
176
|
if (includes.includes(top)) {
|
|
185
177
|
return true;
|
|
186
178
|
}
|
|
187
|
-
// Top-level files
|
|
188
|
-
//
|
|
189
|
-
//
|
|
190
|
-
|
|
179
|
+
// Top-level files: same filter create.mjs applies at scaffold
|
|
180
|
+
// time, so update never re-adds a root file the combo doesn't
|
|
181
|
+
// ship (e.g. seed.sh on a project without the server).
|
|
182
|
+
if (rel.indexOf('/') === -1) {
|
|
183
|
+
return ALWAYS_ROOT_FILES.has(rel)
|
|
184
|
+
|| rootFilesForComponents(components).includes(rel);
|
|
185
|
+
}
|
|
186
|
+
return false;
|
|
191
187
|
}
|
|
192
188
|
|
|
193
|
-
|
|
189
|
+
/**
|
|
190
|
+
* Walk the template, resolve each file's contents (post-replacement)
|
|
191
|
+
* + final relative path. Returns a map of relative-path → buffer.
|
|
192
|
+
* Skips files outside the project's component combo — we only want
|
|
193
|
+
* to update what the project actually shipped.
|
|
194
|
+
*/
|
|
195
|
+
|
|
196
|
+
async function loadTemplate(details, manifest) {
|
|
194
197
|
const replacements = buildReplacements(details);
|
|
195
198
|
const files = await getAllFiles(TEMPLATE_DIR);
|
|
196
199
|
const out = new Map();
|
|
200
|
+
const components = componentsFromManifest(manifest);
|
|
197
201
|
|
|
198
202
|
for (const abs of files) {
|
|
199
203
|
const relRaw = path.relative(TEMPLATE_DIR, abs);
|
|
200
204
|
const rel = applyPathPlaceholders(relRaw, details.slug);
|
|
201
205
|
|
|
202
|
-
if (
|
|
206
|
+
if (!isPathInComponents(rel, components)) {
|
|
203
207
|
continue;
|
|
204
208
|
}
|
|
205
209
|
|
|
@@ -213,13 +217,13 @@ async function loadTemplate(details, preset) {
|
|
|
213
217
|
}
|
|
214
218
|
|
|
215
219
|
// Landing-page variant resolution — mirror create.mjs's copyTemplate
|
|
216
|
-
// swap. For app-landing
|
|
220
|
+
// swap. For app-landing projects the canonical landing files are
|
|
217
221
|
// sourced from ui/site/_variants/app/ so `umpordez update` compares
|
|
218
222
|
// against the SAME flavor the project was scaffolded with (otherwise
|
|
219
223
|
// it would try to push the SaaS landing over an app project). The
|
|
220
|
-
// _variants/ staging files never ship, so drop them from the map
|
|
221
|
-
// every
|
|
222
|
-
if (
|
|
224
|
+
// _variants/ staging files never ship, so drop them from the map
|
|
225
|
+
// in every case.
|
|
226
|
+
if (wantsAppLanding(details, manifest.preset)) {
|
|
223
227
|
for (const [variant, canonical] of APP_LANDING_SWAPS) {
|
|
224
228
|
if (out.has(variant)) {
|
|
225
229
|
out.set(canonical, out.get(variant));
|
|
@@ -366,7 +370,7 @@ export async function updateProject(projectDirArg, opts = {}) {
|
|
|
366
370
|
));
|
|
367
371
|
console.log('');
|
|
368
372
|
|
|
369
|
-
const template = await loadTemplate(details, manifest
|
|
373
|
+
const template = await loadTemplate(details, manifest);
|
|
370
374
|
const newHashes = {};
|
|
371
375
|
|
|
372
376
|
let acceptAll = !!opts.yes;
|
|
@@ -1,238 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Health — unified surface over Apple Health (iOS, react-native-health)
|
|
3
|
-
* and Health Connect (Android, react-native-health-connect).
|
|
4
|
-
*
|
|
5
|
-
* Both libraries are lazy-loaded with try/require so Expo Go (which
|
|
6
|
-
* has neither linked) gets a no-op service instead of a hard crash.
|
|
7
|
-
*
|
|
8
|
-
* The module exposes a small, opinionated API:
|
|
9
|
-
* - requestPermissions — surface the platform's permissions UI
|
|
10
|
-
* - getStepsToday — read example
|
|
11
|
-
* - writeExerciseSession — write example: minutes-of-X workout
|
|
12
|
-
*
|
|
13
|
-
* Adapt to whatever data your app actually cares about. Mirror this
|
|
14
|
-
* shape: one method per "thing the app needs" rather than
|
|
15
|
-
* one-method-per-data-type, so a swap of platforms or libraries
|
|
16
|
-
* stays a contained change.
|
|
17
|
-
*
|
|
18
|
-
* All errors are swallowed by callers — health is best-effort.
|
|
19
|
-
* Returns null / 0 on failure rather than throwing.
|
|
20
|
-
*/
|
|
21
|
-
|
|
22
|
-
import { Platform } from 'react-native';
|
|
23
|
-
|
|
24
|
-
// ── iOS HealthKit (react-native-health) ──────────────────────────────
|
|
25
|
-
interface AppleHealthKit {
|
|
26
|
-
initHealthKit: (
|
|
27
|
-
options: unknown,
|
|
28
|
-
cb: (err: string | null) => void
|
|
29
|
-
) => void;
|
|
30
|
-
getStepCount: (
|
|
31
|
-
opts: unknown,
|
|
32
|
-
cb: (err: string | null, result: { value: number }) => void
|
|
33
|
-
) => void;
|
|
34
|
-
saveWorkout: (
|
|
35
|
-
opts: unknown,
|
|
36
|
-
cb: (err: string | null) => void
|
|
37
|
-
) => void;
|
|
38
|
-
Constants?: { Permissions?: Record<string, string> };
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
// ── Android Health Connect (react-native-health-connect) ─────────────
|
|
42
|
-
interface HealthConnect {
|
|
43
|
-
initialize: () => Promise<boolean>;
|
|
44
|
-
requestPermission: (
|
|
45
|
-
permissions: { accessType: string; recordType: string }[]
|
|
46
|
-
) => Promise<{ accessType: string; recordType: string }[]>;
|
|
47
|
-
readRecords: (
|
|
48
|
-
recordType: string,
|
|
49
|
-
opts: { timeRangeFilter: { operator: string; startTime: string; endTime: string } }
|
|
50
|
-
) => Promise<{ records: { count: number }[] }>;
|
|
51
|
-
insertRecords: (records: unknown[]) => Promise<unknown>;
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
let _appleHealth: AppleHealthKit | null | undefined = undefined;
|
|
55
|
-
let _healthConnect: HealthConnect | null | undefined = undefined;
|
|
56
|
-
|
|
57
|
-
function getAppleHealth(): AppleHealthKit | null {
|
|
58
|
-
if (Platform.OS !== 'ios') {
|
|
59
|
-
return null;
|
|
60
|
-
}
|
|
61
|
-
if (_appleHealth !== undefined) {
|
|
62
|
-
return _appleHealth;
|
|
63
|
-
}
|
|
64
|
-
try {
|
|
65
|
-
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
66
|
-
const pkg = require('react-native-health');
|
|
67
|
-
_appleHealth = (pkg.default ?? pkg) as AppleHealthKit;
|
|
68
|
-
} catch {
|
|
69
|
-
_appleHealth = null;
|
|
70
|
-
}
|
|
71
|
-
return _appleHealth;
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
function getHealthConnect(): HealthConnect | null {
|
|
75
|
-
if (Platform.OS !== 'android') {
|
|
76
|
-
return null;
|
|
77
|
-
}
|
|
78
|
-
if (_healthConnect !== undefined) {
|
|
79
|
-
return _healthConnect;
|
|
80
|
-
}
|
|
81
|
-
try {
|
|
82
|
-
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
83
|
-
_healthConnect = require('react-native-health-connect') as
|
|
84
|
-
HealthConnect;
|
|
85
|
-
} catch {
|
|
86
|
-
_healthConnect = null;
|
|
87
|
-
}
|
|
88
|
-
return _healthConnect;
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
const APPLE_PERMISSIONS = {
|
|
92
|
-
permissions: {
|
|
93
|
-
read: ['Steps'],
|
|
94
|
-
write: ['Workout']
|
|
95
|
-
}
|
|
96
|
-
};
|
|
97
|
-
|
|
98
|
-
const ANDROID_PERMISSIONS = [
|
|
99
|
-
{ accessType: 'read', recordType: 'Steps' },
|
|
100
|
-
{ accessType: 'write', recordType: 'ExerciseSession' }
|
|
101
|
-
];
|
|
102
|
-
|
|
103
|
-
/**
|
|
104
|
-
* Surface the platform's permission sheet. Returns true if the user
|
|
105
|
-
* granted enough permissions for our subsequent reads/writes to work.
|
|
106
|
-
*/
|
|
107
|
-
export async function requestHealthPermissions(): Promise<boolean> {
|
|
108
|
-
if (Platform.OS === 'ios') {
|
|
109
|
-
const ahk = getAppleHealth();
|
|
110
|
-
if (!ahk) {
|
|
111
|
-
return false;
|
|
112
|
-
}
|
|
113
|
-
return new Promise(resolve => {
|
|
114
|
-
ahk.initHealthKit(APPLE_PERMISSIONS, err => {
|
|
115
|
-
resolve(!err);
|
|
116
|
-
});
|
|
117
|
-
});
|
|
118
|
-
}
|
|
119
|
-
if (Platform.OS === 'android') {
|
|
120
|
-
const hc = getHealthConnect();
|
|
121
|
-
if (!hc) {
|
|
122
|
-
return false;
|
|
123
|
-
}
|
|
124
|
-
try {
|
|
125
|
-
const ready = await hc.initialize();
|
|
126
|
-
if (!ready) {
|
|
127
|
-
return false;
|
|
128
|
-
}
|
|
129
|
-
const granted = await hc.requestPermission(
|
|
130
|
-
ANDROID_PERMISSIONS
|
|
131
|
-
);
|
|
132
|
-
return granted.length === ANDROID_PERMISSIONS.length;
|
|
133
|
-
} catch {
|
|
134
|
-
return false;
|
|
135
|
-
}
|
|
136
|
-
}
|
|
137
|
-
return false;
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
export async function getStepsToday(): Promise<number> {
|
|
141
|
-
const start = new Date();
|
|
142
|
-
start.setHours(0, 0, 0, 0);
|
|
143
|
-
const end = new Date();
|
|
144
|
-
|
|
145
|
-
if (Platform.OS === 'ios') {
|
|
146
|
-
const ahk = getAppleHealth();
|
|
147
|
-
if (!ahk) {
|
|
148
|
-
return 0;
|
|
149
|
-
}
|
|
150
|
-
return new Promise(resolve => {
|
|
151
|
-
ahk.getStepCount({ date: end.toISOString() }, (err, res) => {
|
|
152
|
-
if (err) {
|
|
153
|
-
resolve(0);
|
|
154
|
-
return;
|
|
155
|
-
}
|
|
156
|
-
resolve(res?.value ?? 0);
|
|
157
|
-
});
|
|
158
|
-
});
|
|
159
|
-
}
|
|
160
|
-
if (Platform.OS === 'android') {
|
|
161
|
-
const hc = getHealthConnect();
|
|
162
|
-
if (!hc) {
|
|
163
|
-
return 0;
|
|
164
|
-
}
|
|
165
|
-
try {
|
|
166
|
-
const res = await hc.readRecords('Steps', {
|
|
167
|
-
timeRangeFilter: {
|
|
168
|
-
operator: 'between',
|
|
169
|
-
startTime: start.toISOString(),
|
|
170
|
-
endTime: end.toISOString()
|
|
171
|
-
}
|
|
172
|
-
});
|
|
173
|
-
return res.records.reduce((sum, r) => sum + (r.count ?? 0), 0);
|
|
174
|
-
} catch {
|
|
175
|
-
return 0;
|
|
176
|
-
}
|
|
177
|
-
}
|
|
178
|
-
return 0;
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
export interface ExerciseSession {
|
|
182
|
-
type: string;
|
|
183
|
-
startedAt: Date;
|
|
184
|
-
durationMs: number;
|
|
185
|
-
energyBurned?: number;
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
/**
|
|
189
|
-
* Write a completed exercise session to the platform's health store.
|
|
190
|
-
* `type` is loosely free-form on iOS (Apple maps it to HKWorkoutType)
|
|
191
|
-
* and constrained on Android (must be a Health Connect ExerciseType).
|
|
192
|
-
* For barebones we leave the mapping to the caller — wrap this in a
|
|
193
|
-
* domain method like `recordYogaSession` if you only ever record one
|
|
194
|
-
* kind.
|
|
195
|
-
*/
|
|
196
|
-
export async function writeExerciseSession(
|
|
197
|
-
s: ExerciseSession
|
|
198
|
-
): Promise<boolean> {
|
|
199
|
-
const startISO = s.startedAt.toISOString();
|
|
200
|
-
const endISO = new Date(
|
|
201
|
-
s.startedAt.getTime() + s.durationMs
|
|
202
|
-
).toISOString();
|
|
203
|
-
|
|
204
|
-
if (Platform.OS === 'ios') {
|
|
205
|
-
const ahk = getAppleHealth();
|
|
206
|
-
if (!ahk) {
|
|
207
|
-
return false;
|
|
208
|
-
}
|
|
209
|
-
return new Promise(resolve => {
|
|
210
|
-
ahk.saveWorkout({
|
|
211
|
-
type: s.type,
|
|
212
|
-
startDate: startISO,
|
|
213
|
-
endDate: endISO,
|
|
214
|
-
energyBurned: s.energyBurned ?? 0,
|
|
215
|
-
energyBurnedUnit: 'kilocalorie'
|
|
216
|
-
}, err => resolve(!err));
|
|
217
|
-
});
|
|
218
|
-
}
|
|
219
|
-
if (Platform.OS === 'android') {
|
|
220
|
-
const hc = getHealthConnect();
|
|
221
|
-
if (!hc) {
|
|
222
|
-
return false;
|
|
223
|
-
}
|
|
224
|
-
try {
|
|
225
|
-
await hc.insertRecords([{
|
|
226
|
-
recordType: 'ExerciseSession',
|
|
227
|
-
exerciseType: s.type,
|
|
228
|
-
startTime: startISO,
|
|
229
|
-
endTime: endISO,
|
|
230
|
-
title: s.type
|
|
231
|
-
}]);
|
|
232
|
-
return true;
|
|
233
|
-
} catch {
|
|
234
|
-
return false;
|
|
235
|
-
}
|
|
236
|
-
}
|
|
237
|
-
return false;
|
|
238
|
-
}
|