@craft-native/ios 0.0.98 → 0.0.99
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/dist/cli.js +49 -20
- package/dist/index.d.ts +34 -0
- package/dist/index.js +52 -20
- package/package.json +1 -1
- package/templates/CraftApp.swift +286 -27
- package/templates/Info.plist.template +1 -1
package/dist/cli.js
CHANGED
|
@@ -69,6 +69,19 @@ function plistString(key, value) {
|
|
|
69
69
|
return ` <key>${key}</key>
|
|
70
70
|
<string>${xmlEscape(value)}</string>`;
|
|
71
71
|
}
|
|
72
|
+
function resolveAppearance(config) {
|
|
73
|
+
if (config.appearance)
|
|
74
|
+
return config.appearance;
|
|
75
|
+
return config.darkMode === false ? "light" : "dark";
|
|
76
|
+
}
|
|
77
|
+
function renderAppearance(config) {
|
|
78
|
+
const appearance = resolveAppearance(config);
|
|
79
|
+
if (appearance === "system")
|
|
80
|
+
return { interfaceStyle: "Automatic", statusBarStyle: "UIStatusBarStyleDefault" };
|
|
81
|
+
if (appearance === "light")
|
|
82
|
+
return { interfaceStyle: "Light", statusBarStyle: "UIStatusBarStyleDarkContent" };
|
|
83
|
+
return { interfaceStyle: "Dark", statusBarStyle: "UIStatusBarStyleLightContent" };
|
|
84
|
+
}
|
|
72
85
|
function renderUsageDescriptions(config) {
|
|
73
86
|
const entries = [
|
|
74
87
|
[config.enableSpeechRecognition, "NSSpeechRecognitionUsageDescription", `${config.appName} uses speech recognition for voice input.`],
|
|
@@ -261,17 +274,25 @@ function renderAssetCatalog(output, config) {
|
|
|
261
274
|
info: { author: "xcode", version: 1 }
|
|
262
275
|
}, null, 2)}
|
|
263
276
|
`);
|
|
264
|
-
const
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
color:
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
277
|
+
const colorEntry = (hex) => {
|
|
278
|
+
const color = hex?.replace(/^#/, "") || "000000";
|
|
279
|
+
const normalized = color.length === 3 ? [...color].map((value) => `${value}${value}`).join("") : color.padEnd(6, "0").slice(0, 6);
|
|
280
|
+
const components = [0, 2, 4].map((index) => (Number.parseInt(normalized.slice(index, index + 2), 16) / 255).toFixed(3));
|
|
281
|
+
return {
|
|
282
|
+
"color-space": "srgb",
|
|
283
|
+
components: { alpha: "1.000", blue: components[2], green: components[1], red: components[0] }
|
|
284
|
+
};
|
|
285
|
+
};
|
|
286
|
+
const colors = [{ color: colorEntry(config.backgroundColor), idiom: "universal" }];
|
|
287
|
+
if (config.backgroundColorDark) {
|
|
288
|
+
colors.push({
|
|
289
|
+
appearances: [{ appearance: "luminosity", value: "dark" }],
|
|
290
|
+
color: colorEntry(config.backgroundColorDark),
|
|
273
291
|
idiom: "universal"
|
|
274
|
-
}
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
writeFileSync(join(launchBackground, "Contents.json"), `${JSON.stringify({
|
|
295
|
+
colors,
|
|
275
296
|
info: { author: "xcode", version: 1 }
|
|
276
297
|
}, null, 2)}
|
|
277
298
|
`);
|
|
@@ -345,10 +366,18 @@ async function refreshRuntime(output, override) {
|
|
|
345
366
|
await installRuntime(output, dir);
|
|
346
367
|
console.log(" Refreshed the Zig runtime from", dir);
|
|
347
368
|
}
|
|
369
|
+
function productName(name) {
|
|
370
|
+
const words = name.split(/[^A-Za-z0-9]+/).filter(Boolean);
|
|
371
|
+
const joined = words.map((word) => word[0].toUpperCase() + word.slice(1)).join("");
|
|
372
|
+
if (!joined)
|
|
373
|
+
return "Craft";
|
|
374
|
+
return /^\d/.test(joined) ? `App${joined}` : joined;
|
|
375
|
+
}
|
|
348
376
|
async function init(options) {
|
|
349
|
-
const { name, bundleId, teamId, output } = options;
|
|
377
|
+
const { name: displayName, bundleId, teamId, output } = options;
|
|
378
|
+
const name = productName(displayName);
|
|
350
379
|
console.log(`
|
|
351
|
-
\u26A1 Initializing Craft iOS project: ${
|
|
380
|
+
\u26A1 Initializing Craft iOS project: ${displayName}`);
|
|
352
381
|
console.log(` Output: ${output}
|
|
353
382
|
`);
|
|
354
383
|
const dirs = [output, join(output, "Sources"), join(output, "Shared"), join(output, "dist")];
|
|
@@ -359,11 +388,11 @@ async function init(options) {
|
|
|
359
388
|
mkdirSync(dir, { recursive: true });
|
|
360
389
|
}
|
|
361
390
|
}
|
|
362
|
-
const finalBundleId = bundleId || `com.craft.${name.toLowerCase()
|
|
391
|
+
const finalBundleId = bundleId || `com.craft.${name.toLowerCase()}`;
|
|
363
392
|
const bundleIdPrefix = finalBundleId.split(".").slice(0, -1).join(".");
|
|
364
393
|
const config = {
|
|
365
394
|
...DEFAULT_CONFIG,
|
|
366
|
-
appName:
|
|
395
|
+
appName: displayName,
|
|
367
396
|
bundleId: finalBundleId,
|
|
368
397
|
teamId: teamId || "",
|
|
369
398
|
...options.config
|
|
@@ -375,7 +404,7 @@ async function init(options) {
|
|
|
375
404
|
const swiftSource = swiftTemplate.replace(/CraftApp/g, `${name}App`).replace(/\{\{BUNDLE_ID\}\}/g, finalBundleId);
|
|
376
405
|
writeFileSync(join(output, "Sources", `${name}App.swift`), swiftSource);
|
|
377
406
|
const infoPlistTemplate = readFileSync(join(TEMPLATES_DIR, "Info.plist.template"), "utf-8");
|
|
378
|
-
const infoPlist = infoPlistTemplate.replace(/\{\{APP_NAME\}\}/g,
|
|
407
|
+
const infoPlist = infoPlistTemplate.replace(/\{\{APP_NAME\}\}/g, xmlEscape(displayName)).replace(/\{\{BUNDLE_ID\}\}/g, finalBundleId).replace(/\{\{VERSION\}\}/g, config.version || "1.0.0").replace(/\{\{BUILD_NUMBER\}\}/g, config.buildNumber || "1").replace(/\{\{UI_STYLE\}\}/g, renderAppearance(config).interfaceStyle).replace(/\{\{STATUS_BAR_STYLE\}\}/g, renderAppearance(config).statusBarStyle).replace(/\{\{ORIENTATIONS\}\}/g, renderOrientations(config)).replace(/\{\{USAGE_DESCRIPTIONS\}\}/g, renderUsageDescriptions(config)).replace(/\{\{URL_TYPES\}\}/g, renderUrlTypes(config)).replace(/\{\{BACKGROUND_MODES\}\}/g, renderBackgroundModes(config)).replace(/\{\{LIVE_ACTIVITY_SUPPORT\}\}/g, config.enableLiveActivities ? ` <key>NSSupportsLiveActivities</key>
|
|
379
408
|
<true/>
|
|
380
409
|
<key>NSSupportsLiveActivitiesFrequentUpdates</key>
|
|
381
410
|
<true/>` : "");
|
|
@@ -458,10 +487,10 @@ ${nativeDependencies.join(`
|
|
|
458
487
|
if (config.enableWatchApp) {
|
|
459
488
|
const watchSource = readFileSync(join(TEMPLATES_DIR, "CraftWatchApp.swift.template"), "utf8").replace(/\{\{APP_NAME\}\}/g, name);
|
|
460
489
|
writeFileSync(join(output, "WatchExtension", `${name}WatchApp.swift`), watchSource);
|
|
461
|
-
const watchInfo = readFileSync(join(TEMPLATES_DIR, "WatchApp.Info.plist.template"), "utf8").replace(/\{\{APP_NAME\}\}/g,
|
|
490
|
+
const watchInfo = readFileSync(join(TEMPLATES_DIR, "WatchApp.Info.plist.template"), "utf8").replace(/\{\{APP_NAME\}\}/g, xmlEscape(displayName)).replace(/\{\{BUNDLE_ID\}\}/g, finalBundleId);
|
|
462
491
|
writeFileSync(join(output, "WatchApp", "Info.plist"), watchInfo);
|
|
463
492
|
writeFileSync(join(output, "WatchApp", "Watch.entitlements"), renderWatchEntitlements(config));
|
|
464
|
-
const watchExtensionInfo = readFileSync(join(TEMPLATES_DIR, "WatchExtension.Info.plist.template"), "utf8").replace(/\{\{APP_NAME\}\}/g,
|
|
493
|
+
const watchExtensionInfo = readFileSync(join(TEMPLATES_DIR, "WatchExtension.Info.plist.template"), "utf8").replace(/\{\{APP_NAME\}\}/g, xmlEscape(displayName)).replace(/\{\{BUNDLE_ID\}\}/g, finalBundleId);
|
|
465
494
|
writeFileSync(join(output, "WatchExtension", "Info.plist"), watchExtensionInfo);
|
|
466
495
|
writeFileSync(join(output, "WatchExtension", "Watch.entitlements"), renderWatchEntitlements(config));
|
|
467
496
|
}
|
|
@@ -470,7 +499,7 @@ ${nativeDependencies.join(`
|
|
|
470
499
|
<head>
|
|
471
500
|
<meta charset="UTF-8">
|
|
472
501
|
<meta name="viewport" content="viewport-fit=cover, width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
|
473
|
-
<title>${
|
|
502
|
+
<title>${xmlEscape(displayName)}</title>
|
|
474
503
|
<style>
|
|
475
504
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
476
505
|
body {
|
|
@@ -491,7 +520,7 @@ ${nativeDependencies.join(`
|
|
|
491
520
|
</head>
|
|
492
521
|
<body>
|
|
493
522
|
<div class="container">
|
|
494
|
-
<h1>\u26A1 ${
|
|
523
|
+
<h1>\u26A1 ${xmlEscape(displayName)}</h1>
|
|
495
524
|
<p>Built with Craft iOS</p>
|
|
496
525
|
<p class="ready" id="status">Waiting for Craft bridge...</p>
|
|
497
526
|
</div>
|
|
@@ -541,7 +570,7 @@ async function build(options) {
|
|
|
541
570
|
if (result.exitCode === 0) {
|
|
542
571
|
console.log(" Running xcodegen...");
|
|
543
572
|
await $`cd ${output} && xcodegen generate`.quiet();
|
|
544
|
-
console.log(`\u2705 Xcode project created: ${config.appName}.xcodeproj`);
|
|
573
|
+
console.log(`\u2705 Xcode project created: ${productName(config.appName)}.xcodeproj`);
|
|
545
574
|
} else {
|
|
546
575
|
throw new Error("xcodegen not found");
|
|
547
576
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -3,13 +3,22 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Generates native iOS apps from web content using WKWebView.
|
|
5
5
|
*/
|
|
6
|
+
export type CraftAppearance = 'light' | 'dark' | 'system';
|
|
6
7
|
export interface CraftConfig {
|
|
7
8
|
appName: string;
|
|
8
9
|
bundleId: string;
|
|
9
10
|
version?: string;
|
|
10
11
|
buildNumber?: string;
|
|
11
12
|
darkMode?: boolean;
|
|
13
|
+
/**
|
|
14
|
+
* Which interface style the app draws in. `system` follows the phone's
|
|
15
|
+
* Light/Dark setting (and the page's `prefers-color-scheme` with it); `light`
|
|
16
|
+
* and `dark` pin one. Unset keeps the older `darkMode` switch.
|
|
17
|
+
*/
|
|
18
|
+
appearance?: CraftAppearance;
|
|
12
19
|
backgroundColor?: string;
|
|
20
|
+
/** The launch and webview background while the phone is in Dark Mode. */
|
|
21
|
+
backgroundColorDark?: string;
|
|
13
22
|
enableSpeechRecognition?: boolean;
|
|
14
23
|
enableHaptics?: boolean;
|
|
15
24
|
enableShare?: boolean;
|
|
@@ -115,6 +124,20 @@ export interface RunOptions {
|
|
|
115
124
|
output: string;
|
|
116
125
|
}
|
|
117
126
|
export declare function renderDeviceFamilies(config: CraftConfig): string;
|
|
127
|
+
/** `appearance` when set, otherwise what the older `darkMode` switch pinned. */
|
|
128
|
+
export declare function resolveAppearance(config: Pick<CraftConfig, 'appearance' | 'darkMode'>): CraftAppearance;
|
|
129
|
+
/**
|
|
130
|
+
* The Info.plist interface style and the status bar that reads on it.
|
|
131
|
+
*
|
|
132
|
+
* The status bar used to be `LightContent` whatever the app looked like:
|
|
133
|
+
* white text that vanished on a light page. It now follows the appearance —
|
|
134
|
+
* dark text on a light app, light on a dark one, and the system's choice when
|
|
135
|
+
* the app follows the system.
|
|
136
|
+
*/
|
|
137
|
+
export declare function renderAppearance(config: Pick<CraftConfig, 'appearance' | 'darkMode'>): {
|
|
138
|
+
interfaceStyle: string;
|
|
139
|
+
statusBarStyle: string;
|
|
140
|
+
};
|
|
118
141
|
export declare function renderUsageDescriptions(config: CraftConfig): string;
|
|
119
142
|
export declare function renderOrientations(config: CraftConfig): string;
|
|
120
143
|
export declare function renderUrlTypes(config: CraftConfig): string;
|
|
@@ -181,6 +204,17 @@ export declare function resolveRuntimeDir(override?: string | null): string | nu
|
|
|
181
204
|
* Returns true when a runtime was installed.
|
|
182
205
|
*/
|
|
183
206
|
export declare function installRuntime(output: string, runtimeDir: string): Promise<boolean>;
|
|
207
|
+
/**
|
|
208
|
+
* The identifier an app's display name becomes wherever Xcode or Swift needs
|
|
209
|
+
* one: the project, the target, the product and the `@main` type.
|
|
210
|
+
*
|
|
211
|
+
* The display name goes in front of people and may say anything; an
|
|
212
|
+
* identifier may not. "HQ.training" used to become `struct HQ.trainingApp`,
|
|
213
|
+
* which does not compile. Words are joined in PascalCase ("HQTraining"), and
|
|
214
|
+
* a name that starts with a digit or has no letters at all still yields a
|
|
215
|
+
* valid Swift type.
|
|
216
|
+
*/
|
|
217
|
+
export declare function productName(name: string): string;
|
|
184
218
|
export declare function init(options: InitOptions): Promise<void>;
|
|
185
219
|
/**
|
|
186
220
|
* Build web assets and generate Xcode project
|
package/dist/index.js
CHANGED
|
@@ -64,6 +64,19 @@ function plistString(key, value) {
|
|
|
64
64
|
return ` <key>${key}</key>
|
|
65
65
|
<string>${xmlEscape(value)}</string>`;
|
|
66
66
|
}
|
|
67
|
+
function resolveAppearance(config) {
|
|
68
|
+
if (config.appearance)
|
|
69
|
+
return config.appearance;
|
|
70
|
+
return config.darkMode === false ? "light" : "dark";
|
|
71
|
+
}
|
|
72
|
+
function renderAppearance(config) {
|
|
73
|
+
const appearance = resolveAppearance(config);
|
|
74
|
+
if (appearance === "system")
|
|
75
|
+
return { interfaceStyle: "Automatic", statusBarStyle: "UIStatusBarStyleDefault" };
|
|
76
|
+
if (appearance === "light")
|
|
77
|
+
return { interfaceStyle: "Light", statusBarStyle: "UIStatusBarStyleDarkContent" };
|
|
78
|
+
return { interfaceStyle: "Dark", statusBarStyle: "UIStatusBarStyleLightContent" };
|
|
79
|
+
}
|
|
67
80
|
function renderUsageDescriptions(config) {
|
|
68
81
|
const entries = [
|
|
69
82
|
[config.enableSpeechRecognition, "NSSpeechRecognitionUsageDescription", `${config.appName} uses speech recognition for voice input.`],
|
|
@@ -256,17 +269,25 @@ function renderAssetCatalog(output, config) {
|
|
|
256
269
|
info: { author: "xcode", version: 1 }
|
|
257
270
|
}, null, 2)}
|
|
258
271
|
`);
|
|
259
|
-
const
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
color:
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
272
|
+
const colorEntry = (hex) => {
|
|
273
|
+
const color = hex?.replace(/^#/, "") || "000000";
|
|
274
|
+
const normalized = color.length === 3 ? [...color].map((value) => `${value}${value}`).join("") : color.padEnd(6, "0").slice(0, 6);
|
|
275
|
+
const components = [0, 2, 4].map((index) => (Number.parseInt(normalized.slice(index, index + 2), 16) / 255).toFixed(3));
|
|
276
|
+
return {
|
|
277
|
+
"color-space": "srgb",
|
|
278
|
+
components: { alpha: "1.000", blue: components[2], green: components[1], red: components[0] }
|
|
279
|
+
};
|
|
280
|
+
};
|
|
281
|
+
const colors = [{ color: colorEntry(config.backgroundColor), idiom: "universal" }];
|
|
282
|
+
if (config.backgroundColorDark) {
|
|
283
|
+
colors.push({
|
|
284
|
+
appearances: [{ appearance: "luminosity", value: "dark" }],
|
|
285
|
+
color: colorEntry(config.backgroundColorDark),
|
|
268
286
|
idiom: "universal"
|
|
269
|
-
}
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
writeFileSync(join(launchBackground, "Contents.json"), `${JSON.stringify({
|
|
290
|
+
colors,
|
|
270
291
|
info: { author: "xcode", version: 1 }
|
|
271
292
|
}, null, 2)}
|
|
272
293
|
`);
|
|
@@ -340,10 +361,18 @@ async function refreshRuntime(output, override) {
|
|
|
340
361
|
await installRuntime(output, dir);
|
|
341
362
|
console.log(" Refreshed the Zig runtime from", dir);
|
|
342
363
|
}
|
|
364
|
+
function productName(name) {
|
|
365
|
+
const words = name.split(/[^A-Za-z0-9]+/).filter(Boolean);
|
|
366
|
+
const joined = words.map((word) => word[0].toUpperCase() + word.slice(1)).join("");
|
|
367
|
+
if (!joined)
|
|
368
|
+
return "Craft";
|
|
369
|
+
return /^\d/.test(joined) ? `App${joined}` : joined;
|
|
370
|
+
}
|
|
343
371
|
async function init(options) {
|
|
344
|
-
const { name, bundleId, teamId, output } = options;
|
|
372
|
+
const { name: displayName, bundleId, teamId, output } = options;
|
|
373
|
+
const name = productName(displayName);
|
|
345
374
|
console.log(`
|
|
346
|
-
\u26A1 Initializing Craft iOS project: ${
|
|
375
|
+
\u26A1 Initializing Craft iOS project: ${displayName}`);
|
|
347
376
|
console.log(` Output: ${output}
|
|
348
377
|
`);
|
|
349
378
|
const dirs = [output, join(output, "Sources"), join(output, "Shared"), join(output, "dist")];
|
|
@@ -354,11 +383,11 @@ async function init(options) {
|
|
|
354
383
|
mkdirSync(dir, { recursive: true });
|
|
355
384
|
}
|
|
356
385
|
}
|
|
357
|
-
const finalBundleId = bundleId || `com.craft.${name.toLowerCase()
|
|
386
|
+
const finalBundleId = bundleId || `com.craft.${name.toLowerCase()}`;
|
|
358
387
|
const bundleIdPrefix = finalBundleId.split(".").slice(0, -1).join(".");
|
|
359
388
|
const config = {
|
|
360
389
|
...DEFAULT_CONFIG,
|
|
361
|
-
appName:
|
|
390
|
+
appName: displayName,
|
|
362
391
|
bundleId: finalBundleId,
|
|
363
392
|
teamId: teamId || "",
|
|
364
393
|
...options.config
|
|
@@ -370,7 +399,7 @@ async function init(options) {
|
|
|
370
399
|
const swiftSource = swiftTemplate.replace(/CraftApp/g, `${name}App`).replace(/\{\{BUNDLE_ID\}\}/g, finalBundleId);
|
|
371
400
|
writeFileSync(join(output, "Sources", `${name}App.swift`), swiftSource);
|
|
372
401
|
const infoPlistTemplate = readFileSync(join(TEMPLATES_DIR, "Info.plist.template"), "utf-8");
|
|
373
|
-
const infoPlist = infoPlistTemplate.replace(/\{\{APP_NAME\}\}/g,
|
|
402
|
+
const infoPlist = infoPlistTemplate.replace(/\{\{APP_NAME\}\}/g, xmlEscape(displayName)).replace(/\{\{BUNDLE_ID\}\}/g, finalBundleId).replace(/\{\{VERSION\}\}/g, config.version || "1.0.0").replace(/\{\{BUILD_NUMBER\}\}/g, config.buildNumber || "1").replace(/\{\{UI_STYLE\}\}/g, renderAppearance(config).interfaceStyle).replace(/\{\{STATUS_BAR_STYLE\}\}/g, renderAppearance(config).statusBarStyle).replace(/\{\{ORIENTATIONS\}\}/g, renderOrientations(config)).replace(/\{\{USAGE_DESCRIPTIONS\}\}/g, renderUsageDescriptions(config)).replace(/\{\{URL_TYPES\}\}/g, renderUrlTypes(config)).replace(/\{\{BACKGROUND_MODES\}\}/g, renderBackgroundModes(config)).replace(/\{\{LIVE_ACTIVITY_SUPPORT\}\}/g, config.enableLiveActivities ? ` <key>NSSupportsLiveActivities</key>
|
|
374
403
|
<true/>
|
|
375
404
|
<key>NSSupportsLiveActivitiesFrequentUpdates</key>
|
|
376
405
|
<true/>` : "");
|
|
@@ -453,10 +482,10 @@ ${nativeDependencies.join(`
|
|
|
453
482
|
if (config.enableWatchApp) {
|
|
454
483
|
const watchSource = readFileSync(join(TEMPLATES_DIR, "CraftWatchApp.swift.template"), "utf8").replace(/\{\{APP_NAME\}\}/g, name);
|
|
455
484
|
writeFileSync(join(output, "WatchExtension", `${name}WatchApp.swift`), watchSource);
|
|
456
|
-
const watchInfo = readFileSync(join(TEMPLATES_DIR, "WatchApp.Info.plist.template"), "utf8").replace(/\{\{APP_NAME\}\}/g,
|
|
485
|
+
const watchInfo = readFileSync(join(TEMPLATES_DIR, "WatchApp.Info.plist.template"), "utf8").replace(/\{\{APP_NAME\}\}/g, xmlEscape(displayName)).replace(/\{\{BUNDLE_ID\}\}/g, finalBundleId);
|
|
457
486
|
writeFileSync(join(output, "WatchApp", "Info.plist"), watchInfo);
|
|
458
487
|
writeFileSync(join(output, "WatchApp", "Watch.entitlements"), renderWatchEntitlements(config));
|
|
459
|
-
const watchExtensionInfo = readFileSync(join(TEMPLATES_DIR, "WatchExtension.Info.plist.template"), "utf8").replace(/\{\{APP_NAME\}\}/g,
|
|
488
|
+
const watchExtensionInfo = readFileSync(join(TEMPLATES_DIR, "WatchExtension.Info.plist.template"), "utf8").replace(/\{\{APP_NAME\}\}/g, xmlEscape(displayName)).replace(/\{\{BUNDLE_ID\}\}/g, finalBundleId);
|
|
460
489
|
writeFileSync(join(output, "WatchExtension", "Info.plist"), watchExtensionInfo);
|
|
461
490
|
writeFileSync(join(output, "WatchExtension", "Watch.entitlements"), renderWatchEntitlements(config));
|
|
462
491
|
}
|
|
@@ -465,7 +494,7 @@ ${nativeDependencies.join(`
|
|
|
465
494
|
<head>
|
|
466
495
|
<meta charset="UTF-8">
|
|
467
496
|
<meta name="viewport" content="viewport-fit=cover, width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
|
468
|
-
<title>${
|
|
497
|
+
<title>${xmlEscape(displayName)}</title>
|
|
469
498
|
<style>
|
|
470
499
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
471
500
|
body {
|
|
@@ -486,7 +515,7 @@ ${nativeDependencies.join(`
|
|
|
486
515
|
</head>
|
|
487
516
|
<body>
|
|
488
517
|
<div class="container">
|
|
489
|
-
<h1>\u26A1 ${
|
|
518
|
+
<h1>\u26A1 ${xmlEscape(displayName)}</h1>
|
|
490
519
|
<p>Built with Craft iOS</p>
|
|
491
520
|
<p class="ready" id="status">Waiting for Craft bridge...</p>
|
|
492
521
|
</div>
|
|
@@ -536,7 +565,7 @@ async function build(options) {
|
|
|
536
565
|
if (result.exitCode === 0) {
|
|
537
566
|
console.log(" Running xcodegen...");
|
|
538
567
|
await $`cd ${output} && xcodegen generate`.quiet();
|
|
539
|
-
console.log(`\u2705 Xcode project created: ${config.appName}.xcodeproj`);
|
|
568
|
+
console.log(`\u2705 Xcode project created: ${productName(config.appName)}.xcodeproj`);
|
|
540
569
|
} else {
|
|
541
570
|
throw new Error("xcodegen not found");
|
|
542
571
|
}
|
|
@@ -655,6 +684,7 @@ export {
|
|
|
655
684
|
showSimulator,
|
|
656
685
|
run,
|
|
657
686
|
resolveRuntimeDir,
|
|
687
|
+
resolveAppearance,
|
|
658
688
|
renderWatchEntitlements,
|
|
659
689
|
renderUsageDescriptions,
|
|
660
690
|
renderUrlTypes,
|
|
@@ -664,6 +694,8 @@ export {
|
|
|
664
694
|
renderEntitlements,
|
|
665
695
|
renderDeviceFamilies,
|
|
666
696
|
renderBackgroundModes,
|
|
697
|
+
renderAppearance,
|
|
698
|
+
productName,
|
|
667
699
|
pickSimulator,
|
|
668
700
|
orderSimulators,
|
|
669
701
|
open,
|
package/package.json
CHANGED
package/templates/CraftApp.swift
CHANGED
|
@@ -223,7 +223,7 @@ struct CraftApp: App {
|
|
|
223
223
|
WindowGroup {
|
|
224
224
|
CraftWebView(config: appState.config)
|
|
225
225
|
.ignoresSafeArea()
|
|
226
|
-
.preferredColorScheme(appState.config.
|
|
226
|
+
.preferredColorScheme(appState.config.colorScheme)
|
|
227
227
|
.environmentObject(appState)
|
|
228
228
|
.onOpenURL { url in
|
|
229
229
|
// Handle deep links and universal links
|
|
@@ -353,7 +353,12 @@ struct CraftConfig: Codable {
|
|
|
353
353
|
var appName: String = "Craft App"
|
|
354
354
|
var bundleId: String = "com.craft.app"
|
|
355
355
|
var darkMode: Bool = true
|
|
356
|
+
/// "light", "dark" or "system". Absent in configs older than the field,
|
|
357
|
+
/// which keep what `darkMode` pinned.
|
|
358
|
+
var appearance: String? = nil
|
|
356
359
|
var backgroundColor: String = "#1a1a2e"
|
|
360
|
+
/// The background while the phone is in Dark Mode; the light one if unset.
|
|
361
|
+
var backgroundColorDark: String? = nil
|
|
357
362
|
var enableSpeechRecognition: Bool = false
|
|
358
363
|
var enableHaptics: Bool = false
|
|
359
364
|
var enableShare: Bool = false
|
|
@@ -393,6 +398,26 @@ struct CraftConfig: Codable {
|
|
|
393
398
|
var trustedOrigins: [String] = []
|
|
394
399
|
}
|
|
395
400
|
|
|
401
|
+
extension CraftConfig {
|
|
402
|
+
/// The scheme SwiftUI pins, or nil to follow the phone's setting.
|
|
403
|
+
var colorScheme: ColorScheme? {
|
|
404
|
+
switch appearance {
|
|
405
|
+
case "system": return nil
|
|
406
|
+
case "light": return .light
|
|
407
|
+
case "dark": return .dark
|
|
408
|
+
default: return darkMode ? .dark : .light
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/// The webview's background, resolved per trait so a Dark Mode switch
|
|
413
|
+
/// repaints it without a reload.
|
|
414
|
+
var resolvedBackgroundColor: UIColor {
|
|
415
|
+
let light = UIColor(hex: backgroundColor) ?? .black
|
|
416
|
+
guard let darkHex = backgroundColorDark, let dark = UIColor(hex: darkHex) else { return light }
|
|
417
|
+
return UIColor { traits in traits.userInterfaceStyle == .dark ? dark : light }
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
|
|
396
421
|
// MARK: - WebView
|
|
397
422
|
final class BundledAssetSchemeHandler: NSObject, WKURLSchemeHandler {
|
|
398
423
|
private let rootURL: URL?
|
|
@@ -517,7 +542,7 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
517
542
|
CraftEventManager.shared.setWebView(webView)
|
|
518
543
|
|
|
519
544
|
// Parse background color
|
|
520
|
-
let bgColor =
|
|
545
|
+
let bgColor = config.resolvedBackgroundColor
|
|
521
546
|
webView.backgroundColor = bgColor
|
|
522
547
|
webView.scrollView.backgroundColor = bgColor
|
|
523
548
|
|
|
@@ -1298,6 +1323,32 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
1298
1323
|
} else {
|
|
1299
1324
|
rejectCallback(callbackId, error: "HealthKit is disabled")
|
|
1300
1325
|
}
|
|
1326
|
+
case "getHealthWorkouts":
|
|
1327
|
+
if config.enableHealthKit {
|
|
1328
|
+
getHealthWorkouts(
|
|
1329
|
+
startDate: body["startDate"] as? Double,
|
|
1330
|
+
endDate: body["endDate"] as? Double,
|
|
1331
|
+
limit: body["limit"] as? Int,
|
|
1332
|
+
callbackId: callbackId
|
|
1333
|
+
)
|
|
1334
|
+
} else {
|
|
1335
|
+
rejectCallback(callbackId, error: "HealthKit is disabled", code: "CAPABILITY_DISABLED")
|
|
1336
|
+
}
|
|
1337
|
+
case "getHealthDailyStatistics":
|
|
1338
|
+
if config.enableHealthKit {
|
|
1339
|
+
if let dataType = body["type"] as? String {
|
|
1340
|
+
getHealthDailyStatistics(
|
|
1341
|
+
type: dataType,
|
|
1342
|
+
startDate: body["startDate"] as? Double,
|
|
1343
|
+
endDate: body["endDate"] as? Double,
|
|
1344
|
+
callbackId: callbackId
|
|
1345
|
+
)
|
|
1346
|
+
} else {
|
|
1347
|
+
rejectCallback(callbackId, error: "getHealthDailyStatistics was called without a type", code: "INVALID_ARGUMENT")
|
|
1348
|
+
}
|
|
1349
|
+
} else {
|
|
1350
|
+
rejectCallback(callbackId, error: "HealthKit is disabled", code: "CAPABILITY_DISABLED")
|
|
1351
|
+
}
|
|
1301
1352
|
|
|
1302
1353
|
// MARK: - Live Activities
|
|
1303
1354
|
case "startLiveActivity":
|
|
@@ -3341,7 +3392,15 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
3341
3392
|
options = options || {};
|
|
3342
3393
|
return craft._invoke('getHealthData', {type: type, startDate: options.startDate, endDate: options.endDate});
|
|
3343
3394
|
},
|
|
3344
|
-
saveWorkout: function(workout) { return craft._invoke('saveHealthWorkout', workout || {}); }
|
|
3395
|
+
saveWorkout: function(workout) { return craft._invoke('saveHealthWorkout', workout || {}); },
|
|
3396
|
+
getWorkouts: function(options) {
|
|
3397
|
+
options = options || {};
|
|
3398
|
+
return craft._invoke('getHealthWorkouts', {startDate: options.startDate, endDate: options.endDate, limit: options.limit});
|
|
3399
|
+
},
|
|
3400
|
+
getDailyStatistics: function(type, options) {
|
|
3401
|
+
options = options || {};
|
|
3402
|
+
return craft._invoke('getHealthDailyStatistics', {type: type, startDate: options.startDate, endDate: options.endDate});
|
|
3403
|
+
}
|
|
3345
3404
|
};
|
|
3346
3405
|
craft.liveActivity = {
|
|
3347
3406
|
start: function(options) { return craft._invoke('startLiveActivity', options || {}); },
|
|
@@ -5100,6 +5159,30 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
5100
5159
|
}
|
|
5101
5160
|
case "workouts":
|
|
5102
5161
|
readTypes.insert(HKObjectType.workoutType())
|
|
5162
|
+
// A workout's own heart rate and energy are read through
|
|
5163
|
+
// these; without them every workout comes back bare.
|
|
5164
|
+
if let heartType = HKQuantityType.quantityType(forIdentifier: .heartRate) {
|
|
5165
|
+
readTypes.insert(heartType)
|
|
5166
|
+
}
|
|
5167
|
+
if let energyType = HKQuantityType.quantityType(forIdentifier: .activeEnergyBurned) {
|
|
5168
|
+
readTypes.insert(energyType)
|
|
5169
|
+
}
|
|
5170
|
+
case "restingHeartRate":
|
|
5171
|
+
if let restingType = HKQuantityType.quantityType(forIdentifier: .restingHeartRate) {
|
|
5172
|
+
readTypes.insert(restingType)
|
|
5173
|
+
}
|
|
5174
|
+
case "heartRateVariability":
|
|
5175
|
+
if let hrvType = HKQuantityType.quantityType(forIdentifier: .heartRateVariabilitySDNN) {
|
|
5176
|
+
readTypes.insert(hrvType)
|
|
5177
|
+
}
|
|
5178
|
+
case "bodyMass":
|
|
5179
|
+
if let massType = HKQuantityType.quantityType(forIdentifier: .bodyMass) {
|
|
5180
|
+
readTypes.insert(massType)
|
|
5181
|
+
}
|
|
5182
|
+
case "sleep":
|
|
5183
|
+
if let sleepType = HKObjectType.categoryType(forIdentifier: .sleepAnalysis) {
|
|
5184
|
+
readTypes.insert(sleepType)
|
|
5185
|
+
}
|
|
5103
5186
|
default:
|
|
5104
5187
|
break
|
|
5105
5188
|
}
|
|
@@ -5120,44 +5203,32 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
5120
5203
|
return
|
|
5121
5204
|
}
|
|
5122
5205
|
|
|
5123
|
-
|
|
5124
|
-
var unit: HKUnit?
|
|
5125
|
-
|
|
5126
|
-
switch type {
|
|
5127
|
-
case "steps":
|
|
5128
|
-
quantityType = HKQuantityType.quantityType(forIdentifier: .stepCount)
|
|
5129
|
-
unit = .count()
|
|
5130
|
-
case "heartRate":
|
|
5131
|
-
quantityType = HKQuantityType.quantityType(forIdentifier: .heartRate)
|
|
5132
|
-
unit = HKUnit(from: "count/min")
|
|
5133
|
-
case "activeEnergy":
|
|
5134
|
-
quantityType = HKQuantityType.quantityType(forIdentifier: .activeEnergyBurned)
|
|
5135
|
-
unit = .kilocalorie()
|
|
5136
|
-
case "distance":
|
|
5137
|
-
quantityType = HKQuantityType.quantityType(forIdentifier: .distanceWalkingRunning)
|
|
5138
|
-
unit = .meter()
|
|
5139
|
-
default:
|
|
5206
|
+
guard let spec = Self.healthQuantity(type), let qType = spec.type else {
|
|
5140
5207
|
rejectCallback(callbackId, error: "Unknown health data type")
|
|
5141
5208
|
return
|
|
5142
5209
|
}
|
|
5143
|
-
|
|
5144
|
-
guard let qType = quantityType, let qUnit = unit else {
|
|
5145
|
-
rejectCallback(callbackId, error: "Invalid health data type")
|
|
5146
|
-
return
|
|
5147
|
-
}
|
|
5210
|
+
let qUnit = spec.unit
|
|
5148
5211
|
|
|
5149
5212
|
let start = startDate != nil ? Date(timeIntervalSince1970: startDate! / 1000) : Calendar.current.date(byAdding: .day, value: -7, to: Date())!
|
|
5150
5213
|
let end = endDate != nil ? Date(timeIntervalSince1970: endDate! / 1000) : Date()
|
|
5151
5214
|
|
|
5152
5215
|
let predicate = HKQuery.predicateForSamples(withStart: start, end: end, options: .strictStartDate)
|
|
5153
5216
|
|
|
5154
|
-
|
|
5217
|
+
// A discrete type (heart rate, weight) has no sum: HealthKit fails
|
|
5218
|
+
// the whole query when asked for one, which is what heart rate
|
|
5219
|
+
// used to do. Each type is asked for the statistic it has.
|
|
5220
|
+
let query = HKStatisticsQuery(quantityType: qType, quantitySamplePredicate: predicate, options: spec.options) { [weak self] _, result, error in
|
|
5155
5221
|
if let error = error {
|
|
5222
|
+
// No samples in the range is an answer, not a failure.
|
|
5223
|
+
if (error as? HKError)?.code == .errorNoData {
|
|
5224
|
+
self?.resolveCallback(callbackId, result: ["value": 0, "unit": qUnit.unitString])
|
|
5225
|
+
return
|
|
5226
|
+
}
|
|
5156
5227
|
self?.rejectCallback(callbackId, error: error.localizedDescription)
|
|
5157
5228
|
return
|
|
5158
5229
|
}
|
|
5159
5230
|
|
|
5160
|
-
let value = result
|
|
5231
|
+
let value = result.flatMap { Self.statisticValue($0, options: spec.options, unit: qUnit) } ?? 0
|
|
5161
5232
|
self?.resolveCallback(callbackId, result: ["value": value, "unit": qUnit.unitString])
|
|
5162
5233
|
}
|
|
5163
5234
|
|
|
@@ -5252,6 +5323,194 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
5252
5323
|
}
|
|
5253
5324
|
}
|
|
5254
5325
|
|
|
5326
|
+
/// The quantity behind each health data type, its unit, and the one
|
|
5327
|
+
/// statistic that type supports.
|
|
5328
|
+
private static func healthQuantity(_ type: String) -> (type: HKQuantityType?, unit: HKUnit, options: HKStatisticsOptions)? {
|
|
5329
|
+
let bpm = HKUnit.count().unitDivided(by: .minute())
|
|
5330
|
+
switch type {
|
|
5331
|
+
case "steps": return (HKQuantityType.quantityType(forIdentifier: .stepCount), .count(), .cumulativeSum)
|
|
5332
|
+
case "activeEnergy": return (HKQuantityType.quantityType(forIdentifier: .activeEnergyBurned), .kilocalorie(), .cumulativeSum)
|
|
5333
|
+
case "distance": return (HKQuantityType.quantityType(forIdentifier: .distanceWalkingRunning), .meter(), .cumulativeSum)
|
|
5334
|
+
case "heartRate": return (HKQuantityType.quantityType(forIdentifier: .heartRate), bpm, .discreteAverage)
|
|
5335
|
+
case "restingHeartRate": return (HKQuantityType.quantityType(forIdentifier: .restingHeartRate), bpm, .discreteAverage)
|
|
5336
|
+
case "heartRateVariability": return (HKQuantityType.quantityType(forIdentifier: .heartRateVariabilitySDNN), .secondUnit(with: .milli), .discreteAverage)
|
|
5337
|
+
case "bodyMass": return (HKQuantityType.quantityType(forIdentifier: .bodyMass), .gramUnit(with: .kilo), .mostRecent)
|
|
5338
|
+
default: return nil
|
|
5339
|
+
}
|
|
5340
|
+
}
|
|
5341
|
+
|
|
5342
|
+
private static func statisticValue(_ statistics: HKStatistics, options: HKStatisticsOptions, unit: HKUnit) -> Double? {
|
|
5343
|
+
if options.contains(.cumulativeSum) { return statistics.sumQuantity()?.doubleValue(for: unit) }
|
|
5344
|
+
if options.contains(.mostRecent) { return statistics.mostRecentQuantity()?.doubleValue(for: unit) }
|
|
5345
|
+
return statistics.averageQuantity()?.doubleValue(for: unit)
|
|
5346
|
+
}
|
|
5347
|
+
|
|
5348
|
+
private static let healthDayFormatter: DateFormatter = {
|
|
5349
|
+
let formatter = DateFormatter()
|
|
5350
|
+
formatter.calendar = Calendar(identifier: .gregorian)
|
|
5351
|
+
formatter.locale = Locale(identifier: "en_US_POSIX")
|
|
5352
|
+
formatter.timeZone = .current
|
|
5353
|
+
formatter.dateFormat = "yyyy-MM-dd"
|
|
5354
|
+
return formatter
|
|
5355
|
+
}()
|
|
5356
|
+
|
|
5357
|
+
/// A stable name for a workout's activity, independent of the SDK's
|
|
5358
|
+
/// raw values, so a page can map it to its own sports.
|
|
5359
|
+
private static func workoutTypeName(_ type: HKWorkoutActivityType) -> String {
|
|
5360
|
+
switch type {
|
|
5361
|
+
case .running: return "running"
|
|
5362
|
+
case .cycling: return "cycling"
|
|
5363
|
+
case .walking: return "walking"
|
|
5364
|
+
case .hiking: return "hiking"
|
|
5365
|
+
case .swimming: return "swimming"
|
|
5366
|
+
case .rowing: return "rowing"
|
|
5367
|
+
case .elliptical: return "elliptical"
|
|
5368
|
+
case .stairClimbing, .stairs: return "stairClimbing"
|
|
5369
|
+
case .yoga: return "yoga"
|
|
5370
|
+
case .pilates: return "pilates"
|
|
5371
|
+
case .functionalStrengthTraining, .traditionalStrengthTraining, .coreTraining: return "strength"
|
|
5372
|
+
case .highIntensityIntervalTraining: return "hiit"
|
|
5373
|
+
case .crossTraining, .mixedCardio: return "crossTraining"
|
|
5374
|
+
case .crossCountrySkiing: return "crossCountrySkiing"
|
|
5375
|
+
case .downhillSkiing, .snowboarding: return "skiing"
|
|
5376
|
+
case .paddleSports: return "paddling"
|
|
5377
|
+
case .climbing: return "climbing"
|
|
5378
|
+
case .dance, .socialDance, .cardioDance: return "dance"
|
|
5379
|
+
case .cooldown, .flexibility, .mindAndBody: return "mobility"
|
|
5380
|
+
default: return "other"
|
|
5381
|
+
}
|
|
5382
|
+
}
|
|
5383
|
+
|
|
5384
|
+
/// Workouts in Apple Health — the watch's, and every app's that
|
|
5385
|
+
/// writes there — newest first, with the numbers a training log needs.
|
|
5386
|
+
private func getHealthWorkouts(startDate: Double?, endDate: Double?, limit: Int?, callbackId: String?) {
|
|
5387
|
+
guard let healthStore = healthStore else {
|
|
5388
|
+
rejectCallback(callbackId, error: "HealthKit not available")
|
|
5389
|
+
return
|
|
5390
|
+
}
|
|
5391
|
+
|
|
5392
|
+
let start = startDate.map { Date(timeIntervalSince1970: $0 / 1000) } ?? Calendar.current.date(byAdding: .day, value: -30, to: Date())!
|
|
5393
|
+
let end = endDate.map { Date(timeIntervalSince1970: $0 / 1000) } ?? Date()
|
|
5394
|
+
let predicate = HKQuery.predicateForSamples(withStart: start, end: end, options: [])
|
|
5395
|
+
let newestFirst = NSSortDescriptor(key: HKSampleSortIdentifierStartDate, ascending: false)
|
|
5396
|
+
let cap = max(1, min(limit ?? 200, 1000))
|
|
5397
|
+
|
|
5398
|
+
let query = HKSampleQuery(sampleType: HKObjectType.workoutType(), predicate: predicate, limit: cap, sortDescriptors: [newestFirst]) { [weak self] _, samples, error in
|
|
5399
|
+
if let error = error, (error as? HKError)?.code != .errorNoData {
|
|
5400
|
+
self?.rejectCallback(callbackId, error: error.localizedDescription)
|
|
5401
|
+
return
|
|
5402
|
+
}
|
|
5403
|
+
|
|
5404
|
+
let bpm = HKUnit.count().unitDivided(by: .minute())
|
|
5405
|
+
let workouts: [[String: Any]] = (samples as? [HKWorkout] ?? []).map { workout in
|
|
5406
|
+
var item: [String: Any] = [
|
|
5407
|
+
"id": workout.uuid.uuidString,
|
|
5408
|
+
"type": Self.workoutTypeName(workout.workoutActivityType),
|
|
5409
|
+
"startDate": workout.startDate.timeIntervalSince1970 * 1000,
|
|
5410
|
+
"endDate": workout.endDate.timeIntervalSince1970 * 1000,
|
|
5411
|
+
"durationSeconds": workout.duration,
|
|
5412
|
+
"sourceName": workout.sourceRevision.source.name,
|
|
5413
|
+
"indoor": (workout.metadata?[HKMetadataKeyIndoorWorkout] as? Bool) ?? false,
|
|
5414
|
+
]
|
|
5415
|
+
if let meters = workout.totalDistance?.doubleValue(for: .meter()), meters > 0 {
|
|
5416
|
+
item["distanceMeters"] = meters
|
|
5417
|
+
}
|
|
5418
|
+
if let ascent = workout.metadata?[HKMetadataKeyElevationAscended] as? HKQuantity {
|
|
5419
|
+
item["elevationGainMeters"] = ascent.doubleValue(for: .meter())
|
|
5420
|
+
}
|
|
5421
|
+
if #available(iOS 16.0, *) {
|
|
5422
|
+
if let heartType = HKQuantityType.quantityType(forIdentifier: .heartRate),
|
|
5423
|
+
let heart = workout.statistics(for: heartType) {
|
|
5424
|
+
if let average = heart.averageQuantity()?.doubleValue(for: bpm) { item["averageHeartRate"] = average }
|
|
5425
|
+
if let maximum = heart.maximumQuantity()?.doubleValue(for: bpm) { item["maxHeartRate"] = maximum }
|
|
5426
|
+
}
|
|
5427
|
+
if let energyType = HKQuantityType.quantityType(forIdentifier: .activeEnergyBurned),
|
|
5428
|
+
let calories = workout.statistics(for: energyType)?.sumQuantity()?.doubleValue(for: .kilocalorie()) {
|
|
5429
|
+
item["activeEnergyCalories"] = calories
|
|
5430
|
+
}
|
|
5431
|
+
} else if let calories = workout.totalEnergyBurned?.doubleValue(for: .kilocalorie()) {
|
|
5432
|
+
item["activeEnergyCalories"] = calories
|
|
5433
|
+
}
|
|
5434
|
+
return item
|
|
5435
|
+
}
|
|
5436
|
+
self?.resolveCallback(callbackId, result: workouts)
|
|
5437
|
+
}
|
|
5438
|
+
healthStore.execute(query)
|
|
5439
|
+
}
|
|
5440
|
+
|
|
5441
|
+
/// One value per local day: a sum for steps, energy and distance, an
|
|
5442
|
+
/// average for heart rate, resting heart rate and HRV, the latest
|
|
5443
|
+
/// weight, and hours asleep for sleep (counted on the day you woke).
|
|
5444
|
+
private func getHealthDailyStatistics(type: String, startDate: Double?, endDate: Double?, callbackId: String?) {
|
|
5445
|
+
guard let healthStore = healthStore else {
|
|
5446
|
+
rejectCallback(callbackId, error: "HealthKit not available")
|
|
5447
|
+
return
|
|
5448
|
+
}
|
|
5449
|
+
|
|
5450
|
+
let calendar = Calendar.current
|
|
5451
|
+
let end = endDate.map { Date(timeIntervalSince1970: $0 / 1000) } ?? Date()
|
|
5452
|
+
let start = calendar.startOfDay(for: startDate.map { Date(timeIntervalSince1970: $0 / 1000) } ?? calendar.date(byAdding: .day, value: -30, to: end)!)
|
|
5453
|
+
|
|
5454
|
+
if type == "sleep" {
|
|
5455
|
+
guard let sleepType = HKObjectType.categoryType(forIdentifier: .sleepAnalysis) else {
|
|
5456
|
+
rejectCallback(callbackId, error: "Sleep data is unavailable")
|
|
5457
|
+
return
|
|
5458
|
+
}
|
|
5459
|
+
// From the evening before the first day, so its night counts.
|
|
5460
|
+
let predicate = HKQuery.predicateForSamples(withStart: calendar.date(byAdding: .hour, value: -12, to: start), end: end, options: [])
|
|
5461
|
+
let query = HKSampleQuery(sampleType: sleepType, predicate: predicate, limit: HKObjectQueryNoLimit, sortDescriptors: nil) { [weak self] _, samples, error in
|
|
5462
|
+
if let error = error, (error as? HKError)?.code != .errorNoData {
|
|
5463
|
+
self?.rejectCallback(callbackId, error: error.localizedDescription)
|
|
5464
|
+
return
|
|
5465
|
+
}
|
|
5466
|
+
// Time in bed and time awake are not sleep; every asleep
|
|
5467
|
+
// stage (and the older single "asleep" value) is.
|
|
5468
|
+
let awake: Set<Int> = [HKCategoryValueSleepAnalysis.inBed.rawValue, HKCategoryValueSleepAnalysis.awake.rawValue]
|
|
5469
|
+
var secondsByDay: [String: Double] = [:]
|
|
5470
|
+
for sample in samples as? [HKCategorySample] ?? [] where !awake.contains(sample.value) {
|
|
5471
|
+
let day = Self.healthDayFormatter.string(from: sample.endDate)
|
|
5472
|
+
secondsByDay[day, default: 0] += sample.endDate.timeIntervalSince(sample.startDate)
|
|
5473
|
+
}
|
|
5474
|
+
let days = secondsByDay.keys.sorted().map { day -> [String: Any] in
|
|
5475
|
+
["date": day, "value": (secondsByDay[day]! / 3600 * 100).rounded() / 100, "unit": "hr"]
|
|
5476
|
+
}
|
|
5477
|
+
self?.resolveCallback(callbackId, result: days)
|
|
5478
|
+
}
|
|
5479
|
+
healthStore.execute(query)
|
|
5480
|
+
return
|
|
5481
|
+
}
|
|
5482
|
+
|
|
5483
|
+
guard let spec = Self.healthQuantity(type), let qType = spec.type else {
|
|
5484
|
+
rejectCallback(callbackId, error: "Unknown health data type")
|
|
5485
|
+
return
|
|
5486
|
+
}
|
|
5487
|
+
let predicate = HKQuery.predicateForSamples(withStart: start, end: end, options: [])
|
|
5488
|
+
let query = HKStatisticsCollectionQuery(
|
|
5489
|
+
quantityType: qType,
|
|
5490
|
+
quantitySamplePredicate: predicate,
|
|
5491
|
+
options: spec.options,
|
|
5492
|
+
anchorDate: start,
|
|
5493
|
+
intervalComponents: DateComponents(day: 1)
|
|
5494
|
+
)
|
|
5495
|
+
query.initialResultsHandler = { [weak self] _, collection, error in
|
|
5496
|
+
if let error = error, (error as? HKError)?.code != .errorNoData {
|
|
5497
|
+
self?.rejectCallback(callbackId, error: error.localizedDescription)
|
|
5498
|
+
return
|
|
5499
|
+
}
|
|
5500
|
+
var days: [[String: Any]] = []
|
|
5501
|
+
collection?.enumerateStatistics(from: start, to: end) { statistics, _ in
|
|
5502
|
+
guard let value = Self.statisticValue(statistics, options: spec.options, unit: spec.unit) else { return }
|
|
5503
|
+
days.append([
|
|
5504
|
+
"date": Self.healthDayFormatter.string(from: statistics.startDate),
|
|
5505
|
+
"value": value,
|
|
5506
|
+
"unit": spec.unit.unitString,
|
|
5507
|
+
])
|
|
5508
|
+
}
|
|
5509
|
+
self?.resolveCallback(callbackId, result: days)
|
|
5510
|
+
}
|
|
5511
|
+
healthStore.execute(query)
|
|
5512
|
+
}
|
|
5513
|
+
|
|
5255
5514
|
// MARK: - Live Activities
|
|
5256
5515
|
private func startLiveActivity(body: [String: Any], callbackId: String?) {
|
|
5257
5516
|
guard #available(iOS 16.2, *), ActivityAuthorizationInfo().areActivitiesEnabled else {
|
|
@@ -38,7 +38,7 @@
|
|
|
38
38
|
<key>UIUserInterfaceStyle</key>
|
|
39
39
|
<string>{{UI_STYLE}}</string>
|
|
40
40
|
<key>UIStatusBarStyle</key>
|
|
41
|
-
<string>
|
|
41
|
+
<string>{{STATUS_BAR_STYLE}}</string>
|
|
42
42
|
<key>UIViewControllerBasedStatusBarAppearance</key>
|
|
43
43
|
<false/>
|
|
44
44
|
{{USAGE_DESCRIPTIONS}}
|