@craft-native/ios 0.0.272 → 0.0.278
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +50 -0
- package/dist/cli.js +7 -3
- package/dist/index.js +7 -3
- package/package.json +1 -1
- package/templates/CraftHybrid.swift +5 -2
- package/templates/CraftNativeActions.swift +6 -0
- package/templates/CraftNativeFiles.swift +136 -0
- package/templates/CraftNativeMutation.swift +51 -0
- package/templates/CraftNativeScreen.swift +183 -14
- package/templates/CraftWatchApp.swift.template +303 -9
- package/templates/WatchExtension.Info.plist.template +1 -0
package/README.md
CHANGED
|
@@ -594,6 +594,28 @@ stopListening()
|
|
|
594
594
|
// sends a made-up watch reading every 5 s instead: someone settling into slow
|
|
595
595
|
// breathing, from about 72 toward 60 bpm.
|
|
596
596
|
|
|
597
|
+
// Live workout sessions (iOS 26+, requires enableHealthKit: true). The OS
|
|
598
|
+
// keeps the session alive while the app is backgrounded; samples arrive as
|
|
599
|
+
// typed craftHealthWorkout events. Ask for the workout/heart-rate grants before
|
|
600
|
+
// starting a session.
|
|
601
|
+
await health.requestAuthorization(['workouts', 'heartRate', 'activeEnergy', 'distance'], { write: true })
|
|
602
|
+
const stopWorkout = health.onWorkout(event => {
|
|
603
|
+
if (event.event === 'metrics') showMetrics(event.heartRateBpm, event.activeEnergyCalories, event.distanceMeters)
|
|
604
|
+
if (event.event === 'state') showWorkoutState(event.state)
|
|
605
|
+
if (event.event === 'error') showWorkoutError(event.message)
|
|
606
|
+
})
|
|
607
|
+
const live = await health.startWorkout({ type: 'running', location: 'outdoor' })
|
|
608
|
+
await health.pauseWorkout()
|
|
609
|
+
await health.resumeWorkout()
|
|
610
|
+
const saved = await health.endWorkout() // { id, workoutId, endDate, durationSeconds }
|
|
611
|
+
stopWorkout()
|
|
612
|
+
|
|
613
|
+
`startWorkout`, `pauseWorkout`, `resumeWorkout`, and `endWorkout` reject with
|
|
614
|
+
`Live workouts are unavailable` when the build does not include HealthKit or
|
|
615
|
+
when the OS is older than iOS 26. `onWorkout` receives lifecycle events plus
|
|
616
|
+
live `heartRateBpm`, `activeEnergyCalories`, and `distanceMeters` metrics; the
|
|
617
|
+
existing post-hoc `saveWorkout` API remains separate.
|
|
618
|
+
|
|
597
619
|
// Screen Capture
|
|
598
620
|
const screenshotBase64 = await window.craft.takeScreenshot();
|
|
599
621
|
|
|
@@ -841,6 +863,14 @@ if (await window.craft.scheduledWorkouts.isSupported()) {
|
|
|
841
863
|
// FormData through fetch, or hand its path to the share sheet.
|
|
842
864
|
const photo = await craft.files.pickImage({ maxPixels: 2048, quality: 0.85 });
|
|
843
865
|
// { path, name: 'IMG_1234.jpg', type: 'image/jpeg', size, width, height }
|
|
866
|
+
// General documents use the same owned-file path and can be selected more
|
|
867
|
+
// than one at a time. Filters are MIME types, UTIs, or filename extensions.
|
|
868
|
+
const attachments = await craft.files.pickFile({
|
|
869
|
+
contentTypes: ['application/pdf', 'csv', 'gpx', 'fit'],
|
|
870
|
+
maxSelections: 4,
|
|
871
|
+
maxBytes: 20 * 1024 * 1024,
|
|
872
|
+
});
|
|
873
|
+
// [{ path, name, type, size }], or [] when the picker is dismissed.
|
|
844
874
|
const form = new FormData();
|
|
845
875
|
form.append('photo', photo); // multipart, built natively
|
|
846
876
|
await fetch('https://example.com/upload', { method: 'POST', body: form });
|
|
@@ -1004,6 +1034,26 @@ await window.craft.watch.updateContext({
|
|
|
1004
1034
|
status: 'active'
|
|
1005
1035
|
});
|
|
1006
1036
|
|
|
1037
|
+
// A planned workout is delivered through application context, so the latest
|
|
1038
|
+
// plan replaces an older one while the watch is offline and is replayed when
|
|
1039
|
+
// the companion launches. The watch persists its current plan and metrics in
|
|
1040
|
+
// its own UserDefaults store, then publishes a `watch-state` context back.
|
|
1041
|
+
await window.craft.watch.updateContext({
|
|
1042
|
+
type: 'planned-session',
|
|
1043
|
+
plan: {
|
|
1044
|
+
id: 'morning-run',
|
|
1045
|
+
activity: 'running',
|
|
1046
|
+
startDate: Date.now() + 60 * 60 * 1000,
|
|
1047
|
+
targetDistanceMeters: 5000,
|
|
1048
|
+
},
|
|
1049
|
+
});
|
|
1050
|
+
|
|
1051
|
+
// Watch controls include a protocol version and requestId. Use requestId for
|
|
1052
|
+
// idempotency when a queued command arrives after a reconnect. The watch's
|
|
1053
|
+
// `watch-state` context includes a monotonically increasing stateVersion; use
|
|
1054
|
+
// the highest version when a queued command and a reconnect snapshot cross.
|
|
1055
|
+
await window.craft.watch.send({ type: 'recording-control', action: 'start' });
|
|
1056
|
+
|
|
1007
1057
|
// Listen for messages from watch
|
|
1008
1058
|
window.craft.watch.onMessage((message) => {
|
|
1009
1059
|
console.log('Watch message:', message);
|
package/dist/cli.js
CHANGED
|
@@ -589,12 +589,15 @@ function renderWatchEntitlements(config) {
|
|
|
589
589
|
<array>
|
|
590
590
|
${plistArray(config.appGroups)}
|
|
591
591
|
</array>
|
|
592
|
+
` : "";
|
|
593
|
+
const healthKit = config.enableHealthKit ? ` <key>com.apple.developer.healthkit</key>
|
|
594
|
+
<true/>
|
|
592
595
|
` : "";
|
|
593
596
|
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
594
597
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
595
598
|
<plist version="1.0">
|
|
596
599
|
<dict>
|
|
597
|
-
${appGroups}</dict>
|
|
600
|
+
${appGroups}${healthKit}</dict>
|
|
598
601
|
</plist>
|
|
599
602
|
`;
|
|
600
603
|
}
|
|
@@ -995,7 +998,7 @@ function writeShell(output, config) {
|
|
|
995
998
|
if (!enabled)
|
|
996
999
|
continue;
|
|
997
1000
|
mkdirSync(join3(output, folder), { recursive: true });
|
|
998
|
-
const source = readFileSync(join3(TEMPLATES_DIR, template), "utf8").replace(/\{\{APP_NAME\}\}/g, name);
|
|
1001
|
+
const source = readFileSync(join3(TEMPLATES_DIR, template), "utf8").replace(/\{\{APP_NAME\}\}/g, name).replace(/\{\{HEALTH_KIT_ENABLED\}\}/g, config.enableHealthKit ? "true" : "false");
|
|
999
1002
|
writeFileSync(join3(output, folder, filename), source);
|
|
1000
1003
|
}
|
|
1001
1004
|
}
|
|
@@ -1128,7 +1131,8 @@ ${nativeDependencies.join(`
|
|
|
1128
1131
|
const watchInfo = readFileSync(join3(TEMPLATES_DIR, "WatchApp.Info.plist.template"), "utf8").replace(/\{\{APP_NAME\}\}/g, xmlEscape(displayName)).replace(/\{\{BUNDLE_ID\}\}/g, finalBundleId);
|
|
1129
1132
|
writeFileSync(join3(output, "WatchApp", "Info.plist"), watchInfo);
|
|
1130
1133
|
writeFileSync(join3(output, "WatchApp", "Watch.entitlements"), renderWatchEntitlements(config));
|
|
1131
|
-
const watchExtensionInfo = readFileSync(join3(TEMPLATES_DIR, "WatchExtension.Info.plist.template"), "utf8").replace(/\{\{APP_NAME\}\}/g, xmlEscape(displayName)).replace(/\{\{BUNDLE_ID\}\}/g, finalBundleId)
|
|
1134
|
+
const watchExtensionInfo = readFileSync(join3(TEMPLATES_DIR, "WatchExtension.Info.plist.template"), "utf8").replace(/\{\{APP_NAME\}\}/g, xmlEscape(displayName)).replace(/\{\{BUNDLE_ID\}\}/g, finalBundleId).replace(/\{\{HEALTH_USAGE_DESCRIPTIONS\}\}/g, config.enableHealthKit ? `${plistString("NSHealthShareUsageDescription", `${displayName} reads health data you choose to share.`)}
|
|
1135
|
+
${plistString("NSHealthUpdateUsageDescription", `${displayName} writes health data only with your permission.`)}` : "");
|
|
1132
1136
|
writeFileSync(join3(output, "WatchExtension", "Info.plist"), watchExtensionInfo);
|
|
1133
1137
|
writeFileSync(join3(output, "WatchExtension", "Watch.entitlements"), renderWatchEntitlements(config));
|
|
1134
1138
|
}
|
package/dist/index.js
CHANGED
|
@@ -776,12 +776,15 @@ function renderWatchEntitlements(config) {
|
|
|
776
776
|
<array>
|
|
777
777
|
${plistArray(config.appGroups)}
|
|
778
778
|
</array>
|
|
779
|
+
` : "";
|
|
780
|
+
const healthKit = config.enableHealthKit ? ` <key>com.apple.developer.healthkit</key>
|
|
781
|
+
<true/>
|
|
779
782
|
` : "";
|
|
780
783
|
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
781
784
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
782
785
|
<plist version="1.0">
|
|
783
786
|
<dict>
|
|
784
|
-
${appGroups}</dict>
|
|
787
|
+
${appGroups}${healthKit}</dict>
|
|
785
788
|
</plist>
|
|
786
789
|
`;
|
|
787
790
|
}
|
|
@@ -1195,7 +1198,7 @@ function writeShell(output, config) {
|
|
|
1195
1198
|
if (!enabled)
|
|
1196
1199
|
continue;
|
|
1197
1200
|
mkdirSync2(join3(output, folder), { recursive: true });
|
|
1198
|
-
const source = readFileSync2(join3(TEMPLATES_DIR, template), "utf8").replace(/\{\{APP_NAME\}\}/g, name);
|
|
1201
|
+
const source = readFileSync2(join3(TEMPLATES_DIR, template), "utf8").replace(/\{\{APP_NAME\}\}/g, name).replace(/\{\{HEALTH_KIT_ENABLED\}\}/g, config.enableHealthKit ? "true" : "false");
|
|
1199
1202
|
writeFileSync2(join3(output, folder, filename), source);
|
|
1200
1203
|
}
|
|
1201
1204
|
}
|
|
@@ -1328,7 +1331,8 @@ ${nativeDependencies.join(`
|
|
|
1328
1331
|
const watchInfo = readFileSync2(join3(TEMPLATES_DIR, "WatchApp.Info.plist.template"), "utf8").replace(/\{\{APP_NAME\}\}/g, xmlEscape(displayName)).replace(/\{\{BUNDLE_ID\}\}/g, finalBundleId);
|
|
1329
1332
|
writeFileSync2(join3(output, "WatchApp", "Info.plist"), watchInfo);
|
|
1330
1333
|
writeFileSync2(join3(output, "WatchApp", "Watch.entitlements"), renderWatchEntitlements(config));
|
|
1331
|
-
const watchExtensionInfo = readFileSync2(join3(TEMPLATES_DIR, "WatchExtension.Info.plist.template"), "utf8").replace(/\{\{APP_NAME\}\}/g, xmlEscape(displayName)).replace(/\{\{BUNDLE_ID\}\}/g, finalBundleId)
|
|
1334
|
+
const watchExtensionInfo = readFileSync2(join3(TEMPLATES_DIR, "WatchExtension.Info.plist.template"), "utf8").replace(/\{\{APP_NAME\}\}/g, xmlEscape(displayName)).replace(/\{\{BUNDLE_ID\}\}/g, finalBundleId).replace(/\{\{HEALTH_USAGE_DESCRIPTIONS\}\}/g, config.enableHealthKit ? `${plistString("NSHealthShareUsageDescription", `${displayName} reads health data you choose to share.`)}
|
|
1335
|
+
${plistString("NSHealthUpdateUsageDescription", `${displayName} writes health data only with your permission.`)}` : "");
|
|
1332
1336
|
writeFileSync2(join3(output, "WatchExtension", "Info.plist"), watchExtensionInfo);
|
|
1333
1337
|
writeFileSync2(join3(output, "WatchExtension", "Watch.entitlements"), renderWatchEntitlements(config));
|
|
1334
1338
|
}
|
package/package.json
CHANGED
|
@@ -357,6 +357,7 @@ final class CraftHybrid: NSObject {
|
|
|
357
357
|
isConfigured = true
|
|
358
358
|
self.config = config
|
|
359
359
|
CraftNativeScriptThread.isBackground = config.nativeScriptThread == "background"
|
|
360
|
+
if CraftNativeScriptThread.isBackground { CraftNativeBundle.prewarm() }
|
|
360
361
|
hasWeb = config.renderer != "native"
|
|
361
362
|
siteURL = config.devServerURL.flatMap(URL.init(string:)).flatMap { url in
|
|
362
363
|
["http", "https"].contains((url.scheme ?? "").lowercased()) ? url : nil
|
|
@@ -2295,13 +2296,15 @@ final class CraftFramePacing: NSObject {
|
|
|
2295
2296
|
}
|
|
2296
2297
|
}
|
|
2297
2298
|
|
|
2298
|
-
/// One
|
|
2299
|
+
/// One block, timed: a screen mount, a bundle evaluation. Labelled by the
|
|
2300
|
+
/// thread it ran on, since a screen's script now runs off the main thread
|
|
2301
|
+
/// and a bundle's time there costs no frames.
|
|
2299
2302
|
@discardableResult
|
|
2300
2303
|
func measure<T>(_ name: String, _ work: () -> T) -> T {
|
|
2301
2304
|
guard enabled else { return work() }
|
|
2302
2305
|
let started = CACurrentMediaTime()
|
|
2303
2306
|
let result = work()
|
|
2304
|
-
write(String(format: "%@
|
|
2307
|
+
write(String(format: "%@ %@=%.1fms", name, Thread.isMainThread ? "main" : "script", (CACurrentMediaTime() - started) * 1000))
|
|
2305
2308
|
return result
|
|
2306
2309
|
}
|
|
2307
2310
|
|
|
@@ -253,6 +253,12 @@ enum CraftNativeActions {
|
|
|
253
253
|
CraftNativeFiles.pickVideo(options) { complete(requestToken, $0, completion) }
|
|
254
254
|
}
|
|
255
255
|
return
|
|
256
|
+
case ("Files", "pickFile"):
|
|
257
|
+
let options = args.first as? [String: Any] ?? [:]
|
|
258
|
+
DispatchQueue.main.async {
|
|
259
|
+
CraftNativeFiles.pickFile(options) { complete(requestToken, $0, completion) }
|
|
260
|
+
}
|
|
261
|
+
return
|
|
256
262
|
case ("Files", "download"):
|
|
257
263
|
download(requestToken: requestToken, args: args, completion: completion)
|
|
258
264
|
return
|
|
@@ -328,6 +328,142 @@ enum CraftNativeFiles {
|
|
|
328
328
|
}
|
|
329
329
|
}
|
|
330
330
|
|
|
331
|
+
// MARK: Picking a general file
|
|
332
|
+
|
|
333
|
+
/// Pick one or more documents and copy them into app-owned storage while
|
|
334
|
+
/// the security-scoped URLs are valid. `contentTypes` accepts MIME types,
|
|
335
|
+
/// UTIs, or filename extensions; an empty list means any document.
|
|
336
|
+
static func pickFile(_ options: [String: Any], done: @escaping (Result<Any, CraftNativeActionError>) -> Void) {
|
|
337
|
+
guard activePicker == nil else {
|
|
338
|
+
done(.failure(CraftNativeActionError(code: "BUSY", message: "A picker is already open")))
|
|
339
|
+
return
|
|
340
|
+
}
|
|
341
|
+
let limit = craftNativeNumber(options["maxBytes"])
|
|
342
|
+
.map { $0.doubleValue }
|
|
343
|
+
.flatMap { value in
|
|
344
|
+
guard value.isFinite else { return nil }
|
|
345
|
+
return Int(max(1, min(Double(512 * 1024 * 1024), value)))
|
|
346
|
+
} ?? 512 * 1024 * 1024
|
|
347
|
+
let selectionLimit = craftNativeNumber(options["maxSelections"])
|
|
348
|
+
.map { $0.intValue }
|
|
349
|
+
.map { max(1, min(20, $0)) } ?? 1
|
|
350
|
+
let types: [UTType]
|
|
351
|
+
do {
|
|
352
|
+
types = try documentTypes(options["contentTypes"])
|
|
353
|
+
} catch let error as CraftNativeActionError {
|
|
354
|
+
done(.failure(error))
|
|
355
|
+
return
|
|
356
|
+
} catch {
|
|
357
|
+
done(.failure(CraftNativeActionError(code: "INVALID_ARGUMENT", message: "files.pickFile contentTypes are invalid")))
|
|
358
|
+
return
|
|
359
|
+
}
|
|
360
|
+
let finish: (Result<Any, CraftNativeActionError>) -> Void = { result in
|
|
361
|
+
DispatchQueue.main.async { activePicker = nil; done(result) }
|
|
362
|
+
}
|
|
363
|
+
let delegate = FileDocumentDelegate(limit: limit, done: finish)
|
|
364
|
+
let picker = UIDocumentPickerViewController(forOpeningContentTypes: types, asCopy: true)
|
|
365
|
+
picker.allowsMultipleSelection = selectionLimit > 1
|
|
366
|
+
picker.delegate = delegate
|
|
367
|
+
activePicker = delegate
|
|
368
|
+
if !CraftPresenter.present(picker, from: nil) {
|
|
369
|
+
activePicker = nil
|
|
370
|
+
done(.failure(CraftNativeActionError(code: "PICKER_ERROR", message: "Unable to present the file picker")))
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/// Resolve an app-supplied content filter without silently broadening it.
|
|
375
|
+
/// A MIME type, UTI, or extension is enough for `UTType`; applications can
|
|
376
|
+
/// therefore ask for `application/pdf`, `com.adobe.pdf`, or `pdf` alike.
|
|
377
|
+
private static func documentTypes(_ value: Any?) throws -> [UTType] {
|
|
378
|
+
guard let value else { return [.item] }
|
|
379
|
+
guard let entries = value as? [Any] else {
|
|
380
|
+
throw CraftNativeActionError(code: "INVALID_ARGUMENT", message: "files.pickFile contentTypes must be an array")
|
|
381
|
+
}
|
|
382
|
+
guard !entries.isEmpty else { return [.item] }
|
|
383
|
+
var types: [UTType] = []
|
|
384
|
+
for entry in entries {
|
|
385
|
+
guard let raw = entry as? String else {
|
|
386
|
+
throw CraftNativeActionError(code: "INVALID_ARGUMENT", message: "files.pickFile contentTypes must be strings")
|
|
387
|
+
}
|
|
388
|
+
let name = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
389
|
+
guard !name.isEmpty,
|
|
390
|
+
let type = UTType(mimeType: name) ?? UTType(name) ?? UTType(filenameExtension: name.trimmingCharacters(in: CharacterSet(charactersIn: "."))) else {
|
|
391
|
+
throw CraftNativeActionError(code: "INVALID_ARGUMENT", message: "files.pickFile contentType is not a MIME type, UTI, or extension: \(raw)")
|
|
392
|
+
}
|
|
393
|
+
types.append(type)
|
|
394
|
+
}
|
|
395
|
+
return types
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/// Copy an arbitrary selected document into Craft's private file area and
|
|
399
|
+
/// return the same descriptor used by downloads, writes, and uploads.
|
|
400
|
+
static func copyFile(_ source: URL, limit: Int) -> Result<Any, CraftNativeActionError> {
|
|
401
|
+
do {
|
|
402
|
+
let values = try source.resourceValues(forKeys: [.fileSizeKey, .contentTypeKey])
|
|
403
|
+
let size: Int
|
|
404
|
+
if let fileSize = values.fileSize {
|
|
405
|
+
size = fileSize
|
|
406
|
+
} else {
|
|
407
|
+
size = ((try? FileManager.default.attributesOfItem(atPath: source.path))?[.size] as? NSNumber)?.intValue ?? 0
|
|
408
|
+
}
|
|
409
|
+
guard size > 0, size <= limit else {
|
|
410
|
+
return .failure(CraftNativeActionError(code: "FILE_TOO_LARGE", message: "Choose a nonempty file under \(limit / 1024 / 1024)MB."))
|
|
411
|
+
}
|
|
412
|
+
let destination = try newFile(named: source.lastPathComponent)
|
|
413
|
+
do { try FileManager.default.copyItem(at: source, to: destination) }
|
|
414
|
+
catch { try? FileManager.default.removeItem(at: destination.deletingLastPathComponent()); throw error }
|
|
415
|
+
let type = mimeType(for: source, contentType: values.contentType)
|
|
416
|
+
return .success(describe(destination, type: type))
|
|
417
|
+
} catch {
|
|
418
|
+
return .failure(CraftNativeActionError(code: "READ_ERROR", message: error.localizedDescription))
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
private static func mimeType(for source: URL, contentType: UTType?) -> String {
|
|
423
|
+
let generic = "application/octet-stream"
|
|
424
|
+
if let preferred = contentType?.preferredMIMEType, preferred != generic { return preferred }
|
|
425
|
+
if let preferred = UTType(filenameExtension: source.pathExtension)?.preferredMIMEType, preferred != generic {
|
|
426
|
+
return preferred
|
|
427
|
+
}
|
|
428
|
+
switch source.pathExtension.lowercased() {
|
|
429
|
+
case "gpx": return "application/gpx+xml"
|
|
430
|
+
case "tcx": return "application/vnd.garmin.tcx+xml"
|
|
431
|
+
case "fit": return "application/vnd.ant.fit"
|
|
432
|
+
case "csv": return "text/csv"
|
|
433
|
+
case "json": return "application/json"
|
|
434
|
+
case "txt": return "text/plain"
|
|
435
|
+
default: return generic
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
private final class FileDocumentDelegate: NSObject, UIDocumentPickerDelegate {
|
|
440
|
+
let limit: Int
|
|
441
|
+
let done: (Result<Any, CraftNativeActionError>) -> Void
|
|
442
|
+
init(limit: Int, done: @escaping (Result<Any, CraftNativeActionError>) -> Void) { self.limit = limit; self.done = done }
|
|
443
|
+
func documentPickerWasCancelled(_ controller: UIDocumentPickerViewController) { done(.success([])) }
|
|
444
|
+
func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) {
|
|
445
|
+
guard !urls.isEmpty else { done(.success([])); return }
|
|
446
|
+
let (limit, done) = (self.limit, self.done)
|
|
447
|
+
DispatchQueue.global(qos: .userInitiated).async {
|
|
448
|
+
var results: [[String: Any]] = []
|
|
449
|
+
for url in urls {
|
|
450
|
+
let granted = url.startAccessingSecurityScopedResource()
|
|
451
|
+
defer { if granted { url.stopAccessingSecurityScopedResource() } }
|
|
452
|
+
switch CraftNativeFiles.copyFile(url, limit: limit) {
|
|
453
|
+
case .success(let value):
|
|
454
|
+
guard let info = value as? [String: Any] else { done(.failure(CraftNativeActionError(code: "READ_ERROR", message: "The selected file could not be described"))); return }
|
|
455
|
+
results.append(info)
|
|
456
|
+
case .failure(let error):
|
|
457
|
+
results.compactMap { $0["path"] as? String }.forEach { CraftNativeFiles.remove($0) }
|
|
458
|
+
done(.failure(error))
|
|
459
|
+
return
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
done(.success(results))
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
|
|
331
467
|
static func copyVideo(_ source: URL, limit: Int) -> Result<Any, CraftNativeActionError> {
|
|
332
468
|
let ext = source.pathExtension.lowercased()
|
|
333
469
|
let types = ["mp4": "video/mp4", "m4v": "video/x-m4v", "mov": "video/quicktime", "webm": "video/webm"]
|
|
@@ -115,6 +115,9 @@ final class CraftNativeMutationDocument {
|
|
|
115
115
|
// A child crossing parents needs the committed tree to be
|
|
116
116
|
// reconciled so the host can move its existing native view.
|
|
117
117
|
requiresFullRender = true
|
|
118
|
+
case "replaceChildren":
|
|
119
|
+
let parentId = try replaceChildren(operation, nodes: &candidateNodes)
|
|
120
|
+
appendUnique(parentId, to: &affectedNodeIds)
|
|
118
121
|
case "removeNode":
|
|
119
122
|
if let id = operation["id"] as? String, let parent = candidateNodes[id]?.parent {
|
|
120
123
|
appendUnique(parent, to: &affectedNodeIds)
|
|
@@ -287,6 +290,54 @@ final class CraftNativeMutationDocument {
|
|
|
287
290
|
return previousParentId
|
|
288
291
|
}
|
|
289
292
|
|
|
293
|
+
/// Replaces a container's ordered child snapshot in one atomic mutation.
|
|
294
|
+
///
|
|
295
|
+
/// The list protocol uses this operation when a keyed `:for` expansion
|
|
296
|
+
/// produces a new row set. Existing nodes may stay attached to the same
|
|
297
|
+
/// parent; removed children become detached and can be reused or removed
|
|
298
|
+
/// by later operations in the same batch.
|
|
299
|
+
private func replaceChildren(_ operation: [String: Any], nodes: inout [String: Node]) throws -> String {
|
|
300
|
+
let parentId = try requiredId(operation, field: "parentId", name: "replaceChildren")
|
|
301
|
+
guard var parent = nodes[parentId] else {
|
|
302
|
+
throw CraftNativeMutationFailure("UNKNOWN_PARENT", "node \(parentId) does not exist")
|
|
303
|
+
}
|
|
304
|
+
guard acceptsChildren(parent.type) else {
|
|
305
|
+
throw CraftNativeMutationFailure("INVALID_PARENT", "node \(parentId) cannot contain child nodes")
|
|
306
|
+
}
|
|
307
|
+
guard let values = operation["children"] as? [Any] else {
|
|
308
|
+
throw CraftNativeMutationFailure("INVALID_CHILDREN", "replaceChildren.children must be an array")
|
|
309
|
+
}
|
|
310
|
+
var childIds: [String] = []
|
|
311
|
+
for value in values {
|
|
312
|
+
guard let childId = value as? String, !childId.isEmpty else {
|
|
313
|
+
throw CraftNativeMutationFailure("INVALID_CHILDREN", "replaceChildren.children must contain non-empty ids")
|
|
314
|
+
}
|
|
315
|
+
guard !childIds.contains(childId) else {
|
|
316
|
+
throw CraftNativeMutationFailure("DUPLICATE_CHILD", "child \(childId) appears more than once")
|
|
317
|
+
}
|
|
318
|
+
guard let child = nodes[childId] else {
|
|
319
|
+
throw CraftNativeMutationFailure("UNKNOWN_NODE", "node \(childId) does not exist")
|
|
320
|
+
}
|
|
321
|
+
guard childId != parentId, !descendants(of: childId, nodes: nodes).contains(parentId) else {
|
|
322
|
+
throw CraftNativeMutationFailure("CYCLE", "attaching node \(childId) would create a cycle")
|
|
323
|
+
}
|
|
324
|
+
guard child.parent == nil || child.parent == parentId else {
|
|
325
|
+
throw CraftNativeMutationFailure("NODE_ATTACHED", "node \(childId) already has a parent")
|
|
326
|
+
}
|
|
327
|
+
childIds.append(childId)
|
|
328
|
+
}
|
|
329
|
+
let removedChildIds = parent.children.filter { !childIds.contains($0) }
|
|
330
|
+
for oldChildId in removedChildIds {
|
|
331
|
+
for descendant in descendants(of: oldChildId, nodes: nodes).union([oldChildId]) {
|
|
332
|
+
nodes.removeValue(forKey: descendant)
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
parent.children = childIds
|
|
336
|
+
nodes[parentId] = parent
|
|
337
|
+
for childId in childIds { nodes[childId]?.parent = parentId }
|
|
338
|
+
return parentId
|
|
339
|
+
}
|
|
340
|
+
|
|
290
341
|
private func removeNode(
|
|
291
342
|
_ operation: [String: Any],
|
|
292
343
|
nodes: inout [String: Node],
|
|
@@ -1345,6 +1345,64 @@ private final class CraftNativeVideoView: UIView, AVPlayerViewControllerDelegate
|
|
|
1345
1345
|
deinit { stop() }
|
|
1346
1346
|
}
|
|
1347
1347
|
|
|
1348
|
+
/// A map pin that answers being activated as it answers a tap.
|
|
1349
|
+
final class CraftMarkerAnnotationView: MKAnnotationView {
|
|
1350
|
+
var onActivate: (() -> Void)?
|
|
1351
|
+
|
|
1352
|
+
override func accessibilityActivate() -> Bool {
|
|
1353
|
+
guard let onActivate else { return super.accessibilityActivate() }
|
|
1354
|
+
onActivate()
|
|
1355
|
+
return true
|
|
1356
|
+
}
|
|
1357
|
+
|
|
1358
|
+
override func prepareForReuse() {
|
|
1359
|
+
super.prepareForReuse()
|
|
1360
|
+
onActivate = nil
|
|
1361
|
+
}
|
|
1362
|
+
}
|
|
1363
|
+
|
|
1364
|
+
/// A tile overlay whose tiles are kept on disk.
|
|
1365
|
+
///
|
|
1366
|
+
/// MKTileOverlay asked the network for every tile on every pan back over the
|
|
1367
|
+
/// same ground, and drew nothing where a tile had not come yet. These go
|
|
1368
|
+
/// through one session with a cache of its own (Caches/craft-map-tiles), so
|
|
1369
|
+
/// ground seen before draws at once and offline, and a phone on a trail
|
|
1370
|
+
/// does not fetch the same hillside twice.
|
|
1371
|
+
final class CraftCachedTileOverlay: MKTileOverlay {
|
|
1372
|
+
static let session: URLSession = {
|
|
1373
|
+
let configuration = URLSessionConfiguration.default
|
|
1374
|
+
let directory = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first?.appendingPathComponent("craft-map-tiles", isDirectory: true)
|
|
1375
|
+
configuration.urlCache = URLCache(memoryCapacity: 48 << 20, diskCapacity: 512 << 20, directory: directory)
|
|
1376
|
+
// Topography and imagery change on the scale of months: a tile held
|
|
1377
|
+
// is used without asking again.
|
|
1378
|
+
configuration.requestCachePolicy = .returnCacheDataElseLoad
|
|
1379
|
+
configuration.httpMaximumConnectionsPerHost = 8
|
|
1380
|
+
return URLSession(configuration: configuration)
|
|
1381
|
+
}()
|
|
1382
|
+
|
|
1383
|
+
override func loadTile(at path: MKTileOverlayPath, result: @escaping (Data?, Error?) -> Void) {
|
|
1384
|
+
let request = URLRequest(url: url(forTilePath: path), cachePolicy: .returnCacheDataElseLoad, timeoutInterval: 20)
|
|
1385
|
+
Self.session.dataTask(with: request) { data, response, error in
|
|
1386
|
+
if let status = (response as? HTTPURLResponse)?.statusCode, !(200..<300).contains(status) {
|
|
1387
|
+
result(nil, error ?? URLError(.badServerResponse))
|
|
1388
|
+
return
|
|
1389
|
+
}
|
|
1390
|
+
result(data, error)
|
|
1391
|
+
}.resume()
|
|
1392
|
+
}
|
|
1393
|
+
}
|
|
1394
|
+
|
|
1395
|
+
/// The basemaps a `Map` names with `basemap`, drawn the best way each
|
|
1396
|
+
/// platform can: Apple's vector map (with real terrain from iOS 16) for
|
|
1397
|
+
/// standard, imagery and hybrid, and topography with contours over a muted
|
|
1398
|
+
/// Apple map for outdoors.
|
|
1399
|
+
enum CraftBasemap: String {
|
|
1400
|
+
case standard, outdoors, satellite, hybrid
|
|
1401
|
+
|
|
1402
|
+
static let outdoorTiles = "https://server.arcgisonline.com/ArcGIS/rest/services/World_Topo_Map/MapServer/tile/{z}/{y}/{x}"
|
|
1403
|
+
static let outdoorCredit = "Topography: Esri, USGS, NOAA, OpenStreetMap contributors"
|
|
1404
|
+
}
|
|
1405
|
+
|
|
1348
1406
|
private final class CraftNativeMapView: MKMapView, MKMapViewDelegate {
|
|
1349
1407
|
private var annotationsByID: [String: MKPointAnnotation] = [:]
|
|
1350
1408
|
private var overlaysByID: [String: MKPolyline] = [:]
|
|
@@ -1371,6 +1429,8 @@ private final class CraftNativeMapView: MKMapView, MKMapViewDelegate {
|
|
|
1371
1429
|
private var markerPinColors: [String: UIColor] = [:]
|
|
1372
1430
|
private var tileOverlay: MKTileOverlay?
|
|
1373
1431
|
private var tileTemplate: String?
|
|
1432
|
+
private var tileReplacesMap = true
|
|
1433
|
+
private var appliedBasemap: CraftBasemap?
|
|
1374
1434
|
private var tileAttributionBottom: NSLayoutConstraint?
|
|
1375
1435
|
private lazy var tileAttribution: UILabel = {
|
|
1376
1436
|
let label = UILabel()
|
|
@@ -1395,6 +1455,10 @@ private final class CraftNativeMapView: MKMapView, MKMapViewDelegate {
|
|
|
1395
1455
|
isRotateEnabled = false
|
|
1396
1456
|
let tap = UITapGestureRecognizer(target: self, action: #selector(pressedMap(_:)))
|
|
1397
1457
|
tap.cancelsTouchesInView = false
|
|
1458
|
+
// Alongside MapKit's own recognizers, not instead of them: on its own
|
|
1459
|
+
// this tap won the touch, MapKit never selected the pin under it, and
|
|
1460
|
+
// a pin's onPress (Explore's trail card) never fired on a phone.
|
|
1461
|
+
tap.delegate = CraftMapTapDelegate.shared
|
|
1398
1462
|
addGestureRecognizer(tap)
|
|
1399
1463
|
}
|
|
1400
1464
|
|
|
@@ -1429,8 +1493,13 @@ private final class CraftNativeMapView: MKMapView, MKMapViewDelegate {
|
|
|
1429
1493
|
isZoomEnabled = props["zoomEnabled"] as? Bool ?? true
|
|
1430
1494
|
isRotateEnabled = props["rotateEnabled"] as? Bool ?? false
|
|
1431
1495
|
isPitchEnabled = props["pitchEnabled"] as? Bool ?? true
|
|
1432
|
-
|
|
1433
|
-
|
|
1496
|
+
if let basemap = (props["basemap"] as? String).flatMap({ CraftBasemap(rawValue: $0.lowercased()) }) {
|
|
1497
|
+
applyBasemap(basemap)
|
|
1498
|
+
} else {
|
|
1499
|
+
appliedBasemap = nil
|
|
1500
|
+
mapType = Self.mapType(props["mapType"] as? String)
|
|
1501
|
+
configureTiles(props["tileOverlay"] as? [String: Any])
|
|
1502
|
+
}
|
|
1434
1503
|
showsCompass = props["showsCompass"] as? Bool ?? false
|
|
1435
1504
|
showsScale = props["showsScale"] as? Bool ?? false
|
|
1436
1505
|
authoredPitch = Self.numeric(props["cameraPitch"])
|
|
@@ -1562,7 +1631,9 @@ private final class CraftNativeMapView: MKMapView, MKMapViewDelegate {
|
|
|
1562
1631
|
overlayCoordinatesByID[id] = value.coordinates
|
|
1563
1632
|
overlayColors[ObjectIdentifier(overlay)] = value.color
|
|
1564
1633
|
overlayWidths[ObjectIdentifier(overlay)] = value.width
|
|
1565
|
-
|
|
1634
|
+
// Above labels, as a basemap's topography is: at a lower level
|
|
1635
|
+
// the outdoors layer covered every route drawn on it.
|
|
1636
|
+
addOverlay(overlay, level: .aboveLabels)
|
|
1566
1637
|
}
|
|
1567
1638
|
let stalePolygons = polygonsByID.keys.filter { nextPolygons[$0] == nil }
|
|
1568
1639
|
for id in stalePolygons {
|
|
@@ -1595,7 +1666,7 @@ private final class CraftNativeMapView: MKMapView, MKMapViewDelegate {
|
|
|
1595
1666
|
polygonFills[ObjectIdentifier(polygon)] = value.fill
|
|
1596
1667
|
polygonStrokes[ObjectIdentifier(polygon)] = value.stroke
|
|
1597
1668
|
polygonWidths[ObjectIdentifier(polygon)] = value.width
|
|
1598
|
-
addOverlay(polygon)
|
|
1669
|
+
addOverlay(polygon, level: .aboveLabels)
|
|
1599
1670
|
}
|
|
1600
1671
|
// Keep authored casing/foreground order when an earlier line changes
|
|
1601
1672
|
// or retained children move. Exchanging native overlays preserves the
|
|
@@ -1684,20 +1755,48 @@ private final class CraftNativeMapView: MKMapView, MKMapViewDelegate {
|
|
|
1684
1755
|
tileAttribution.isHidden = true
|
|
1685
1756
|
}
|
|
1686
1757
|
|
|
1687
|
-
|
|
1758
|
+
/// A named basemap. Applied once per change: setting a map configuration
|
|
1759
|
+
/// again redraws the whole map.
|
|
1760
|
+
private func applyBasemap(_ basemap: CraftBasemap) {
|
|
1761
|
+
if appliedBasemap != basemap {
|
|
1762
|
+
appliedBasemap = basemap
|
|
1763
|
+
if #available(iOS 16.0, *) {
|
|
1764
|
+
switch basemap {
|
|
1765
|
+
case .satellite: preferredConfiguration = MKImageryMapConfiguration(elevationStyle: .realistic)
|
|
1766
|
+
case .hybrid: preferredConfiguration = MKHybridMapConfiguration(elevationStyle: .realistic)
|
|
1767
|
+
// Flat under the topography, so the overlay's contours sit
|
|
1768
|
+
// on the ground they describe.
|
|
1769
|
+
case .outdoors: preferredConfiguration = MKStandardMapConfiguration(elevationStyle: .flat, emphasisStyle: .muted)
|
|
1770
|
+
case .standard: preferredConfiguration = MKStandardMapConfiguration(elevationStyle: .realistic)
|
|
1771
|
+
}
|
|
1772
|
+
}
|
|
1773
|
+
else {
|
|
1774
|
+
mapType = basemap == .satellite ? .satellite : basemap == .hybrid ? .hybrid : basemap == .outdoors ? .mutedStandard : .standard
|
|
1775
|
+
}
|
|
1776
|
+
}
|
|
1777
|
+
configureTiles(basemap == .outdoors ? ["urlTemplate": CraftBasemap.outdoorTiles, "attribution": CraftBasemap.outdoorCredit] : nil, replacesMap: false)
|
|
1778
|
+
}
|
|
1779
|
+
|
|
1780
|
+
/// `replacesMap`: an authored `tileOverlay` replaces Apple's map, as it
|
|
1781
|
+
/// always has. A basemap's topography draws over it instead, above its
|
|
1782
|
+
/// labels, so ground whose tiles have not come yet shows the map rather
|
|
1783
|
+
/// than an empty grid.
|
|
1784
|
+
private func configureTiles(_ options: [String: Any]?, replacesMap: Bool = true) {
|
|
1688
1785
|
let template = options?["urlTemplate"] as? String
|
|
1689
1786
|
let credit = options?["attribution"] as? String ?? ""
|
|
1690
1787
|
let valid = template.map { value in value.hasPrefix("https://") && ["{z}", "{x}", "{y}"].allSatisfy { value.contains($0) } } ?? false
|
|
1691
1788
|
let next = valid && !credit.isEmpty ? template : nil
|
|
1692
|
-
if tileTemplate != next {
|
|
1789
|
+
if tileTemplate != next || tileReplacesMap != replacesMap {
|
|
1693
1790
|
if let overlay = tileOverlay { removeOverlay(overlay) }
|
|
1694
1791
|
tileOverlay = nil
|
|
1695
1792
|
tileTemplate = next
|
|
1793
|
+
tileReplacesMap = replacesMap
|
|
1696
1794
|
if let next {
|
|
1697
|
-
let overlay =
|
|
1698
|
-
overlay.canReplaceMapContent =
|
|
1795
|
+
let overlay = CraftCachedTileOverlay(urlTemplate: next)
|
|
1796
|
+
overlay.canReplaceMapContent = replacesMap
|
|
1797
|
+
overlay.maximumZ = 19
|
|
1699
1798
|
tileOverlay = overlay
|
|
1700
|
-
insertOverlay(overlay, at: 0, level: .aboveRoads)
|
|
1799
|
+
insertOverlay(overlay, at: 0, level: replacesMap ? .aboveRoads : .aboveLabels)
|
|
1701
1800
|
}
|
|
1702
1801
|
}
|
|
1703
1802
|
tileAttribution.text = next == nil ? nil : credit
|
|
@@ -1756,11 +1855,27 @@ private final class CraftNativeMapView: MKMapView, MKMapViewDelegate {
|
|
|
1756
1855
|
view.tintColor = color
|
|
1757
1856
|
view.image = Self.markerImage(color: color, direction: true)
|
|
1758
1857
|
view.transform = CGAffineTransform(rotationAngle: CGFloat((bearing - camera.heading) * .pi / 180))
|
|
1858
|
+
// Direction marks thin themselves: MapKit hides the ones that
|
|
1859
|
+
// would overlap at this zoom and brings them back closer in, so
|
|
1860
|
+
// a trail reads as a line with chevrons along it, not a column of
|
|
1861
|
+
// badges over the route.
|
|
1862
|
+
view.displayPriority = .defaultLow
|
|
1863
|
+
view.collisionMode = .circle
|
|
1864
|
+
// Decoration along the route: not a stop for VoiceOver.
|
|
1865
|
+
view.isAccessibilityElement = false
|
|
1759
1866
|
} else {
|
|
1760
1867
|
let color = (markerPinColors[id] ?? .systemRed).resolvedColor(with: traitCollection)
|
|
1761
1868
|
view.tintColor = color
|
|
1762
1869
|
view.image = Self.markerImage(color: color, direction: false)
|
|
1763
1870
|
view.transform = .identity
|
|
1871
|
+
view.displayPriority = .required
|
|
1872
|
+
view.collisionMode = .rectangle
|
|
1873
|
+
// Above the user's own location: at a trailhead - where the map
|
|
1874
|
+
// is most often opened - the dot sat on the trail's pin and took
|
|
1875
|
+
// every tap meant for it.
|
|
1876
|
+
if #available(iOS 14.0, *) { view.zPriority = .max }
|
|
1877
|
+
view.isAccessibilityElement = true
|
|
1878
|
+
view.accessibilityTraits = .button
|
|
1764
1879
|
}
|
|
1765
1880
|
}
|
|
1766
1881
|
|
|
@@ -1771,9 +1886,10 @@ private final class CraftNativeMapView: MKMapView, MKMapViewDelegate {
|
|
|
1771
1886
|
}()
|
|
1772
1887
|
|
|
1773
1888
|
private static func markerImage(color: UIColor, direction: Bool) -> UIImage {
|
|
1774
|
-
let key = "\(direction ? "
|
|
1889
|
+
let key = "\(direction ? "chevron" : "pin"):\(color.description)" as NSString
|
|
1775
1890
|
if let cached = markerImages.object(forKey: key) { return cached }
|
|
1776
|
-
|
|
1891
|
+
if direction { return directionImage(color: color, key: key) }
|
|
1892
|
+
let size = CGSize(width: 28, height: 28)
|
|
1777
1893
|
// Explicit native drawing preserves the light backing independently
|
|
1778
1894
|
// of MapKit's tint and SF Symbols' multicolor rendering preferences.
|
|
1779
1895
|
let image = UIGraphicsImageRenderer(size: size).image { _ in
|
|
@@ -1787,7 +1903,32 @@ private final class CraftNativeMapView: MKMapView, MKMapViewDelegate {
|
|
|
1787
1903
|
let symbol = UIImage(systemName: direction ? "arrow.up" : "mappin",
|
|
1788
1904
|
withConfiguration: UIImage.SymbolConfiguration(pointSize: 16, weight: .semibold))?
|
|
1789
1905
|
.withTintColor(color, renderingMode: .alwaysOriginal)
|
|
1790
|
-
symbol?.draw(in:
|
|
1906
|
+
symbol?.draw(in: CGRect(x: 7, y: 5, width: 14, height: 18))
|
|
1907
|
+
}.withRenderingMode(.alwaysOriginal)
|
|
1908
|
+
markerImages.setObject(image, forKey: key)
|
|
1909
|
+
return image
|
|
1910
|
+
}
|
|
1911
|
+
|
|
1912
|
+
/// A route's direction: a 16-point chevron in the route's colour with a
|
|
1913
|
+
/// white edge, so it sits on the line it describes and stays legible
|
|
1914
|
+
/// over topography and imagery alike.
|
|
1915
|
+
private static func directionImage(color: UIColor, key: NSString) -> UIImage {
|
|
1916
|
+
let size = CGSize(width: 16, height: 16)
|
|
1917
|
+
let image = UIGraphicsImageRenderer(size: size).image { context in
|
|
1918
|
+
let chevron = UIBezierPath()
|
|
1919
|
+
chevron.move(to: CGPoint(x: 8, y: 2.5))
|
|
1920
|
+
chevron.addLine(to: CGPoint(x: 13.5, y: 12))
|
|
1921
|
+
chevron.addLine(to: CGPoint(x: 8, y: 9.25))
|
|
1922
|
+
chevron.addLine(to: CGPoint(x: 2.5, y: 12))
|
|
1923
|
+
chevron.close()
|
|
1924
|
+
chevron.lineJoinStyle = .round
|
|
1925
|
+
context.cgContext.setShadow(offset: .zero, blur: 1.5, color: UIColor.black.withAlphaComponent(0.25).cgColor)
|
|
1926
|
+
UIColor.white.setStroke()
|
|
1927
|
+
chevron.lineWidth = 3
|
|
1928
|
+
chevron.stroke()
|
|
1929
|
+
context.cgContext.setShadow(offset: .zero, blur: 0, color: nil)
|
|
1930
|
+
color.setFill()
|
|
1931
|
+
chevron.fill()
|
|
1791
1932
|
}.withRenderingMode(.alwaysOriginal)
|
|
1792
1933
|
markerImages.setObject(image, forKey: key)
|
|
1793
1934
|
return image
|
|
@@ -1844,9 +1985,12 @@ private final class CraftNativeMapView: MKMapView, MKMapViewDelegate {
|
|
|
1844
1985
|
|
|
1845
1986
|
func mapView(_ mapView: MKMapView, viewFor annotation: MKAnnotation) -> MKAnnotationView? {
|
|
1846
1987
|
guard let id = annotationsByID.first(where: { $0.value === annotation })?.key else { return nil }
|
|
1847
|
-
let view = dequeueReusableAnnotationView(withIdentifier: "craft-native-marker")
|
|
1848
|
-
??
|
|
1988
|
+
let view = (dequeueReusableAnnotationView(withIdentifier: "craft-native-marker") as? CraftMarkerAnnotationView)
|
|
1989
|
+
?? CraftMarkerAnnotationView(annotation: annotation, reuseIdentifier: "craft-native-marker")
|
|
1849
1990
|
view.annotation = annotation
|
|
1991
|
+
// Activated (VoiceOver, Switch Control, a UI test) as a tap would:
|
|
1992
|
+
// the plain annotation view did nothing when activated.
|
|
1993
|
+
view.onActivate = { [weak self] in self?.onMarkerTap?(id) }
|
|
1850
1994
|
configureMarkerView(view, id: id)
|
|
1851
1995
|
view.accessibilityIdentifier = id
|
|
1852
1996
|
return view
|
|
@@ -3998,6 +4142,16 @@ private final class CraftNativeModalView: UIView, UIGestureRecognizerDelegate {
|
|
|
3998
4142
|
}
|
|
3999
4143
|
}
|
|
4000
4144
|
|
|
4145
|
+
/// The delegate of a map's own tap (`Map`'s onPress): it watches beside
|
|
4146
|
+
/// MapKit's recognizers, so a tap on a pin still selects it.
|
|
4147
|
+
final class CraftMapTapDelegate: NSObject, UIGestureRecognizerDelegate {
|
|
4148
|
+
static let shared = CraftMapTapDelegate()
|
|
4149
|
+
|
|
4150
|
+
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
|
|
4151
|
+
true
|
|
4152
|
+
}
|
|
4153
|
+
}
|
|
4154
|
+
|
|
4001
4155
|
/// The delegate of a Pressable's press-in/out feedback. That recognizer is
|
|
4002
4156
|
/// a long press of no duration, so it begins the moment a finger lands; on its
|
|
4003
4157
|
/// own it would claim the touch, and the tap that is the Pressable's onPress
|
|
@@ -5095,6 +5249,17 @@ enum CraftNativeBundle {
|
|
|
5095
5249
|
/// is not parsed again. Each screen keeps a global object of its own.
|
|
5096
5250
|
static let virtualMachine = JSVirtualMachine()!
|
|
5097
5251
|
|
|
5252
|
+
/// JavaScriptCore's first machine and context, made on the script thread
|
|
5253
|
+
/// at launch instead of on the main thread by the first screen: that one-
|
|
5254
|
+
/// time setup was a quarter of a second of the first tab's mount, all of
|
|
5255
|
+
/// it in front of the first frame. Nothing is evaluated, so the main
|
|
5256
|
+
/// thread never waits on the machine for it.
|
|
5257
|
+
static func prewarm() {
|
|
5258
|
+
CraftNativeScriptThread.queue.async {
|
|
5259
|
+
_ = JSContext(virtualMachine: virtualMachine)
|
|
5260
|
+
}
|
|
5261
|
+
}
|
|
5262
|
+
|
|
5098
5263
|
private static let sources: NSCache<NSString, NSString> = {
|
|
5099
5264
|
let cache = NSCache<NSString, NSString>()
|
|
5100
5265
|
// Room for the one-file bundle, or a dozen route files.
|
|
@@ -8649,8 +8814,12 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
8649
8814
|
/// Views that take their own touches whatever handlers the screen gives
|
|
8650
8815
|
/// them: native controls, a scroll view, a modal, and a video's controls (with
|
|
8651
8816
|
/// interaction off, its play, scrubber and full screen never answered).
|
|
8817
|
+
/// A map and a wheel picker too: neither is a UIControl, so a map with no
|
|
8818
|
+
/// long-press handler - every map - was made non-interactive, and could
|
|
8819
|
+
/// not be panned, zoomed, or have a pin tapped; a picker could not be spun.
|
|
8652
8820
|
private func ownsTouches(_ view: UIView) -> Bool {
|
|
8653
8821
|
view is UIControl || view is UIScrollView || view is CraftNativeModalView || view is CraftNativeVideoView
|
|
8822
|
+
|| view is MKMapView || view is UIPickerView
|
|
8654
8823
|
}
|
|
8655
8824
|
|
|
8656
8825
|
private func updatePressHandler(_ handler: String?, for view: UIView) {
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import SwiftUI
|
|
2
|
+
import HealthKit
|
|
2
3
|
import WatchConnectivity
|
|
3
4
|
|
|
4
5
|
@main
|
|
@@ -15,14 +16,28 @@ struct {{APP_NAME}}WatchApp: App {
|
|
|
15
16
|
|
|
16
17
|
@MainActor
|
|
17
18
|
final class CraftWatchSession: NSObject, ObservableObject {
|
|
19
|
+
private static let protocolVersion = 1
|
|
20
|
+
private static let persistedStateKey = "craft.watch.state.v1"
|
|
21
|
+
private static let healthKitEnabled = {{HEALTH_KIT_ENABLED}}
|
|
22
|
+
|
|
18
23
|
@Published private(set) var isPhoneReachable = false
|
|
19
24
|
@Published private(set) var isRecording = false
|
|
20
25
|
@Published private(set) var isPaused = false
|
|
21
26
|
@Published private(set) var distanceMeters = 0.0
|
|
22
27
|
@Published private(set) var durationSeconds = 0.0
|
|
23
28
|
@Published private(set) var status = "Ready"
|
|
29
|
+
@Published private(set) var plannedSession: [String: Any]?
|
|
30
|
+
@Published private(set) var heartRateBpm: Double?
|
|
31
|
+
@Published private(set) var heartRateZone: Int?
|
|
32
|
+
@Published private(set) var lastWorkout: [String: Any]?
|
|
24
33
|
|
|
25
34
|
private let session: WCSession?
|
|
35
|
+
private let healthStore = HKHealthStore()
|
|
36
|
+
private var stateVersion = 0
|
|
37
|
+
private var workoutSession: HKWorkoutSession?
|
|
38
|
+
private var workoutBuilder: HKLiveWorkoutBuilder?
|
|
39
|
+
private var workoutId: String?
|
|
40
|
+
private var workoutStartedAt: Date?
|
|
26
41
|
|
|
27
42
|
override init() {
|
|
28
43
|
if WCSession.isSupported() {
|
|
@@ -32,49 +47,320 @@ final class CraftWatchSession: NSObject, ObservableObject {
|
|
|
32
47
|
}
|
|
33
48
|
super.init()
|
|
34
49
|
session?.delegate = self
|
|
50
|
+
restoreState()
|
|
35
51
|
session?.activate()
|
|
36
52
|
}
|
|
37
53
|
|
|
38
54
|
func toggleRecording() {
|
|
39
|
-
let action: String
|
|
40
55
|
if !isRecording {
|
|
41
|
-
|
|
56
|
+
if Self.healthKitEnabled {
|
|
57
|
+
startHealthWorkout { [weak self] started in
|
|
58
|
+
if started { self?.send(action: "start") }
|
|
59
|
+
}
|
|
60
|
+
} else {
|
|
61
|
+
send(action: "start")
|
|
62
|
+
}
|
|
42
63
|
} else if isPaused {
|
|
43
|
-
|
|
64
|
+
resumeHealthWorkout()
|
|
65
|
+
send(action: "resume")
|
|
44
66
|
} else {
|
|
45
|
-
|
|
67
|
+
pauseHealthWorkout()
|
|
68
|
+
send(action: "pause")
|
|
46
69
|
}
|
|
47
|
-
send(action: action)
|
|
48
70
|
}
|
|
49
71
|
|
|
50
72
|
func finishRecording() {
|
|
51
|
-
|
|
73
|
+
if Self.healthKitEnabled, workoutSession != nil {
|
|
74
|
+
endHealthWorkout { [weak self] _ in self?.send(action: "finish") }
|
|
75
|
+
} else {
|
|
76
|
+
send(action: "finish")
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/// The phone sends a planned session through application context. Context
|
|
81
|
+
/// is the right transport here: it replaces stale plans while the watch is
|
|
82
|
+
/// offline and is replayed when the app launches again.
|
|
83
|
+
func clearPlannedSession() {
|
|
84
|
+
plannedSession = nil
|
|
85
|
+
persistState()
|
|
86
|
+
publishState()
|
|
52
87
|
}
|
|
53
88
|
|
|
54
89
|
private func send(action: String) {
|
|
55
90
|
guard let session, session.activationState == .activated else {
|
|
56
91
|
status = "Open WildLoop on iPhone"
|
|
92
|
+
persistState()
|
|
57
93
|
return
|
|
58
94
|
}
|
|
59
|
-
let message: [String: Any] = [
|
|
95
|
+
let message: [String: Any] = [
|
|
96
|
+
"protocolVersion": Self.protocolVersion,
|
|
97
|
+
"requestId": UUID().uuidString,
|
|
98
|
+
"type": "recording-control",
|
|
99
|
+
"action": action,
|
|
100
|
+
]
|
|
60
101
|
if session.isReachable {
|
|
61
102
|
session.sendMessage(message, replyHandler: { [weak self] reply in
|
|
62
103
|
Task { @MainActor in self?.apply(reply) }
|
|
63
104
|
}, errorHandler: { [weak self] _ in
|
|
64
|
-
Task { @MainActor in
|
|
105
|
+
Task { @MainActor in
|
|
106
|
+
self?.status = "Could not reach iPhone"
|
|
107
|
+
self?.persistState()
|
|
108
|
+
self?.publishState()
|
|
109
|
+
}
|
|
65
110
|
})
|
|
66
111
|
} else {
|
|
67
112
|
session.transferUserInfo(message)
|
|
68
113
|
status = "Command queued"
|
|
114
|
+
persistState()
|
|
115
|
+
publishState()
|
|
69
116
|
}
|
|
70
117
|
}
|
|
71
118
|
|
|
72
119
|
private func apply(_ payload: [String: Any]) {
|
|
120
|
+
if let plan = payload["plan"] as? [String: Any], payload["type"] as? String == "planned-session" {
|
|
121
|
+
plannedSession = plan
|
|
122
|
+
status = "Plan ready"
|
|
123
|
+
} else if let plan = payload["plannedSession"] as? [String: Any] {
|
|
124
|
+
plannedSession = plan
|
|
125
|
+
}
|
|
73
126
|
if let recording = payload["recording"] as? Bool { isRecording = recording }
|
|
74
127
|
if let paused = payload["paused"] as? Bool { isPaused = paused }
|
|
75
128
|
if let distance = payload["distanceMeters"] as? Double { distanceMeters = max(distance, 0) }
|
|
76
129
|
if let duration = payload["durationSeconds"] as? Double { durationSeconds = max(duration, 0) }
|
|
77
130
|
if let value = payload["status"] as? String { status = value }
|
|
131
|
+
if let heartRate = payload["heartRateBpm"] as? Double, heartRate.isFinite { heartRateBpm = max(heartRate, 0) }
|
|
132
|
+
if let zone = payload["heartRateZone"] as? Int { heartRateZone = min(max(zone, 1), 5) }
|
|
133
|
+
if let workout = payload["workout"] as? [String: Any] { lastWorkout = workout }
|
|
134
|
+
persistState()
|
|
135
|
+
publishState()
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
private func restoreState() {
|
|
139
|
+
guard let state = UserDefaults.standard.dictionary(forKey: Self.persistedStateKey) else { return }
|
|
140
|
+
if let recording = state["recording"] as? Bool { isRecording = recording }
|
|
141
|
+
if let paused = state["paused"] as? Bool { isPaused = paused }
|
|
142
|
+
if let distance = state["distanceMeters"] as? Double { distanceMeters = max(distance, 0) }
|
|
143
|
+
if let duration = state["durationSeconds"] as? Double { durationSeconds = max(duration, 0) }
|
|
144
|
+
if let value = state["status"] as? String { status = value }
|
|
145
|
+
if let version = state["stateVersion"] as? Int { stateVersion = max(version, stateVersion) }
|
|
146
|
+
plannedSession = state["plannedSession"] as? [String: Any]
|
|
147
|
+
if let heartRate = state["heartRateBpm"] as? Double { heartRateBpm = max(heartRate, 0) }
|
|
148
|
+
if let zone = state["heartRateZone"] as? Int { heartRateZone = min(max(zone, 1), 5) }
|
|
149
|
+
lastWorkout = state["lastWorkout"] as? [String: Any]
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
private func persistState() {
|
|
153
|
+
stateVersion += 1
|
|
154
|
+
var state: [String: Any] = [
|
|
155
|
+
"protocolVersion": Self.protocolVersion,
|
|
156
|
+
"stateVersion": stateVersion,
|
|
157
|
+
"recording": isRecording,
|
|
158
|
+
"paused": isPaused,
|
|
159
|
+
"distanceMeters": distanceMeters,
|
|
160
|
+
"durationSeconds": durationSeconds,
|
|
161
|
+
"status": status,
|
|
162
|
+
]
|
|
163
|
+
if let plannedSession { state["plannedSession"] = plannedSession }
|
|
164
|
+
if let heartRateBpm { state["heartRateBpm"] = heartRateBpm }
|
|
165
|
+
if let heartRateZone { state["heartRateZone"] = heartRateZone }
|
|
166
|
+
if let lastWorkout { state["lastWorkout"] = lastWorkout }
|
|
167
|
+
UserDefaults.standard.set(state, forKey: Self.persistedStateKey)
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
private func publishState() {
|
|
171
|
+
guard let session, session.activationState == .activated else { return }
|
|
172
|
+
var state: [String: Any] = [
|
|
173
|
+
"protocolVersion": Self.protocolVersion,
|
|
174
|
+
"stateVersion": stateVersion,
|
|
175
|
+
"type": "watch-state",
|
|
176
|
+
"recording": isRecording,
|
|
177
|
+
"paused": isPaused,
|
|
178
|
+
"distanceMeters": distanceMeters,
|
|
179
|
+
"durationSeconds": durationSeconds,
|
|
180
|
+
"status": status,
|
|
181
|
+
]
|
|
182
|
+
if let plannedSession { state["plannedSession"] = plannedSession }
|
|
183
|
+
if let heartRateBpm { state["heartRateBpm"] = heartRateBpm }
|
|
184
|
+
if let heartRateZone { state["heartRateZone"] = heartRateZone }
|
|
185
|
+
if let lastWorkout { state["workout"] = lastWorkout }
|
|
186
|
+
try? session.updateApplicationContext(state)
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
private func startHealthWorkout(completion: @escaping (Bool) -> Void) {
|
|
190
|
+
guard HKHealthStore.isHealthDataAvailable() else {
|
|
191
|
+
status = "HealthKit unavailable"
|
|
192
|
+
persistState()
|
|
193
|
+
completion(false)
|
|
194
|
+
return
|
|
195
|
+
}
|
|
196
|
+
let shareTypes: Set<HKSampleType> = [HKObjectType.workoutType()]
|
|
197
|
+
var readTypes: Set<HKObjectType> = [HKObjectType.workoutType()]
|
|
198
|
+
if let heartRate = HKQuantityType.quantityType(forIdentifier: .heartRate) { readTypes.insert(heartRate) }
|
|
199
|
+
if let distance = HKQuantityType.quantityType(forIdentifier: .distanceWalkingRunning) { readTypes.insert(distance) }
|
|
200
|
+
if let energy = HKQuantityType.quantityType(forIdentifier: .activeEnergyBurned) { readTypes.insert(energy) }
|
|
201
|
+
healthStore.requestAuthorization(toShare: shareTypes, read: readTypes) { [weak self] success, error in
|
|
202
|
+
DispatchQueue.main.async {
|
|
203
|
+
guard let self else { return completion(false) }
|
|
204
|
+
guard success else {
|
|
205
|
+
self.status = error?.localizedDescription ?? "HealthKit authorization denied"
|
|
206
|
+
self.persistState()
|
|
207
|
+
return completion(false)
|
|
208
|
+
}
|
|
209
|
+
self.beginHealthWorkout(completion: completion)
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
private func beginHealthWorkout(completion: @escaping (Bool) -> Void) {
|
|
215
|
+
guard workoutSession == nil else { completion(true); return }
|
|
216
|
+
let configuration = HKWorkoutConfiguration()
|
|
217
|
+
configuration.activityType = .running
|
|
218
|
+
configuration.locationType = .outdoor
|
|
219
|
+
do {
|
|
220
|
+
let session = try HKWorkoutSession(healthStore: healthStore, configuration: configuration)
|
|
221
|
+
let builder = session.associatedWorkoutBuilder()
|
|
222
|
+
session.delegate = self
|
|
223
|
+
builder.delegate = self
|
|
224
|
+
builder.shouldCollectWorkoutEvents = true
|
|
225
|
+
builder.dataSource = HKLiveWorkoutDataSource(healthStore: healthStore, workoutConfiguration: configuration)
|
|
226
|
+
workoutSession = session
|
|
227
|
+
workoutBuilder = builder
|
|
228
|
+
workoutId = UUID().uuidString.lowercased()
|
|
229
|
+
let start = Date()
|
|
230
|
+
workoutStartedAt = start
|
|
231
|
+
session.startActivity(with: start)
|
|
232
|
+
builder.beginCollection(withStart: start) { [weak self] success, error in
|
|
233
|
+
DispatchQueue.main.async {
|
|
234
|
+
guard let self else { return completion(false) }
|
|
235
|
+
guard success else {
|
|
236
|
+
self.clearHealthWorkout()
|
|
237
|
+
self.status = error?.localizedDescription ?? "Workout could not start"
|
|
238
|
+
self.persistState()
|
|
239
|
+
return completion(false)
|
|
240
|
+
}
|
|
241
|
+
self.isRecording = true
|
|
242
|
+
self.isPaused = false
|
|
243
|
+
self.status = "Recording"
|
|
244
|
+
self.persistState()
|
|
245
|
+
self.publishState()
|
|
246
|
+
completion(true)
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
} catch {
|
|
250
|
+
status = error.localizedDescription
|
|
251
|
+
persistState()
|
|
252
|
+
completion(false)
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
private func pauseHealthWorkout() {
|
|
257
|
+
guard let workoutSession else { return }
|
|
258
|
+
workoutSession.pause()
|
|
259
|
+
isPaused = true
|
|
260
|
+
status = "Paused"
|
|
261
|
+
persistState()
|
|
262
|
+
publishState()
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
private func resumeHealthWorkout() {
|
|
266
|
+
guard let workoutSession else { return }
|
|
267
|
+
workoutSession.resume()
|
|
268
|
+
isPaused = false
|
|
269
|
+
status = "Recording"
|
|
270
|
+
persistState()
|
|
271
|
+
publishState()
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
private func endHealthWorkout(completion: @escaping ([String: Any]?) -> Void) {
|
|
275
|
+
guard let workoutSession, let workoutBuilder, let id = workoutId else {
|
|
276
|
+
completion(nil)
|
|
277
|
+
return
|
|
278
|
+
}
|
|
279
|
+
let end = Date()
|
|
280
|
+
workoutSession.end()
|
|
281
|
+
workoutBuilder.endCollection(withEnd: end) { [weak self] success, error in
|
|
282
|
+
guard let self else { return }
|
|
283
|
+
guard success else {
|
|
284
|
+
DispatchQueue.main.async {
|
|
285
|
+
self.status = error?.localizedDescription ?? "Workout could not finish"
|
|
286
|
+
self.persistState()
|
|
287
|
+
completion(nil)
|
|
288
|
+
}
|
|
289
|
+
return
|
|
290
|
+
}
|
|
291
|
+
workoutBuilder.finishWorkout { [weak self] workout, finishError in
|
|
292
|
+
DispatchQueue.main.async {
|
|
293
|
+
guard let self else { return completion(nil) }
|
|
294
|
+
guard let workout else {
|
|
295
|
+
self.status = finishError?.localizedDescription ?? "Workout could not be saved"
|
|
296
|
+
self.persistState()
|
|
297
|
+
return completion(nil)
|
|
298
|
+
}
|
|
299
|
+
let result: [String: Any] = [
|
|
300
|
+
"id": id,
|
|
301
|
+
"workoutId": workout.uuid.uuidString,
|
|
302
|
+
"startDate": (self.workoutStartedAt ?? workout.startDate).timeIntervalSince1970 * 1000,
|
|
303
|
+
"endDate": end.timeIntervalSince1970 * 1000,
|
|
304
|
+
"durationSeconds": workout.duration,
|
|
305
|
+
"distanceMeters": self.distanceMeters,
|
|
306
|
+
]
|
|
307
|
+
self.lastWorkout = result
|
|
308
|
+
self.isRecording = false
|
|
309
|
+
self.isPaused = false
|
|
310
|
+
self.status = "Saved to Apple Health"
|
|
311
|
+
self.clearHealthWorkout()
|
|
312
|
+
self.persistState()
|
|
313
|
+
self.publishState()
|
|
314
|
+
completion(result)
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
private func clearHealthWorkout() {
|
|
321
|
+
workoutSession = nil
|
|
322
|
+
workoutBuilder = nil
|
|
323
|
+
workoutId = nil
|
|
324
|
+
workoutStartedAt = nil
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
private func updateHealthMetrics(_ builder: HKLiveWorkoutBuilder) {
|
|
328
|
+
if let heartRate = HKQuantityType.quantityType(forIdentifier: .heartRate),
|
|
329
|
+
let value = builder.statistics(for: heartRate)?.mostRecentQuantity()?.doubleValue(for: HKUnit.count().unitDivided(by: .minute())),
|
|
330
|
+
value.isFinite {
|
|
331
|
+
heartRateBpm = max(value, 0)
|
|
332
|
+
let maximum = (plannedSession?["maxHeartRateBpm"] as? Double) ?? 190
|
|
333
|
+
heartRateZone = min(max(Int(ceil(value / maximum * 5)), 1), 5)
|
|
334
|
+
}
|
|
335
|
+
if let distance = HKQuantityType.quantityType(forIdentifier: .distanceWalkingRunning),
|
|
336
|
+
let value = builder.statistics(for: distance)?.sumQuantity()?.doubleValue(for: .meter()),
|
|
337
|
+
value.isFinite {
|
|
338
|
+
distanceMeters = max(value, 0)
|
|
339
|
+
}
|
|
340
|
+
if let start = workoutStartedAt { durationSeconds = max(Date().timeIntervalSince(start), 0) }
|
|
341
|
+
persistState()
|
|
342
|
+
publishState()
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
extension CraftWatchSession: HKWorkoutSessionDelegate, HKLiveWorkoutBuilderDelegate {
|
|
347
|
+
nonisolated func workoutSession(_ workoutSession: HKWorkoutSession, didChangeTo toState: HKWorkoutSessionState, from fromState: HKWorkoutSessionState, date: Date) {}
|
|
348
|
+
|
|
349
|
+
nonisolated func workoutSession(_ workoutSession: HKWorkoutSession, didFailWithError error: Error) {
|
|
350
|
+
Task { @MainActor in
|
|
351
|
+
status = error.localizedDescription
|
|
352
|
+
isRecording = false
|
|
353
|
+
isPaused = false
|
|
354
|
+
clearHealthWorkout()
|
|
355
|
+
persistState()
|
|
356
|
+
publishState()
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
nonisolated func workoutBuilderDidCollectEvent(_ workoutBuilder: HKLiveWorkoutBuilder) {}
|
|
361
|
+
|
|
362
|
+
nonisolated func workoutBuilder(_ workoutBuilder: HKLiveWorkoutBuilder, didCollectDataOf types: Set<HKSampleType>) {
|
|
363
|
+
Task { @MainActor in updateHealthMetrics(workoutBuilder) }
|
|
78
364
|
}
|
|
79
365
|
}
|
|
80
366
|
|
|
@@ -84,11 +370,15 @@ extension CraftWatchSession: WCSessionDelegate {
|
|
|
84
370
|
isPhoneReachable = session.isReachable
|
|
85
371
|
status = error == nil ? "Ready" : "Connection unavailable"
|
|
86
372
|
apply(session.receivedApplicationContext)
|
|
373
|
+
publishState()
|
|
87
374
|
}
|
|
88
375
|
}
|
|
89
376
|
|
|
90
377
|
nonisolated func sessionReachabilityDidChange(_ session: WCSession) {
|
|
91
|
-
Task { @MainActor in
|
|
378
|
+
Task { @MainActor in
|
|
379
|
+
isPhoneReachable = session.isReachable
|
|
380
|
+
if session.isReachable { publishState() }
|
|
381
|
+
}
|
|
92
382
|
}
|
|
93
383
|
|
|
94
384
|
nonisolated func session(_ session: WCSession, didReceiveApplicationContext applicationContext: [String: Any]) {
|
|
@@ -129,6 +419,10 @@ struct CraftWatchRecordingView: View {
|
|
|
129
419
|
metric(distance, label: "Distance")
|
|
130
420
|
metric(duration, label: "Time")
|
|
131
421
|
}
|
|
422
|
+
if let heartRate = session.heartRateBpm {
|
|
423
|
+
metric(String(format: "%.0f bpm", heartRate), label: "Heart rate")
|
|
424
|
+
if let zone = session.heartRateZone { Text("Zone \(zone) of 5").font(.caption2).foregroundStyle(.secondary) }
|
|
425
|
+
}
|
|
132
426
|
Button(session.isRecording && !session.isPaused ? "Pause" : session.isPaused ? "Resume" : "Start") {
|
|
133
427
|
session.toggleRecording()
|
|
134
428
|
}
|