@craft-native/ios 0.0.98 → 0.0.100

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 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 color = config.backgroundColor?.replace(/^#/, "") || "000000";
265
- const normalized = color.length === 3 ? [...color].map((value) => `${value}${value}`).join("") : color.padEnd(6, "0").slice(0, 6);
266
- const components = [0, 2, 4].map((index) => (Number.parseInt(normalized.slice(index, index + 2), 16) / 255).toFixed(3));
267
- writeFileSync(join(launchBackground, "Contents.json"), `${JSON.stringify({
268
- colors: [{
269
- color: {
270
- "color-space": "srgb",
271
- components: { alpha: "1.000", blue: components[2], green: components[1], red: components[0] }
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: ${name}`);
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().replace(/[^a-z0-9]/g, "")}`;
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: name,
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, name).replace(/\{\{BUNDLE_ID\}\}/g, finalBundleId).replace(/\{\{VERSION\}\}/g, config.version || "1.0.0").replace(/\{\{BUILD_NUMBER\}\}/g, config.buildNumber || "1").replace(/\{\{UI_STYLE\}\}/g, config.darkMode ? "Dark" : "Light").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>
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, name).replace(/\{\{BUNDLE_ID\}\}/g, finalBundleId);
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, name).replace(/\{\{BUNDLE_ID\}\}/g, finalBundleId);
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>${name}</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 ${name}</h1>
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,24 @@
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;
22
+ /** Edge-swipe back and forward through the page's history, pushState entries included. */
23
+ swipeNavigation?: boolean;
13
24
  enableSpeechRecognition?: boolean;
14
25
  enableHaptics?: boolean;
15
26
  enableShare?: boolean;
@@ -115,6 +126,20 @@ export interface RunOptions {
115
126
  output: string;
116
127
  }
117
128
  export declare function renderDeviceFamilies(config: CraftConfig): string;
129
+ /** `appearance` when set, otherwise what the older `darkMode` switch pinned. */
130
+ export declare function resolveAppearance(config: Pick<CraftConfig, 'appearance' | 'darkMode'>): CraftAppearance;
131
+ /**
132
+ * The Info.plist interface style and the status bar that reads on it.
133
+ *
134
+ * The status bar used to be `LightContent` whatever the app looked like:
135
+ * white text that vanished on a light page. It now follows the appearance —
136
+ * dark text on a light app, light on a dark one, and the system's choice when
137
+ * the app follows the system.
138
+ */
139
+ export declare function renderAppearance(config: Pick<CraftConfig, 'appearance' | 'darkMode'>): {
140
+ interfaceStyle: string;
141
+ statusBarStyle: string;
142
+ };
118
143
  export declare function renderUsageDescriptions(config: CraftConfig): string;
119
144
  export declare function renderOrientations(config: CraftConfig): string;
120
145
  export declare function renderUrlTypes(config: CraftConfig): string;
@@ -181,6 +206,17 @@ export declare function resolveRuntimeDir(override?: string | null): string | nu
181
206
  * Returns true when a runtime was installed.
182
207
  */
183
208
  export declare function installRuntime(output: string, runtimeDir: string): Promise<boolean>;
209
+ /**
210
+ * The identifier an app's display name becomes wherever Xcode or Swift needs
211
+ * one: the project, the target, the product and the `@main` type.
212
+ *
213
+ * The display name goes in front of people and may say anything; an
214
+ * identifier may not. "HQ.training" used to become `struct HQ.trainingApp`,
215
+ * which does not compile. Words are joined in PascalCase ("HQTraining"), and
216
+ * a name that starts with a digit or has no letters at all still yields a
217
+ * valid Swift type.
218
+ */
219
+ export declare function productName(name: string): string;
184
220
  export declare function init(options: InitOptions): Promise<void>;
185
221
  /**
186
222
  * 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 color = config.backgroundColor?.replace(/^#/, "") || "000000";
260
- const normalized = color.length === 3 ? [...color].map((value) => `${value}${value}`).join("") : color.padEnd(6, "0").slice(0, 6);
261
- const components = [0, 2, 4].map((index) => (Number.parseInt(normalized.slice(index, index + 2), 16) / 255).toFixed(3));
262
- writeFileSync(join(launchBackground, "Contents.json"), `${JSON.stringify({
263
- colors: [{
264
- color: {
265
- "color-space": "srgb",
266
- components: { alpha: "1.000", blue: components[2], green: components[1], red: components[0] }
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: ${name}`);
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().replace(/[^a-z0-9]/g, "")}`;
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: name,
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, name).replace(/\{\{BUNDLE_ID\}\}/g, finalBundleId).replace(/\{\{VERSION\}\}/g, config.version || "1.0.0").replace(/\{\{BUILD_NUMBER\}\}/g, config.buildNumber || "1").replace(/\{\{UI_STYLE\}\}/g, config.darkMode ? "Dark" : "Light").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>
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, name).replace(/\{\{BUNDLE_ID\}\}/g, finalBundleId);
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, name).replace(/\{\{BUNDLE_ID\}\}/g, finalBundleId);
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>${name}</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 ${name}</h1>
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@craft-native/ios",
3
- "version": "0.0.98",
3
+ "version": "0.0.100",
4
4
  "homepage": "https://github.com/craft-native/craft/tree/main/packages/ios#readme",
5
5
  "repository": {
6
6
  "type": "git",
@@ -223,7 +223,7 @@ struct CraftApp: App {
223
223
  WindowGroup {
224
224
  CraftWebView(config: appState.config)
225
225
  .ignoresSafeArea()
226
- .preferredColorScheme(appState.config.darkMode ? .dark : .light)
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,14 @@ 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
362
+ /// Edge-swipe back and forward through the page's history.
363
+ var swipeNavigation: Bool? = nil
357
364
  var enableSpeechRecognition: Bool = false
358
365
  var enableHaptics: Bool = false
359
366
  var enableShare: Bool = false
@@ -393,6 +400,26 @@ struct CraftConfig: Codable {
393
400
  var trustedOrigins: [String] = []
394
401
  }
395
402
 
403
+ extension CraftConfig {
404
+ /// The scheme SwiftUI pins, or nil to follow the phone's setting.
405
+ var colorScheme: ColorScheme? {
406
+ switch appearance {
407
+ case "system": return nil
408
+ case "light": return .light
409
+ case "dark": return .dark
410
+ default: return darkMode ? .dark : .light
411
+ }
412
+ }
413
+
414
+ /// The webview's background, resolved per trait so a Dark Mode switch
415
+ /// repaints it without a reload.
416
+ var resolvedBackgroundColor: UIColor {
417
+ let light = UIColor(hex: backgroundColor) ?? .black
418
+ guard let darkHex = backgroundColorDark, let dark = UIColor(hex: darkHex) else { return light }
419
+ return UIColor { traits in traits.userInterfaceStyle == .dark ? dark : light }
420
+ }
421
+ }
422
+
396
423
  // MARK: - WebView
397
424
  final class BundledAssetSchemeHandler: NSObject, WKURLSchemeHandler {
398
425
  private let rootURL: URL?
@@ -505,6 +532,13 @@ struct CraftWebView: UIViewRepresentable {
505
532
  let webView = WKWebView(frame: .zero, configuration: webConfig)
506
533
  webView.navigationDelegate = context.coordinator
507
534
  webView.isOpaque = false
535
+ // An edge swipe goes back (and forward) through the page's history,
536
+ // pushState entries included, the way every iOS app's stack does.
537
+ webView.allowsBackForwardNavigationGestures = config.swipeNavigation ?? false
538
+ #if DEBUG
539
+ // Safari's Develop menu can attach to a debug build; never a release.
540
+ if #available(iOS 16.4, *) { webView.isInspectable = true }
541
+ #endif
508
542
 
509
543
  // Before any message can be offered. Zig evaluates every reply against
510
544
  // this webview, and without it an action would run, succeed, and reach
@@ -517,7 +551,7 @@ struct CraftWebView: UIViewRepresentable {
517
551
  CraftEventManager.shared.setWebView(webView)
518
552
 
519
553
  // Parse background color
520
- let bgColor = UIColor(hex: config.backgroundColor) ?? .black
554
+ let bgColor = config.resolvedBackgroundColor
521
555
  webView.backgroundColor = bgColor
522
556
  webView.scrollView.backgroundColor = bgColor
523
557
 
@@ -1276,7 +1310,7 @@ struct CraftWebView: UIViewRepresentable {
1276
1310
  case "requestHealthAuthorization":
1277
1311
  if config.enableHealthKit {
1278
1312
  let types = body["types"] as? [String] ?? []
1279
- requestHealthAuthorization(types: types, callbackId: callbackId)
1313
+ requestHealthAuthorization(types: types, readOnly: body["readOnly"] as? Bool ?? false, callbackId: callbackId)
1280
1314
  } else {
1281
1315
  rejectCallback(callbackId, error: "HealthKit is disabled", code: "CAPABILITY_DISABLED")
1282
1316
  }
@@ -1298,6 +1332,32 @@ struct CraftWebView: UIViewRepresentable {
1298
1332
  } else {
1299
1333
  rejectCallback(callbackId, error: "HealthKit is disabled")
1300
1334
  }
1335
+ case "getHealthWorkouts":
1336
+ if config.enableHealthKit {
1337
+ getHealthWorkouts(
1338
+ startDate: body["startDate"] as? Double,
1339
+ endDate: body["endDate"] as? Double,
1340
+ limit: body["limit"] as? Int,
1341
+ callbackId: callbackId
1342
+ )
1343
+ } else {
1344
+ rejectCallback(callbackId, error: "HealthKit is disabled", code: "CAPABILITY_DISABLED")
1345
+ }
1346
+ case "getHealthDailyStatistics":
1347
+ if config.enableHealthKit {
1348
+ if let dataType = body["type"] as? String {
1349
+ getHealthDailyStatistics(
1350
+ type: dataType,
1351
+ startDate: body["startDate"] as? Double,
1352
+ endDate: body["endDate"] as? Double,
1353
+ callbackId: callbackId
1354
+ )
1355
+ } else {
1356
+ rejectCallback(callbackId, error: "getHealthDailyStatistics was called without a type", code: "INVALID_ARGUMENT")
1357
+ }
1358
+ } else {
1359
+ rejectCallback(callbackId, error: "HealthKit is disabled", code: "CAPABILITY_DISABLED")
1360
+ }
1301
1361
 
1302
1362
  // MARK: - Live Activities
1303
1363
  case "startLiveActivity":
@@ -3188,19 +3248,28 @@ struct CraftWebView: UIViewRepresentable {
3188
3248
  var legacyNotifications = craft.notifications;
3189
3249
 
3190
3250
  craft.contractVersion = '1.0.0';
3251
+ // Actions answered by a person, through a system sheet they
3252
+ // may take a minute to read. Thirty seconds of reading the
3253
+ // HealthKit sheet used to fail the call while the sheet was
3254
+ // still open; these wait for the answer instead.
3255
+ var personFacing = {
3256
+ requestHealthAuthorization: true,
3257
+ requestPermission: true,
3258
+ signInWithApple: true
3259
+ };
3191
3260
  craft._invoke = function(action, payload) {
3192
3261
  var self = craft;
3193
3262
  var id = 'cb_' + (++self._callbackId);
3194
3263
  var message = Object.assign({}, payload || {}, {action: action, callbackId: id});
3195
3264
  window.webkit.messageHandlers.craft.postMessage(message);
3196
3265
  return new Promise(function(resolve, reject) {
3197
- var timeout = setTimeout(function() {
3266
+ var timeout = personFacing[action] ? null : setTimeout(function() {
3198
3267
  delete self._callbacks[id];
3199
3268
  reject(new Error('Craft bridge timed out: ' + action));
3200
3269
  }, 30000);
3201
3270
  self._callbacks[id] = {
3202
- resolve: function(value) { clearTimeout(timeout); resolve(value); },
3203
- reject: function(error) { clearTimeout(timeout); reject(error); }
3271
+ resolve: function(value) { if (timeout) clearTimeout(timeout); resolve(value); },
3272
+ reject: function(error) { if (timeout) clearTimeout(timeout); reject(error); }
3204
3273
  };
3205
3274
  });
3206
3275
  };
@@ -3336,12 +3405,22 @@ struct CraftWebView: UIViewRepresentable {
3336
3405
  readRecording: function() { return craft._invoke('readLocationRecording'); }
3337
3406
  };
3338
3407
  craft.health = {
3339
- requestAuthorization: function(types) { return craft._invoke('requestHealthAuthorization', {types: types || []}); },
3408
+ requestAuthorization: function(types, options) {
3409
+ return craft._invoke('requestHealthAuthorization', {types: types || [], readOnly: Boolean(options && options.write === false)});
3410
+ },
3340
3411
  getData: function(type, options) {
3341
3412
  options = options || {};
3342
3413
  return craft._invoke('getHealthData', {type: type, startDate: options.startDate, endDate: options.endDate});
3343
3414
  },
3344
- saveWorkout: function(workout) { return craft._invoke('saveHealthWorkout', workout || {}); }
3415
+ saveWorkout: function(workout) { return craft._invoke('saveHealthWorkout', workout || {}); },
3416
+ getWorkouts: function(options) {
3417
+ options = options || {};
3418
+ return craft._invoke('getHealthWorkouts', {startDate: options.startDate, endDate: options.endDate, limit: options.limit});
3419
+ },
3420
+ getDailyStatistics: function(type, options) {
3421
+ options = options || {};
3422
+ return craft._invoke('getHealthDailyStatistics', {type: type, startDate: options.startDate, endDate: options.endDate});
3423
+ }
3345
3424
  };
3346
3425
  craft.liveActivity = {
3347
3426
  start: function(options) { return craft._invoke('startLiveActivity', options || {}); },
@@ -5066,7 +5145,7 @@ struct CraftWebView: UIViewRepresentable {
5066
5145
  }
5067
5146
 
5068
5147
  // MARK: - Health
5069
- private func requestHealthAuthorization(types: [String], callbackId: String?) {
5148
+ private func requestHealthAuthorization(types: [String], readOnly: Bool = false, callbackId: String?) {
5070
5149
  guard let healthStore = healthStore else {
5071
5150
  rejectCallback(callbackId, error: "HealthKit not available")
5072
5151
  return
@@ -5100,11 +5179,38 @@ struct CraftWebView: UIViewRepresentable {
5100
5179
  }
5101
5180
  case "workouts":
5102
5181
  readTypes.insert(HKObjectType.workoutType())
5182
+ // A workout's own heart rate and energy are read through
5183
+ // these; without them every workout comes back bare.
5184
+ if let heartType = HKQuantityType.quantityType(forIdentifier: .heartRate) {
5185
+ readTypes.insert(heartType)
5186
+ }
5187
+ if let energyType = HKQuantityType.quantityType(forIdentifier: .activeEnergyBurned) {
5188
+ readTypes.insert(energyType)
5189
+ }
5190
+ case "restingHeartRate":
5191
+ if let restingType = HKQuantityType.quantityType(forIdentifier: .restingHeartRate) {
5192
+ readTypes.insert(restingType)
5193
+ }
5194
+ case "heartRateVariability":
5195
+ if let hrvType = HKQuantityType.quantityType(forIdentifier: .heartRateVariabilitySDNN) {
5196
+ readTypes.insert(hrvType)
5197
+ }
5198
+ case "bodyMass":
5199
+ if let massType = HKQuantityType.quantityType(forIdentifier: .bodyMass) {
5200
+ readTypes.insert(massType)
5201
+ }
5202
+ case "sleep":
5203
+ if let sleepType = HKObjectType.categoryType(forIdentifier: .sleepAnalysis) {
5204
+ readTypes.insert(sleepType)
5205
+ }
5103
5206
  default:
5104
5207
  break
5105
5208
  }
5106
5209
  }
5107
5210
 
5211
+ // A page that only reads asks only to read: the sheet then says
5212
+ // "access", not "access and update".
5213
+ if readOnly { shareTypes.removeAll() }
5108
5214
  healthStore.requestAuthorization(toShare: shareTypes, read: readTypes) { [weak self] success, error in
5109
5215
  if success {
5110
5216
  self?.resolveCallback(callbackId, result: true)
@@ -5120,44 +5226,32 @@ struct CraftWebView: UIViewRepresentable {
5120
5226
  return
5121
5227
  }
5122
5228
 
5123
- var quantityType: HKQuantityType?
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:
5229
+ guard let spec = Self.healthQuantity(type), let qType = spec.type else {
5140
5230
  rejectCallback(callbackId, error: "Unknown health data type")
5141
5231
  return
5142
5232
  }
5143
-
5144
- guard let qType = quantityType, let qUnit = unit else {
5145
- rejectCallback(callbackId, error: "Invalid health data type")
5146
- return
5147
- }
5233
+ let qUnit = spec.unit
5148
5234
 
5149
5235
  let start = startDate != nil ? Date(timeIntervalSince1970: startDate! / 1000) : Calendar.current.date(byAdding: .day, value: -7, to: Date())!
5150
5236
  let end = endDate != nil ? Date(timeIntervalSince1970: endDate! / 1000) : Date()
5151
5237
 
5152
5238
  let predicate = HKQuery.predicateForSamples(withStart: start, end: end, options: .strictStartDate)
5153
5239
 
5154
- let query = HKStatisticsQuery(quantityType: qType, quantitySamplePredicate: predicate, options: .cumulativeSum) { [weak self] _, result, error in
5240
+ // A discrete type (heart rate, weight) has no sum: HealthKit fails
5241
+ // the whole query when asked for one, which is what heart rate
5242
+ // used to do. Each type is asked for the statistic it has.
5243
+ let query = HKStatisticsQuery(quantityType: qType, quantitySamplePredicate: predicate, options: spec.options) { [weak self] _, result, error in
5155
5244
  if let error = error {
5245
+ // No samples in the range is an answer, not a failure.
5246
+ if (error as? HKError)?.code == .errorNoData {
5247
+ self?.resolveCallback(callbackId, result: ["value": 0, "unit": qUnit.unitString])
5248
+ return
5249
+ }
5156
5250
  self?.rejectCallback(callbackId, error: error.localizedDescription)
5157
5251
  return
5158
5252
  }
5159
5253
 
5160
- let value = result?.sumQuantity()?.doubleValue(for: qUnit) ?? 0
5254
+ let value = result.flatMap { Self.statisticValue($0, options: spec.options, unit: qUnit) } ?? 0
5161
5255
  self?.resolveCallback(callbackId, result: ["value": value, "unit": qUnit.unitString])
5162
5256
  }
5163
5257
 
@@ -5252,6 +5346,194 @@ struct CraftWebView: UIViewRepresentable {
5252
5346
  }
5253
5347
  }
5254
5348
 
5349
+ /// The quantity behind each health data type, its unit, and the one
5350
+ /// statistic that type supports.
5351
+ private static func healthQuantity(_ type: String) -> (type: HKQuantityType?, unit: HKUnit, options: HKStatisticsOptions)? {
5352
+ let bpm = HKUnit.count().unitDivided(by: .minute())
5353
+ switch type {
5354
+ case "steps": return (HKQuantityType.quantityType(forIdentifier: .stepCount), .count(), .cumulativeSum)
5355
+ case "activeEnergy": return (HKQuantityType.quantityType(forIdentifier: .activeEnergyBurned), .kilocalorie(), .cumulativeSum)
5356
+ case "distance": return (HKQuantityType.quantityType(forIdentifier: .distanceWalkingRunning), .meter(), .cumulativeSum)
5357
+ case "heartRate": return (HKQuantityType.quantityType(forIdentifier: .heartRate), bpm, .discreteAverage)
5358
+ case "restingHeartRate": return (HKQuantityType.quantityType(forIdentifier: .restingHeartRate), bpm, .discreteAverage)
5359
+ case "heartRateVariability": return (HKQuantityType.quantityType(forIdentifier: .heartRateVariabilitySDNN), .secondUnit(with: .milli), .discreteAverage)
5360
+ case "bodyMass": return (HKQuantityType.quantityType(forIdentifier: .bodyMass), .gramUnit(with: .kilo), .mostRecent)
5361
+ default: return nil
5362
+ }
5363
+ }
5364
+
5365
+ private static func statisticValue(_ statistics: HKStatistics, options: HKStatisticsOptions, unit: HKUnit) -> Double? {
5366
+ if options.contains(.cumulativeSum) { return statistics.sumQuantity()?.doubleValue(for: unit) }
5367
+ if options.contains(.mostRecent) { return statistics.mostRecentQuantity()?.doubleValue(for: unit) }
5368
+ return statistics.averageQuantity()?.doubleValue(for: unit)
5369
+ }
5370
+
5371
+ private static let healthDayFormatter: DateFormatter = {
5372
+ let formatter = DateFormatter()
5373
+ formatter.calendar = Calendar(identifier: .gregorian)
5374
+ formatter.locale = Locale(identifier: "en_US_POSIX")
5375
+ formatter.timeZone = .current
5376
+ formatter.dateFormat = "yyyy-MM-dd"
5377
+ return formatter
5378
+ }()
5379
+
5380
+ /// A stable name for a workout's activity, independent of the SDK's
5381
+ /// raw values, so a page can map it to its own sports.
5382
+ private static func workoutTypeName(_ type: HKWorkoutActivityType) -> String {
5383
+ switch type {
5384
+ case .running: return "running"
5385
+ case .cycling: return "cycling"
5386
+ case .walking: return "walking"
5387
+ case .hiking: return "hiking"
5388
+ case .swimming: return "swimming"
5389
+ case .rowing: return "rowing"
5390
+ case .elliptical: return "elliptical"
5391
+ case .stairClimbing, .stairs: return "stairClimbing"
5392
+ case .yoga: return "yoga"
5393
+ case .pilates: return "pilates"
5394
+ case .functionalStrengthTraining, .traditionalStrengthTraining, .coreTraining: return "strength"
5395
+ case .highIntensityIntervalTraining: return "hiit"
5396
+ case .crossTraining, .mixedCardio: return "crossTraining"
5397
+ case .crossCountrySkiing: return "crossCountrySkiing"
5398
+ case .downhillSkiing, .snowboarding: return "skiing"
5399
+ case .paddleSports: return "paddling"
5400
+ case .climbing: return "climbing"
5401
+ case .dance, .socialDance, .cardioDance: return "dance"
5402
+ case .cooldown, .flexibility, .mindAndBody: return "mobility"
5403
+ default: return "other"
5404
+ }
5405
+ }
5406
+
5407
+ /// Workouts in Apple Health — the watch's, and every app's that
5408
+ /// writes there — newest first, with the numbers a training log needs.
5409
+ private func getHealthWorkouts(startDate: Double?, endDate: Double?, limit: Int?, callbackId: String?) {
5410
+ guard let healthStore = healthStore else {
5411
+ rejectCallback(callbackId, error: "HealthKit not available")
5412
+ return
5413
+ }
5414
+
5415
+ let start = startDate.map { Date(timeIntervalSince1970: $0 / 1000) } ?? Calendar.current.date(byAdding: .day, value: -30, to: Date())!
5416
+ let end = endDate.map { Date(timeIntervalSince1970: $0 / 1000) } ?? Date()
5417
+ let predicate = HKQuery.predicateForSamples(withStart: start, end: end, options: [])
5418
+ let newestFirst = NSSortDescriptor(key: HKSampleSortIdentifierStartDate, ascending: false)
5419
+ let cap = max(1, min(limit ?? 200, 1000))
5420
+
5421
+ let query = HKSampleQuery(sampleType: HKObjectType.workoutType(), predicate: predicate, limit: cap, sortDescriptors: [newestFirst]) { [weak self] _, samples, error in
5422
+ if let error = error, (error as? HKError)?.code != .errorNoData {
5423
+ self?.rejectCallback(callbackId, error: error.localizedDescription)
5424
+ return
5425
+ }
5426
+
5427
+ let bpm = HKUnit.count().unitDivided(by: .minute())
5428
+ let workouts: [[String: Any]] = (samples as? [HKWorkout] ?? []).map { workout in
5429
+ var item: [String: Any] = [
5430
+ "id": workout.uuid.uuidString,
5431
+ "type": Self.workoutTypeName(workout.workoutActivityType),
5432
+ "startDate": workout.startDate.timeIntervalSince1970 * 1000,
5433
+ "endDate": workout.endDate.timeIntervalSince1970 * 1000,
5434
+ "durationSeconds": workout.duration,
5435
+ "sourceName": workout.sourceRevision.source.name,
5436
+ "indoor": (workout.metadata?[HKMetadataKeyIndoorWorkout] as? Bool) ?? false,
5437
+ ]
5438
+ if let meters = workout.totalDistance?.doubleValue(for: .meter()), meters > 0 {
5439
+ item["distanceMeters"] = meters
5440
+ }
5441
+ if let ascent = workout.metadata?[HKMetadataKeyElevationAscended] as? HKQuantity {
5442
+ item["elevationGainMeters"] = ascent.doubleValue(for: .meter())
5443
+ }
5444
+ if #available(iOS 16.0, *) {
5445
+ if let heartType = HKQuantityType.quantityType(forIdentifier: .heartRate),
5446
+ let heart = workout.statistics(for: heartType) {
5447
+ if let average = heart.averageQuantity()?.doubleValue(for: bpm) { item["averageHeartRate"] = average }
5448
+ if let maximum = heart.maximumQuantity()?.doubleValue(for: bpm) { item["maxHeartRate"] = maximum }
5449
+ }
5450
+ if let energyType = HKQuantityType.quantityType(forIdentifier: .activeEnergyBurned),
5451
+ let calories = workout.statistics(for: energyType)?.sumQuantity()?.doubleValue(for: .kilocalorie()) {
5452
+ item["activeEnergyCalories"] = calories
5453
+ }
5454
+ } else if let calories = workout.totalEnergyBurned?.doubleValue(for: .kilocalorie()) {
5455
+ item["activeEnergyCalories"] = calories
5456
+ }
5457
+ return item
5458
+ }
5459
+ self?.resolveCallback(callbackId, result: workouts)
5460
+ }
5461
+ healthStore.execute(query)
5462
+ }
5463
+
5464
+ /// One value per local day: a sum for steps, energy and distance, an
5465
+ /// average for heart rate, resting heart rate and HRV, the latest
5466
+ /// weight, and hours asleep for sleep (counted on the day you woke).
5467
+ private func getHealthDailyStatistics(type: String, startDate: Double?, endDate: Double?, callbackId: String?) {
5468
+ guard let healthStore = healthStore else {
5469
+ rejectCallback(callbackId, error: "HealthKit not available")
5470
+ return
5471
+ }
5472
+
5473
+ let calendar = Calendar.current
5474
+ let end = endDate.map { Date(timeIntervalSince1970: $0 / 1000) } ?? Date()
5475
+ let start = calendar.startOfDay(for: startDate.map { Date(timeIntervalSince1970: $0 / 1000) } ?? calendar.date(byAdding: .day, value: -30, to: end)!)
5476
+
5477
+ if type == "sleep" {
5478
+ guard let sleepType = HKObjectType.categoryType(forIdentifier: .sleepAnalysis) else {
5479
+ rejectCallback(callbackId, error: "Sleep data is unavailable")
5480
+ return
5481
+ }
5482
+ // From the evening before the first day, so its night counts.
5483
+ let predicate = HKQuery.predicateForSamples(withStart: calendar.date(byAdding: .hour, value: -12, to: start), end: end, options: [])
5484
+ let query = HKSampleQuery(sampleType: sleepType, predicate: predicate, limit: HKObjectQueryNoLimit, sortDescriptors: nil) { [weak self] _, samples, error in
5485
+ if let error = error, (error as? HKError)?.code != .errorNoData {
5486
+ self?.rejectCallback(callbackId, error: error.localizedDescription)
5487
+ return
5488
+ }
5489
+ // Time in bed and time awake are not sleep; every asleep
5490
+ // stage (and the older single "asleep" value) is.
5491
+ let awake: Set<Int> = [HKCategoryValueSleepAnalysis.inBed.rawValue, HKCategoryValueSleepAnalysis.awake.rawValue]
5492
+ var secondsByDay: [String: Double] = [:]
5493
+ for sample in samples as? [HKCategorySample] ?? [] where !awake.contains(sample.value) {
5494
+ let day = Self.healthDayFormatter.string(from: sample.endDate)
5495
+ secondsByDay[day, default: 0] += sample.endDate.timeIntervalSince(sample.startDate)
5496
+ }
5497
+ let days = secondsByDay.keys.sorted().map { day -> [String: Any] in
5498
+ ["date": day, "value": (secondsByDay[day]! / 3600 * 100).rounded() / 100, "unit": "hr"]
5499
+ }
5500
+ self?.resolveCallback(callbackId, result: days)
5501
+ }
5502
+ healthStore.execute(query)
5503
+ return
5504
+ }
5505
+
5506
+ guard let spec = Self.healthQuantity(type), let qType = spec.type else {
5507
+ rejectCallback(callbackId, error: "Unknown health data type")
5508
+ return
5509
+ }
5510
+ let predicate = HKQuery.predicateForSamples(withStart: start, end: end, options: [])
5511
+ let query = HKStatisticsCollectionQuery(
5512
+ quantityType: qType,
5513
+ quantitySamplePredicate: predicate,
5514
+ options: spec.options,
5515
+ anchorDate: start,
5516
+ intervalComponents: DateComponents(day: 1)
5517
+ )
5518
+ query.initialResultsHandler = { [weak self] _, collection, error in
5519
+ if let error = error, (error as? HKError)?.code != .errorNoData {
5520
+ self?.rejectCallback(callbackId, error: error.localizedDescription)
5521
+ return
5522
+ }
5523
+ var days: [[String: Any]] = []
5524
+ collection?.enumerateStatistics(from: start, to: end) { statistics, _ in
5525
+ guard let value = Self.statisticValue(statistics, options: spec.options, unit: spec.unit) else { return }
5526
+ days.append([
5527
+ "date": Self.healthDayFormatter.string(from: statistics.startDate),
5528
+ "value": value,
5529
+ "unit": spec.unit.unitString,
5530
+ ])
5531
+ }
5532
+ self?.resolveCallback(callbackId, result: days)
5533
+ }
5534
+ healthStore.execute(query)
5535
+ }
5536
+
5255
5537
  // MARK: - Live Activities
5256
5538
  private func startLiveActivity(body: [String: Any], callbackId: String?) {
5257
5539
  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>UIStatusBarStyleLightContent</string>
41
+ <string>{{STATUS_BAR_STYLE}}</string>
42
42
  <key>UIViewControllerBasedStatusBarAppearance</key>
43
43
  <false/>
44
44
  {{USAGE_DESCRIPTIONS}}