@craft-native/ios 0.0.276 → 0.0.290
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/CraftApp.swift +44 -5
- package/templates/CraftHybrid.swift +21 -0
- package/templates/CraftNativeActions.swift +6 -0
- package/templates/CraftNativeFiles.swift +136 -0
- package/templates/CraftNativeMutation.swift +51 -0
- package/templates/CraftNativeScreen.swift +116 -8
- 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
package/templates/CraftApp.swift
CHANGED
|
@@ -903,6 +903,9 @@ final class CraftChrome: ObservableObject {
|
|
|
903
903
|
func tap(_ tab: Tab) {
|
|
904
904
|
let reselect = tab.id == selected
|
|
905
905
|
if !reselect {
|
|
906
|
+
// A tab's screen not made yet is made first, so the pill moves on
|
|
907
|
+
// a free main thread rather than stalling behind its mount.
|
|
908
|
+
CraftHybrid.shared.mountTabBeforeSelecting(tab.id)
|
|
906
909
|
withAnimation(Self.motion(.spring(response: 0.32, dampingFraction: 0.86))) { selected = tab.id }
|
|
907
910
|
}
|
|
908
911
|
// A hybrid app shows the tab's own stack first; the page hears the
|
|
@@ -1162,11 +1165,7 @@ struct CraftTabBarView: View {
|
|
|
1162
1165
|
VStack(spacing: 3) {
|
|
1163
1166
|
ZStack(alignment: .topTrailing) {
|
|
1164
1167
|
if let image = tab.image, let url = URL(string: image) {
|
|
1165
|
-
|
|
1166
|
-
image.resizable().scaledToFill()
|
|
1167
|
-
} placeholder: {
|
|
1168
|
-
Image(systemName: symbol(tab.symbol, selected: isSelected))
|
|
1169
|
-
}
|
|
1168
|
+
CraftTabPicture(url: url, placeholder: symbol(tab.symbol, selected: isSelected))
|
|
1170
1169
|
.frame(width: iconSize + 4, height: iconSize + 4)
|
|
1171
1170
|
.clipShape(Circle())
|
|
1172
1171
|
.frame(height: iconSize + 6)
|
|
@@ -1258,6 +1257,46 @@ struct CraftTabBarView: View {
|
|
|
1258
1257
|
}
|
|
1259
1258
|
}
|
|
1260
1259
|
|
|
1260
|
+
/// A tab's picture (HQ's Me: the account's photo). It is kept once loaded,
|
|
1261
|
+
/// so a tab item drawn again (its screen mounting, the bar coming back) shows
|
|
1262
|
+
/// it at once: AsyncImage keeps nothing, so the photo went back to the
|
|
1263
|
+
/// person symbol, loaded again and arrived inside whatever animation was
|
|
1264
|
+
/// running, sliding in from the side on the first visit to Me.
|
|
1265
|
+
private struct CraftTabPicture: View {
|
|
1266
|
+
let url: URL
|
|
1267
|
+
let placeholder: String
|
|
1268
|
+
@State private var image: UIImage?
|
|
1269
|
+
|
|
1270
|
+
private static let cache: NSCache<NSURL, UIImage> = {
|
|
1271
|
+
let cache = NSCache<NSURL, UIImage>()
|
|
1272
|
+
cache.countLimit = 16
|
|
1273
|
+
return cache
|
|
1274
|
+
}()
|
|
1275
|
+
|
|
1276
|
+
var body: some View {
|
|
1277
|
+
Group {
|
|
1278
|
+
if let shown = image ?? Self.cache.object(forKey: url as NSURL) {
|
|
1279
|
+
Image(uiImage: shown).resizable().scaledToFill()
|
|
1280
|
+
} else {
|
|
1281
|
+
Image(systemName: placeholder)
|
|
1282
|
+
}
|
|
1283
|
+
}
|
|
1284
|
+
// A picture arriving never animates, whatever transaction it lands in.
|
|
1285
|
+
.transaction { $0.animation = nil }
|
|
1286
|
+
.task(id: url) {
|
|
1287
|
+
if let cached = Self.cache.object(forKey: url as NSURL) {
|
|
1288
|
+
image = cached
|
|
1289
|
+
return
|
|
1290
|
+
}
|
|
1291
|
+
guard let (data, response) = try? await URLSession.shared.data(from: url),
|
|
1292
|
+
(response as? HTTPURLResponse).map({ (200..<300).contains($0.statusCode) }) ?? true,
|
|
1293
|
+
let loaded = UIImage(data: data) else { return }
|
|
1294
|
+
Self.cache.setObject(loaded, forKey: url as NSURL)
|
|
1295
|
+
image = loaded
|
|
1296
|
+
}
|
|
1297
|
+
}
|
|
1298
|
+
}
|
|
1299
|
+
|
|
1261
1300
|
private struct CraftTabSelectionShape: Shape {
|
|
1262
1301
|
let cornerRadius: CGFloat?
|
|
1263
1302
|
|
|
@@ -880,6 +880,13 @@ final class CraftHybrid: NSObject {
|
|
|
880
880
|
return controller.tabTapped(id, reselect: reselect)
|
|
881
881
|
}
|
|
882
882
|
|
|
883
|
+
/// A native tab not prepared yet mounts its screen now, hidden, before
|
|
884
|
+
/// the bar's selection moves (CraftChrome.tap).
|
|
885
|
+
func mountTabBeforeSelecting(_ id: String) {
|
|
886
|
+
guard isEnabled else { return }
|
|
887
|
+
controller?.mountTabBeforeSelecting(id)
|
|
888
|
+
}
|
|
889
|
+
|
|
883
890
|
/// The page chose a tab itself (its `selectTab`, or a tab link).
|
|
884
891
|
func pageSelectedTab(_ id: String) {
|
|
885
892
|
guard isEnabled else { return }
|
|
@@ -1731,6 +1738,20 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
|
|
|
1731
1738
|
|
|
1732
1739
|
private var preparation: Timer?
|
|
1733
1740
|
|
|
1741
|
+
/// A native tab tapped before the shell prepared it (HQ's Me, prepared
|
|
1742
|
+
/// last, tapped right after launch) mounts its screen hidden first. Mounted
|
|
1743
|
+
/// in the tap's own turn after the selection began, it held the main
|
|
1744
|
+
/// thread while SwiftUI's spring should have moved the pill, which then
|
|
1745
|
+
/// stalled and jumped in from the side.
|
|
1746
|
+
func mountTabBeforeSelecting(_ id: String) {
|
|
1747
|
+
guard stacks[id] == nil, id != currentTab, activeTabSwipeTarget == nil,
|
|
1748
|
+
hybrid.tabIds.contains(id), hybrid.routes.match(id) != nil, view.window != nil else { return }
|
|
1749
|
+
let nav = makeStack(tab: id, rootPath: id, hidden: true)
|
|
1750
|
+
nav.view.layoutIfNeeded()
|
|
1751
|
+
Self.drawContents(of: nav.view.layer)
|
|
1752
|
+
(visibleStack?.topViewController as? CraftNativeScreenController)?.screenVisibilityChanged(true)
|
|
1753
|
+
}
|
|
1754
|
+
|
|
1734
1755
|
/// When the shell last moved between screens: a tab, a push or a pop.
|
|
1735
1756
|
private var lastNavigation: CFTimeInterval = 0
|
|
1736
1757
|
|
|
@@ -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],
|
|
@@ -5101,6 +5101,17 @@ private final class CraftNativeTextView: UITextView {
|
|
|
5101
5101
|
|
|
5102
5102
|
override func layoutSubviews() {
|
|
5103
5103
|
super.layoutSubviews()
|
|
5104
|
+
// A multiline input grows with its text up to its `maxHeight`, then
|
|
5105
|
+
// scrolls inside itself. Never scrolling, the text past that height
|
|
5106
|
+
// was cut off and could not be reached (a long workout comment).
|
|
5107
|
+
// With scrolling off, contentSize is the view's own height: measure
|
|
5108
|
+
// the text itself.
|
|
5109
|
+
let needed = isEditable ? sizeThatFits(CGSize(width: bounds.width, height: .greatestFiniteMagnitude)).height : 0
|
|
5110
|
+
let overflows = isEditable && bounds.height > 0 && needed > bounds.height + 1
|
|
5111
|
+
if isScrollEnabled != overflows {
|
|
5112
|
+
isScrollEnabled = overflows
|
|
5113
|
+
if overflows, isFirstResponder { scrollRangeToVisible(selectedRange) }
|
|
5114
|
+
}
|
|
5104
5115
|
let inset = textContainerInset
|
|
5105
5116
|
let padding = textContainer.lineFragmentPadding
|
|
5106
5117
|
placeholderLabel.frame = CGRect(
|
|
@@ -5117,6 +5128,7 @@ private final class CraftNativeTextView: UITextView {
|
|
|
5117
5128
|
}
|
|
5118
5129
|
|
|
5119
5130
|
private func configurePlaceholder() {
|
|
5131
|
+
isScrollEnabled = false
|
|
5120
5132
|
placeholderLabel.numberOfLines = 0
|
|
5121
5133
|
placeholderLabel.font = font
|
|
5122
5134
|
placeholderLabel.textColor = .placeholderText
|
|
@@ -5623,6 +5635,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
5623
5635
|
// A tab prepared in a hidden stack "appears" to UIKit as well; the
|
|
5624
5636
|
// shell says when it really shows (screenVisibilityChanged).
|
|
5625
5637
|
if isShownOnScreen {
|
|
5638
|
+
hasAppeared = true
|
|
5626
5639
|
screenVisibilityChanged(true)
|
|
5627
5640
|
// A navigation breadcrumb for the crash reporter: the trail a
|
|
5628
5641
|
// crash report carries is the screens that led to it.
|
|
@@ -5694,6 +5707,14 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
5694
5707
|
// preview also restores a screen that never lost focus, so restore its
|
|
5695
5708
|
// chrome even when no new SCREEN_FOCUS event is needed.
|
|
5696
5709
|
if visible { restoreShellChrome() }
|
|
5710
|
+
// A prepared tab the shell now shows has appeared once its frame is
|
|
5711
|
+
// drawn; what its script asks for until then is there from the start.
|
|
5712
|
+
if visible, !hasAppeared {
|
|
5713
|
+
DispatchQueue.main.async { [weak self] in
|
|
5714
|
+
guard let self, self.isViewLoaded, self.isShownOnScreen else { return }
|
|
5715
|
+
self.hasAppeared = true
|
|
5716
|
+
}
|
|
5717
|
+
}
|
|
5697
5718
|
if visible, frameWanted { CraftNativeFrameClock.shared.request(self) { [weak self] in self?.fireFrame() } }
|
|
5698
5719
|
if !visible { CraftNativeFrameClock.shared.cancel(self) }
|
|
5699
5720
|
guard visible != reportedVisible else { return }
|
|
@@ -5780,6 +5801,27 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
5780
5801
|
super.viewDidLayoutSubviews()
|
|
5781
5802
|
positionModals()
|
|
5782
5803
|
emitLayoutEvents()
|
|
5804
|
+
sendViewportIfChanged()
|
|
5805
|
+
}
|
|
5806
|
+
|
|
5807
|
+
/// The width the bundle last heard, so a layout pass at the same size says nothing.
|
|
5808
|
+
private var reportedViewportWidth: CGFloat = -1
|
|
5809
|
+
|
|
5810
|
+
/// The screen's own width once laid out, else its window's: what a web
|
|
5811
|
+
/// page's media queries would measure.
|
|
5812
|
+
private func currentViewportWidth() -> CGFloat {
|
|
5813
|
+
let width = view.bounds.width > 0 ? view.bounds.width : (view.window ?? CraftPresenter.keyWindow())?.bounds.width ?? 0
|
|
5814
|
+
reportedViewportWidth = width
|
|
5815
|
+
return width
|
|
5816
|
+
}
|
|
5817
|
+
|
|
5818
|
+
/// A rotation, iPad split view or a resized Stage Manager window: the
|
|
5819
|
+
/// bundle's width classes (`md:`, `lg:`) follow the screen's new width.
|
|
5820
|
+
private func sendViewportIfChanged() {
|
|
5821
|
+
let width = view.bounds.width
|
|
5822
|
+
guard width > 0, abs(width - reportedViewportWidth) >= 0.5 else { return }
|
|
5823
|
+
reportedViewportWidth = width
|
|
5824
|
+
send(type: "VIEWPORT", payload: ["width": Double(width), "height": Double(view.bounds.height)])
|
|
5783
5825
|
}
|
|
5784
5826
|
|
|
5785
5827
|
/// Modals cover the screen; their content starts below its bars (the
|
|
@@ -5864,6 +5906,8 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
5864
5906
|
let appearanceJSON: String
|
|
5865
5907
|
let sessionTokenKey: String
|
|
5866
5908
|
let focused: Bool?
|
|
5909
|
+
/// The screen's width in points, for the bundle's `sm:`/`md:`/`lg:` classes.
|
|
5910
|
+
let viewportWidth: Double
|
|
5867
5911
|
}
|
|
5868
5912
|
|
|
5869
5913
|
private func scriptInputs() -> ScriptInputs {
|
|
@@ -5875,7 +5919,8 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
5875
5919
|
colorScheme: colorScheme,
|
|
5876
5920
|
appearanceJSON: appearanceJSON(),
|
|
5877
5921
|
sessionTokenKey: Self.jsonString(CraftSession.tokenKey(config)),
|
|
5878
|
-
focused: reportedVisible
|
|
5922
|
+
focused: reportedVisible,
|
|
5923
|
+
viewportWidth: Double(currentViewportWidth())
|
|
5879
5924
|
)
|
|
5880
5925
|
}
|
|
5881
5926
|
|
|
@@ -5977,6 +6022,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
5977
6022
|
capabilities: \(capabilityJSON),
|
|
5978
6023
|
initialAppState: "\(inputs.appState)",
|
|
5979
6024
|
colorScheme: "\(inputs.colorScheme)",
|
|
6025
|
+
viewportWidth: \(inputs.viewportWidth),
|
|
5980
6026
|
appearance: \(inputs.appearanceJSON),
|
|
5981
6027
|
appURL: \(appURL),
|
|
5982
6028
|
appVersion: \(appVersion),
|
|
@@ -6546,6 +6592,14 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
6546
6592
|
/// own horizontal pager or chart gesture.
|
|
6547
6593
|
private(set) var tabSwipeEnabled = true
|
|
6548
6594
|
private var prefersLargeTitle: Bool?
|
|
6595
|
+
/// Whether the screen has been on screen. Until then, a bar its script
|
|
6596
|
+
/// asks for is simply there in its first frame: a tab opened for the
|
|
6597
|
+
/// first time (HQ's Me) mounts in the window, and its bar and large title
|
|
6598
|
+
/// slid in from the side as the screen appeared.
|
|
6599
|
+
private var hasAppeared = false
|
|
6600
|
+
/// A bar shown or hidden after the screen has appeared animates; before,
|
|
6601
|
+
/// it is in place from the start.
|
|
6602
|
+
var animatesNavigationBarChanges: Bool { hasAppeared && view.window != nil }
|
|
6549
6603
|
/// Whether this screen opts into an opaque navigation bar filled with its
|
|
6550
6604
|
/// own background colour. The default keeps UIKit's material appearance.
|
|
6551
6605
|
private var navigationBarSolid = false
|
|
@@ -6624,7 +6678,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
6624
6678
|
if let hidden = options["hidden"] as? Bool {
|
|
6625
6679
|
navigationBarHidden = hidden
|
|
6626
6680
|
if navigationController?.topViewController === self {
|
|
6627
|
-
navigationController?.setNavigationBarHidden(hidden, animated:
|
|
6681
|
+
navigationController?.setNavigationBarHidden(hidden, animated: animatesNavigationBarChanges)
|
|
6628
6682
|
}
|
|
6629
6683
|
}
|
|
6630
6684
|
if options.keys.contains("leftButtons") {
|
|
@@ -6642,7 +6696,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
6642
6696
|
// Asked for after the bar was hidden for a bare root screen.
|
|
6643
6697
|
if navigationBarHidden == nil, wantsNavigationBar, navigationController?.topViewController === self,
|
|
6644
6698
|
navigationController?.isNavigationBarHidden == true {
|
|
6645
|
-
navigationController?.setNavigationBarHidden(false, animated:
|
|
6699
|
+
navigationController?.setNavigationBarHidden(false, animated: animatesNavigationBarChanges)
|
|
6646
6700
|
}
|
|
6647
6701
|
}
|
|
6648
6702
|
|
|
@@ -7104,13 +7158,39 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
7104
7158
|
/// `input`, scrolled into the visible part of the scroll view it is in.
|
|
7105
7159
|
/// Internal for XCTest.
|
|
7106
7160
|
func scrollIntoView(_ input: UIView, animated: Bool) {
|
|
7107
|
-
|
|
7108
|
-
|
|
7109
|
-
|
|
7110
|
-
|
|
7161
|
+
guard let scroll = enclosingVerticalScroll(of: input) else { return }
|
|
7162
|
+
var rect = input.convert(input.bounds, to: scroll).insetBy(dx: 0, dy: -16)
|
|
7163
|
+
// A field taller than the part the keyboard leaves (a long comment
|
|
7164
|
+
// with no max height) shows its caret rather than its top: scrolled
|
|
7165
|
+
// to its top, the caret at the end of the text stayed under the
|
|
7166
|
+
// keyboard with no way to scroll it out.
|
|
7167
|
+
let visible = scroll.bounds.inset(by: scroll.adjustedContentInset)
|
|
7168
|
+
if rect.height > visible.height, let caret = caretRect(of: input, in: scroll) { rect = caret }
|
|
7111
7169
|
scroll.scrollRectToVisible(rect, animated: animated)
|
|
7112
7170
|
}
|
|
7113
7171
|
|
|
7172
|
+
/// The vertical scroll view `view` sits in, if any.
|
|
7173
|
+
private func enclosingVerticalScroll(of view: UIView) -> CraftNativeScrollView? {
|
|
7174
|
+
var ancestor = view.superview
|
|
7175
|
+
while let current = ancestor, !(current is CraftNativeScrollView) { ancestor = current.superview }
|
|
7176
|
+
guard let scroll = ancestor as? CraftNativeScrollView, scroll.isVertical else { return nil }
|
|
7177
|
+
return scroll
|
|
7178
|
+
}
|
|
7179
|
+
|
|
7180
|
+
/// A focused input's caret in `scroll`'s coordinates, with a little room
|
|
7181
|
+
/// around it, kept within the input (one scrolling inside itself has its
|
|
7182
|
+
/// caret beyond its own frame until it scrolls to it).
|
|
7183
|
+
private func caretRect(of input: UIView, in scroll: UIScrollView) -> CGRect? {
|
|
7184
|
+
guard input.isFirstResponder, let text = input as? UITextInput,
|
|
7185
|
+
let end = text.selectedTextRange?.end else { return nil }
|
|
7186
|
+
let caret = text.caretRect(for: end)
|
|
7187
|
+
guard caret.minY.isFinite, caret.height > 0 else { return nil }
|
|
7188
|
+
let frame = input.convert(input.bounds, to: scroll)
|
|
7189
|
+
var rect = input.convert(caret, to: scroll)
|
|
7190
|
+
rect.origin.y = min(max(rect.minY, frame.minY), max(frame.minY, frame.maxY - rect.height))
|
|
7191
|
+
return rect.insetBy(dx: 0, dy: -16)
|
|
7192
|
+
}
|
|
7193
|
+
|
|
7114
7194
|
private func sendAppState(_ state: String) {
|
|
7115
7195
|
// A screen hears it while it is on top of its stack; the app script,
|
|
7116
7196
|
// which is on no stack, always.
|
|
@@ -8977,7 +9057,8 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
8977
9057
|
if textView.textContainerInset != padding { textView.textContainerInset = padding }
|
|
8978
9058
|
textView.textContainer.lineFragmentPadding = 0
|
|
8979
9059
|
}
|
|
8980
|
-
|
|
9060
|
+
// An input's own scrolling follows its height (CraftNativeTextView).
|
|
9061
|
+
if !(textView is CraftNativeTextView) { textView.isScrollEnabled = false }
|
|
8981
9062
|
let numberOfLines = Int(number(props["numberOfLines"]) ?? 0)
|
|
8982
9063
|
if numberOfLines > 0 {
|
|
8983
9064
|
textView.textContainer.maximumNumberOfLines = numberOfLines
|
|
@@ -9846,11 +9927,38 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
9846
9927
|
let id = ObjectIdentifier(textView)
|
|
9847
9928
|
inputIdentities[id].map { inputDrafts[$0] = textView.text ?? "" }
|
|
9848
9929
|
(textView as? CraftNativeTextView)?.refreshPlaceholder()
|
|
9930
|
+
keepCaretVisible(in: textView)
|
|
9849
9931
|
guard let handler = handlers[id] else { return }
|
|
9850
9932
|
noteSentText(textView.text ?? "", for: textView)
|
|
9851
9933
|
send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": ["text": textView.text ?? ""]])
|
|
9852
9934
|
}
|
|
9853
9935
|
|
|
9936
|
+
func textViewDidChangeSelection(_ textView: UITextView) {
|
|
9937
|
+
guard textView.isFirstResponder else { return }
|
|
9938
|
+
keepCaretVisible(in: textView)
|
|
9939
|
+
}
|
|
9940
|
+
|
|
9941
|
+
/// The caret, kept where it can be seen as a multiline input grows or the
|
|
9942
|
+
/// caret moves: in the part of the screen's scroll view the keyboard
|
|
9943
|
+
/// leaves. Only the field was scrolled into view, when editing began, so
|
|
9944
|
+
/// a long comment typed at the bottom of a screen went under the keyboard.
|
|
9945
|
+
private func keepCaretVisible(in textView: UITextView) {
|
|
9946
|
+
DispatchQueue.main.async { [weak self, weak textView] in
|
|
9947
|
+
guard let self, let textView, textView.isFirstResponder else { return }
|
|
9948
|
+
// The input grows with its text first: a text view's new lines
|
|
9949
|
+
// are news to the flex layout around it, which sized the scroll
|
|
9950
|
+
// view's content to the old height.
|
|
9951
|
+
textView.invalidateIntrinsicContentSize()
|
|
9952
|
+
CraftNativeFlexLayout.invalidateAncestors(of: textView)
|
|
9953
|
+
self.view.layoutIfNeeded()
|
|
9954
|
+
guard let scroll = self.enclosingVerticalScroll(of: textView),
|
|
9955
|
+
let rect = self.caretRect(of: textView, in: scroll) else { return }
|
|
9956
|
+
let visible = scroll.bounds.inset(by: scroll.adjustedContentInset)
|
|
9957
|
+
guard !visible.contains(rect) else { return }
|
|
9958
|
+
scroll.scrollRectToVisible(rect, animated: false)
|
|
9959
|
+
}
|
|
9960
|
+
}
|
|
9961
|
+
|
|
9854
9962
|
func textViewDidBeginEditing(_ textView: UITextView) {
|
|
9855
9963
|
lastFocusedInput = textView
|
|
9856
9964
|
if keyboardScreenFrame != nil { DispatchQueue.main.async { self.scrollFocusedInputIntoView(animated: true) } }
|
|
@@ -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
|
}
|