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.
@@ -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
- // Mirror of the preset top-level includes mapping in create.mjs.
46
- // Kept in sync manually because importing from create.mjs would pull
47
- // inquirer + the prompt code into the update command unnecessarily.
48
- const PRESET_INCLUDES = {
49
- everything: ['server', 'ui/admin', 'ui/site', 'mobile', 'misc',
50
- 'scripts', 'doc'],
51
- 'web-only': ['server', 'ui/admin', 'ui/site', 'misc', 'doc'],
52
- 'mobile-and-site': ['mobile', 'ui/site', 'scripts', 'doc'],
53
- 'mobile-and-server': ['server', 'mobile', 'misc', 'scripts',
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
- * Walk the template, resolve each file's contents (post-replacement)
157
- * + final relative path. Returns a map of relative-path → buffer.
158
- *
159
- * Skips files outside the project's preset by checking against the
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 directories that ship with every preset (project config
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 set in create.mjs.
171
- const ALWAYS_IN_PRESET = new Set(['.claude']);
172
-
173
- function isPathInPreset(rel, preset) {
174
- const includes = PRESET_INCLUDES[preset] ?? PRESET_INCLUDES.everything;
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 (ALWAYS_IN_PRESET.has(top)) {
168
+ if (ALWAYS_IN_PROJECT.has(top)) {
177
169
  return true;
178
170
  }
179
- // ui is special: only the chosen sub-apps live in the preset.
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 (CLAUDE.md, dev.sh, etc) let create.mjs's
188
- // root-files filter decide. For update we ship them all and let
189
- // the manifest comparison do the right thing per file.
190
- return rel.indexOf('/') === -1;
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
- async function loadTemplate(details, preset) {
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 (preset && !isPathInPreset(rel, preset)) {
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 presets the canonical landing files are
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 for
221
- // every preset.
222
- if (APP_LANDING_PRESETS.has(preset)) {
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.preset);
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
- }