craft-native 0.0.162 → 0.0.164
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/android/templates/MainActivityNative.kt.template +133 -114
- package/dist/api/mobile.d.ts +147 -2
- package/dist/cli.js +1 -1
- package/dist/index.cjs +1 -1
- package/dist/index.js +1 -1
- package/dist/ios/src/index.d.ts +18 -0
- package/dist/ios/src/index.js +12 -1
- package/dist/ios/templates/CraftApp.swift +68 -0
- package/dist/ios/templates/CraftHealth.swift +48 -2
- package/dist/ios/templates/CraftHybrid.swift +49 -0
- package/dist/ios/templates/CraftNativeActions.swift +125 -1
- package/dist/ios/templates/CraftNativeFiles.swift +107 -0
- package/dist/ios/templates/CraftNativeScreen.swift +38 -3
- package/dist/ios/templates/CraftScheduledWorkouts.swift +524 -0
- package/dist/ios/templates/project.yml.template +1 -0
- package/dist/mobile.js +75 -0
- package/package.json +1 -1
|
@@ -0,0 +1,524 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
#if CRAFT_SCHEDULED_WORKOUTS
|
|
3
|
+
import HealthKit
|
|
4
|
+
import WorkoutKit
|
|
5
|
+
#endif
|
|
6
|
+
|
|
7
|
+
/// A scheduling call that did not happen: the message a page sees, and the
|
|
8
|
+
/// bridge's error code.
|
|
9
|
+
struct CraftScheduledWorkoutsError: Error {
|
|
10
|
+
let message: String
|
|
11
|
+
var code: String = "CRAFT_ERROR"
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/// Structured workouts scheduled onto the paired Apple Watch through
|
|
15
|
+
/// WorkoutKit's `WorkoutScheduler` (iOS 17+): each appears in the watch's
|
|
16
|
+
/// Workout app on its day, steps, targets and alerts included.
|
|
17
|
+
///
|
|
18
|
+
/// One service for both sides: the page's `craft.scheduledWorkouts` (actions
|
|
19
|
+
/// `scheduledWorkouts` and `scheduledWorkoutsRequestAuthorization`, which
|
|
20
|
+
/// waits on the person) and a native screen's `ScheduledWorkouts` module.
|
|
21
|
+
///
|
|
22
|
+
/// WorkoutKit is compiled in only with `enableScheduledWorkouts`, which sets
|
|
23
|
+
/// the `CRAFT_SCHEDULED_WORKOUTS` condition, so an app that does not schedule
|
|
24
|
+
/// workouts does not link it. Without it (or below iOS 17) `isSupported`
|
|
25
|
+
/// answers false, `authorization` answers `unsupported`, and everything else
|
|
26
|
+
/// rejects UNSUPPORTED.
|
|
27
|
+
///
|
|
28
|
+
/// A plan is plain JSON (SI units):
|
|
29
|
+
///
|
|
30
|
+
/// { id: UUID, date: 'YYYY-MM-DD', hour?, minute?, workout: {
|
|
31
|
+
/// activity: HKWorkoutActivityType case name, location: 'indoor' | 'outdoor' | 'unknown',
|
|
32
|
+
/// swimmingLocation?: 'pool' | 'openWater', poolLengthMeters?, displayName,
|
|
33
|
+
/// warmup?: step, cooldown?: step,
|
|
34
|
+
/// blocks: [{ iterations, steps: [{ purpose: 'work' | 'recovery', step }] }] } }
|
|
35
|
+
/// step = { goal: { type: 'open' | 'time' (seconds) | 'distance' (meters) | 'energy' (kilocalories) },
|
|
36
|
+
/// alert?: { type: 'heartRate' | 'power' | 'speed' | 'cadence', min, max } | { type: 'heartRateZone' | 'powerZone', zone },
|
|
37
|
+
/// displayName? }
|
|
38
|
+
///
|
|
39
|
+
/// An activity or a goal WorkoutKit refuses rejects the call, naming it; an
|
|
40
|
+
/// alert it refuses is dropped and its step kept (`droppedAlerts`).
|
|
41
|
+
///
|
|
42
|
+
/// Simulator builds can stand in a scheduler with the
|
|
43
|
+
/// `CraftFakeWorkoutScheduler` default (`xcrun simctl spawn booted defaults
|
|
44
|
+
/// write <bundle id> CraftFakeWorkoutScheduler -bool YES`): plans are still
|
|
45
|
+
/// built and checked by WorkoutKit, then kept in UserDefaults instead of on a
|
|
46
|
+
/// watch, which a simulator never has. `CraftFakeWorkoutSchedulerAuthorization`
|
|
47
|
+
/// (a string) presets its answer to `denied`, `restricted` or `authorized`.
|
|
48
|
+
final class CraftScheduledWorkouts {
|
|
49
|
+
typealias Completion = (Result<Any, CraftScheduledWorkoutsError>) -> Void
|
|
50
|
+
|
|
51
|
+
static let shared = CraftScheduledWorkouts()
|
|
52
|
+
|
|
53
|
+
/// The call by method, with its options as the bridge sends them; answered on main.
|
|
54
|
+
func perform(method: String, args: [Any], completion: @escaping Completion) {
|
|
55
|
+
let options = args.first as? [String: Any] ?? [:]
|
|
56
|
+
let done: Completion = { result in DispatchQueue.main.async { completion(result) } }
|
|
57
|
+
#if CRAFT_SCHEDULED_WORKOUTS
|
|
58
|
+
if #available(iOS 17.0, *) {
|
|
59
|
+
Task { done(await CraftWorkoutKitScheduler.shared.perform(method: method, options: options)) }
|
|
60
|
+
return
|
|
61
|
+
}
|
|
62
|
+
#endif
|
|
63
|
+
done(Self.unsupported(method: method))
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/// The answer where WorkoutKit is not there: compiled out, or below iOS 17.
|
|
67
|
+
static func unsupported(method: String) -> Result<Any, CraftScheduledWorkoutsError> {
|
|
68
|
+
switch method {
|
|
69
|
+
case "isSupported": return .success(false)
|
|
70
|
+
case "authorization", "requestAuthorization": return .success("unsupported")
|
|
71
|
+
case "list", "schedule", "remove", "removeAll":
|
|
72
|
+
return .failure(CraftScheduledWorkoutsError(message: "unsupported", code: "UNSUPPORTED"))
|
|
73
|
+
default:
|
|
74
|
+
return .failure(CraftScheduledWorkoutsError(message: "Unsupported ScheduledWorkouts method: \(method)", code: "UNKNOWN_ACTION"))
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
#if CRAFT_SCHEDULED_WORKOUTS
|
|
80
|
+
|
|
81
|
+
/// What the scheduler keeps: a plan's id on a day, and whether it was done.
|
|
82
|
+
@available(iOS 17.0, *)
|
|
83
|
+
struct CraftScheduledWorkoutEntry {
|
|
84
|
+
let id: UUID
|
|
85
|
+
let date: DateComponents
|
|
86
|
+
let complete: Bool
|
|
87
|
+
/// The plan itself, which WorkoutKit needs to remove it; nil from the stand-in.
|
|
88
|
+
let plan: WorkoutPlan?
|
|
89
|
+
|
|
90
|
+
/// `{ id, date, hour?, minute?, complete }`, the id lowercase.
|
|
91
|
+
var json: [String: Any] {
|
|
92
|
+
var out: [String: Any] = ["id": id.uuidString.lowercased(), "complete": complete]
|
|
93
|
+
if let year = date.year, let month = date.month, let day = date.day {
|
|
94
|
+
out["date"] = String(format: "%04d-%02d-%02d", year, month, day)
|
|
95
|
+
}
|
|
96
|
+
if let hour = date.hour { out["hour"] = hour }
|
|
97
|
+
if let minute = date.minute { out["minute"] = minute }
|
|
98
|
+
return out
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/// WorkoutKit's scheduler, or the simulator's stand-in.
|
|
103
|
+
@available(iOS 17.0, *)
|
|
104
|
+
protocol CraftWorkoutStore: AnyObject {
|
|
105
|
+
var isSupported: Bool { get }
|
|
106
|
+
var maxCount: Int { get }
|
|
107
|
+
func authorization() async -> String
|
|
108
|
+
func requestAuthorization() async -> String
|
|
109
|
+
func entries() async -> [CraftScheduledWorkoutEntry]
|
|
110
|
+
func schedule(_ plan: WorkoutPlan, at date: DateComponents) async
|
|
111
|
+
func remove(_ entry: CraftScheduledWorkoutEntry) async
|
|
112
|
+
func removeAll() async
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
@available(iOS 17.0, *)
|
|
116
|
+
func craftWorkoutAuthorization(_ state: WorkoutScheduler.AuthorizationState) -> String {
|
|
117
|
+
switch state {
|
|
118
|
+
case .authorized: return "authorized"
|
|
119
|
+
case .denied: return "denied"
|
|
120
|
+
case .restricted: return "restricted"
|
|
121
|
+
case .notDetermined: return "notDetermined"
|
|
122
|
+
@unknown default: return "notDetermined"
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
@available(iOS 17.0, *)
|
|
127
|
+
final class CraftSystemWorkoutStore: CraftWorkoutStore {
|
|
128
|
+
var isSupported: Bool { WorkoutScheduler.isSupported }
|
|
129
|
+
var maxCount: Int { WorkoutScheduler.maxAllowedScheduledWorkoutCount }
|
|
130
|
+
func authorization() async -> String { craftWorkoutAuthorization(await WorkoutScheduler.shared.authorizationState) }
|
|
131
|
+
func requestAuthorization() async -> String { craftWorkoutAuthorization(await WorkoutScheduler.shared.requestAuthorization()) }
|
|
132
|
+
func entries() async -> [CraftScheduledWorkoutEntry] {
|
|
133
|
+
await WorkoutScheduler.shared.scheduledWorkouts.map {
|
|
134
|
+
CraftScheduledWorkoutEntry(id: $0.plan.id, date: $0.date, complete: $0.complete, plan: $0.plan)
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
func schedule(_ plan: WorkoutPlan, at date: DateComponents) async { await WorkoutScheduler.shared.schedule(plan, at: date) }
|
|
138
|
+
func remove(_ entry: CraftScheduledWorkoutEntry) async {
|
|
139
|
+
guard let plan = entry.plan else { return }
|
|
140
|
+
await WorkoutScheduler.shared.remove(plan, at: entry.date)
|
|
141
|
+
}
|
|
142
|
+
func removeAll() async { await WorkoutScheduler.shared.removeAllWorkouts() }
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/// Simulator builds only (see `CraftScheduledWorkouts`): plans kept in
|
|
146
|
+
/// UserDefaults, as a paired watch would keep them.
|
|
147
|
+
@available(iOS 17.0, *)
|
|
148
|
+
final class CraftFakeWorkoutStore: CraftWorkoutStore {
|
|
149
|
+
private let key = "CraftFakeScheduledWorkouts"
|
|
150
|
+
private let authorizationKey = "CraftFakeWorkoutSchedulerAuthorization"
|
|
151
|
+
private let defaults = UserDefaults.standard
|
|
152
|
+
let isSupported = true
|
|
153
|
+
var maxCount: Int { WorkoutScheduler.maxAllowedScheduledWorkoutCount }
|
|
154
|
+
|
|
155
|
+
func authorization() async -> String { defaults.string(forKey: authorizationKey) ?? "notDetermined" }
|
|
156
|
+
func requestAuthorization() async -> String {
|
|
157
|
+
let current = await authorization()
|
|
158
|
+
guard current == "notDetermined" else { return current }
|
|
159
|
+
defaults.set("authorized", forKey: authorizationKey)
|
|
160
|
+
return "authorized"
|
|
161
|
+
}
|
|
162
|
+
func entries() async -> [CraftScheduledWorkoutEntry] {
|
|
163
|
+
(defaults.array(forKey: key) as? [[String: Any]] ?? []).compactMap { row in
|
|
164
|
+
guard let id = (row["id"] as? String).flatMap(UUID.init(uuidString:)) else { return nil }
|
|
165
|
+
var date = DateComponents()
|
|
166
|
+
date.year = row["year"] as? Int
|
|
167
|
+
date.month = row["month"] as? Int
|
|
168
|
+
date.day = row["day"] as? Int
|
|
169
|
+
date.hour = row["hour"] as? Int
|
|
170
|
+
date.minute = row["minute"] as? Int
|
|
171
|
+
return CraftScheduledWorkoutEntry(id: id, date: date, complete: row["complete"] as? Bool ?? false, plan: nil)
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
func schedule(_ plan: WorkoutPlan, at date: DateComponents) async {
|
|
175
|
+
var rows = defaults.array(forKey: key) as? [[String: Any]] ?? []
|
|
176
|
+
var row: [String: Any] = ["id": plan.id.uuidString, "complete": false]
|
|
177
|
+
row["year"] = date.year
|
|
178
|
+
row["month"] = date.month
|
|
179
|
+
row["day"] = date.day
|
|
180
|
+
row["hour"] = date.hour
|
|
181
|
+
row["minute"] = date.minute
|
|
182
|
+
rows.append(row)
|
|
183
|
+
defaults.set(rows, forKey: key)
|
|
184
|
+
}
|
|
185
|
+
func remove(_ entry: CraftScheduledWorkoutEntry) async {
|
|
186
|
+
let rows = (defaults.array(forKey: key) as? [[String: Any]] ?? []).filter { row in
|
|
187
|
+
!((row["id"] as? String).flatMap(UUID.init(uuidString:)) == entry.id
|
|
188
|
+
&& row["year"] as? Int == entry.date.year && row["month"] as? Int == entry.date.month
|
|
189
|
+
&& row["day"] as? Int == entry.date.day && row["hour"] as? Int == entry.date.hour
|
|
190
|
+
&& row["minute"] as? Int == entry.date.minute)
|
|
191
|
+
}
|
|
192
|
+
defaults.set(rows, forKey: key)
|
|
193
|
+
}
|
|
194
|
+
func removeAll() async { defaults.removeObject(forKey: key) }
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/// The calls, on whichever store this build schedules with.
|
|
198
|
+
@available(iOS 17.0, *)
|
|
199
|
+
final class CraftWorkoutKitScheduler {
|
|
200
|
+
static let shared = CraftWorkoutKitScheduler(store: CraftWorkoutKitScheduler.makeStore())
|
|
201
|
+
|
|
202
|
+
private let store: CraftWorkoutStore
|
|
203
|
+
|
|
204
|
+
init(store: CraftWorkoutStore) { self.store = store }
|
|
205
|
+
|
|
206
|
+
private static func makeStore() -> CraftWorkoutStore {
|
|
207
|
+
#if targetEnvironment(simulator)
|
|
208
|
+
if UserDefaults.standard.bool(forKey: "CraftFakeWorkoutScheduler") { return CraftFakeWorkoutStore() }
|
|
209
|
+
#endif
|
|
210
|
+
return CraftSystemWorkoutStore()
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
private func failure(_ message: String, _ code: String) -> Result<Any, CraftScheduledWorkoutsError> {
|
|
214
|
+
.failure(CraftScheduledWorkoutsError(message: message, code: code))
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
func perform(method: String, options: [String: Any]) async -> Result<Any, CraftScheduledWorkoutsError> {
|
|
218
|
+
switch method {
|
|
219
|
+
case "isSupported":
|
|
220
|
+
return .success(store.isSupported)
|
|
221
|
+
case "authorization":
|
|
222
|
+
return .success(store.isSupported ? await store.authorization() : "unsupported")
|
|
223
|
+
case "requestAuthorization":
|
|
224
|
+
return .success(store.isSupported ? await store.requestAuthorization() : "unsupported")
|
|
225
|
+
case "list", "schedule", "remove", "removeAll":
|
|
226
|
+
guard store.isSupported else { return failure("unsupported", "UNSUPPORTED") }
|
|
227
|
+
guard await store.authorization() == "authorized" else {
|
|
228
|
+
return failure("Scheduling workouts on the Apple Watch has not been allowed", "NOT_AUTHORIZED")
|
|
229
|
+
}
|
|
230
|
+
switch method {
|
|
231
|
+
case "list": return .success(await store.entries().map(\.json))
|
|
232
|
+
case "schedule": return await schedule(options)
|
|
233
|
+
case "remove": return await remove(id: options["id"] as? String)
|
|
234
|
+
default:
|
|
235
|
+
let count = await store.entries().count
|
|
236
|
+
await store.removeAll()
|
|
237
|
+
return .success(count)
|
|
238
|
+
}
|
|
239
|
+
default:
|
|
240
|
+
return failure("Unsupported ScheduledWorkouts method: \(method)", "UNKNOWN_ACTION")
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
private func schedule(_ body: [String: Any]) async -> Result<Any, CraftScheduledWorkoutsError> {
|
|
245
|
+
let built: CraftWorkoutPlanBuilder.Built
|
|
246
|
+
switch CraftWorkoutPlanBuilder.build(body) {
|
|
247
|
+
case .success(let value): built = value
|
|
248
|
+
case .failure(let error): return .failure(error)
|
|
249
|
+
}
|
|
250
|
+
let existing = await store.entries().filter { $0.id == built.plan.id }
|
|
251
|
+
let sameDay = existing.contains { CraftWorkoutPlanBuilder.sameMoment($0.date, built.date) }
|
|
252
|
+
if !sameDay {
|
|
253
|
+
// A plan id lives on one day: scheduling it again moves it.
|
|
254
|
+
let others = await store.entries().count - existing.count
|
|
255
|
+
guard others < store.maxCount else {
|
|
256
|
+
return failure("The Apple Watch holds at most \(store.maxCount) scheduled workouts; remove one first", "LIMIT_REACHED")
|
|
257
|
+
}
|
|
258
|
+
for entry in existing { await store.remove(entry) }
|
|
259
|
+
await store.schedule(built.plan, at: built.date)
|
|
260
|
+
// WorkoutScheduler.schedule neither throws nor answers; what it
|
|
261
|
+
// kept is the answer (nothing, without a paired watch).
|
|
262
|
+
let kept = await store.entries().contains { $0.id == built.plan.id && CraftWorkoutPlanBuilder.sameMoment($0.date, built.date) }
|
|
263
|
+
guard kept else {
|
|
264
|
+
return failure("WorkoutKit did not keep the workout. Is an Apple Watch paired with the Workout app installed?", "NOT_SCHEDULED")
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
var out: [String: Any] = ["id": built.plan.id.uuidString.lowercased()]
|
|
268
|
+
if !built.droppedAlerts.isEmpty { out["droppedAlerts"] = built.droppedAlerts }
|
|
269
|
+
return .success(out)
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
private func remove(id: String?) async -> Result<Any, CraftScheduledWorkoutsError> {
|
|
273
|
+
guard let id, let uuid = UUID(uuidString: id) else {
|
|
274
|
+
return failure("remove needs a plan id (a UUID)", "INVALID_ARGUMENT")
|
|
275
|
+
}
|
|
276
|
+
let matching = await store.entries().filter { $0.id == uuid }
|
|
277
|
+
for entry in matching { await store.remove(entry) }
|
|
278
|
+
return .success(!matching.isEmpty)
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/// A plan's JSON turned into WorkoutKit's types, checked the way WorkoutKit
|
|
283
|
+
/// checks them (`CustomWorkout.supportsActivity`, `supportsGoal`,
|
|
284
|
+
/// `supportsAlert`). Pure: no scheduler, so it is unit tested on its own.
|
|
285
|
+
@available(iOS 17.0, *)
|
|
286
|
+
enum CraftWorkoutPlanBuilder {
|
|
287
|
+
struct Built {
|
|
288
|
+
let plan: WorkoutPlan
|
|
289
|
+
let date: DateComponents
|
|
290
|
+
/// Each alert WorkoutKit refused, by where it was ("block 1 step 2: power").
|
|
291
|
+
let droppedAlerts: [String]
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
static let activities: [String: HKWorkoutActivityType] = [
|
|
295
|
+
"running": .running, "cycling": .cycling, "swimming": .swimming, "walking": .walking,
|
|
296
|
+
"hiking": .hiking, "traditionalStrengthTraining": .traditionalStrengthTraining,
|
|
297
|
+
"functionalStrengthTraining": .functionalStrengthTraining, "rowing": .rowing, "yoga": .yoga,
|
|
298
|
+
"flexibility": .flexibility, "crossTraining": .crossTraining, "elliptical": .elliptical,
|
|
299
|
+
"highIntensityIntervalTraining": .highIntensityIntervalTraining, "other": .other,
|
|
300
|
+
"coreTraining": .coreTraining, "mixedCardio": .mixedCardio, "stairClimbing": .stairClimbing,
|
|
301
|
+
"stairs": .stairs, "stepTraining": .stepTraining, "pilates": .pilates, "cardioDance": .cardioDance,
|
|
302
|
+
"kickboxing": .kickboxing, "boxing": .boxing, "jumpRope": .jumpRope, "cooldown": .cooldown,
|
|
303
|
+
"crossCountrySkiing": .crossCountrySkiing, "paddleSports": .paddleSports,
|
|
304
|
+
"handCycling": .handCycling, "wheelchairRunPace": .wheelchairRunPace, "wheelchairWalkPace": .wheelchairWalkPace,
|
|
305
|
+
]
|
|
306
|
+
|
|
307
|
+
static let locations: [String: HKWorkoutSessionLocationType] = ["indoor": .indoor, "outdoor": .outdoor, "unknown": .unknown]
|
|
308
|
+
|
|
309
|
+
private static func refuse(_ message: String) -> Result<Built, CraftScheduledWorkoutsError> {
|
|
310
|
+
.failure(CraftScheduledWorkoutsError(message: message, code: "INVALID_ARGUMENT"))
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
private static func number(_ value: Any?) -> Double? {
|
|
314
|
+
// JSON's true and false arrive as NSNumber too; they are not numbers here.
|
|
315
|
+
guard let value = value as? NSNumber, CFGetTypeID(value) != CFBooleanGetTypeID() else { return nil }
|
|
316
|
+
let double = value.doubleValue
|
|
317
|
+
return double.isFinite ? double : nil
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/// Two schedule dates as WorkoutKit compares them: the day and the time of day.
|
|
321
|
+
static func sameMoment(_ a: DateComponents, _ b: DateComponents) -> Bool {
|
|
322
|
+
a.year == b.year && a.month == b.month && a.day == b.day && (a.hour ?? 0) == (b.hour ?? 0) && (a.minute ?? 0) == (b.minute ?? 0)
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/// 'YYYY-MM-DD' plus the time of day (07:00 when not given), local.
|
|
326
|
+
static func date(_ body: [String: Any]) -> DateComponents? {
|
|
327
|
+
guard let text = body["date"] as? String else { return nil }
|
|
328
|
+
let parts = text.split(separator: "-", omittingEmptySubsequences: false)
|
|
329
|
+
guard parts.count == 3, parts[0].count == 4, parts[1].count == 2, parts[2].count == 2,
|
|
330
|
+
let year = Int(parts[0]), let month = Int(parts[1]), let day = Int(parts[2]) else { return nil }
|
|
331
|
+
let hour = body["hour"] == nil || body["hour"] is NSNull ? 7 : number(body["hour"]).map { Int($0) }
|
|
332
|
+
let minute = body["minute"] == nil || body["minute"] is NSNull ? 0 : number(body["minute"]).map { Int($0) }
|
|
333
|
+
guard let hour, let minute, (0...23).contains(hour), (0...59).contains(minute) else { return nil }
|
|
334
|
+
var components = DateComponents()
|
|
335
|
+
components.year = year
|
|
336
|
+
components.month = month
|
|
337
|
+
components.day = day
|
|
338
|
+
// A day that does not exist (2026-02-30) is refused, not rolled over.
|
|
339
|
+
var calendar = Calendar(identifier: .gregorian)
|
|
340
|
+
calendar.timeZone = .current
|
|
341
|
+
guard let real = calendar.date(from: components),
|
|
342
|
+
calendar.component(.day, from: real) == day, calendar.component(.month, from: real) == month else { return nil }
|
|
343
|
+
components.hour = hour
|
|
344
|
+
components.minute = minute
|
|
345
|
+
return components
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
static func goal(_ value: Any?) -> Result<(goal: WorkoutGoal, name: String), CraftScheduledWorkoutsError> {
|
|
349
|
+
let body = value as? [String: Any] ?? [:]
|
|
350
|
+
let type = body["type"] as? String ?? (value == nil ? "open" : "")
|
|
351
|
+
func positive(_ key: String) -> Double? { number(body[key]).flatMap { $0 > 0 ? $0 : nil } }
|
|
352
|
+
switch type {
|
|
353
|
+
case "open": return .success((.open, "open"))
|
|
354
|
+
case "time":
|
|
355
|
+
guard let seconds = positive("seconds") else { break }
|
|
356
|
+
return .success((.time(seconds, .seconds), "time"))
|
|
357
|
+
case "distance":
|
|
358
|
+
guard let meters = positive("meters") else { break }
|
|
359
|
+
return .success((.distance(meters, .meters), "distance"))
|
|
360
|
+
case "energy":
|
|
361
|
+
guard let kilocalories = positive("kilocalories") else { break }
|
|
362
|
+
return .success((.energy(kilocalories, .kilocalories), "energy"))
|
|
363
|
+
default:
|
|
364
|
+
return .failure(CraftScheduledWorkoutsError(message: "Unknown goal type '\(type)'", code: "INVALID_ARGUMENT"))
|
|
365
|
+
}
|
|
366
|
+
return .failure(CraftScheduledWorkoutsError(message: "A \(type) goal needs a positive number", code: "INVALID_ARGUMENT"))
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/// The alert, or why it was left out.
|
|
370
|
+
static func alert(_ value: Any?) -> Result<(alert: any WorkoutAlert, name: String), CraftScheduledWorkoutsError> {
|
|
371
|
+
let body = value as? [String: Any] ?? [:]
|
|
372
|
+
let type = body["type"] as? String ?? ""
|
|
373
|
+
func invalid(_ why: String) -> Result<(alert: any WorkoutAlert, name: String), CraftScheduledWorkoutsError> {
|
|
374
|
+
.failure(CraftScheduledWorkoutsError(message: "\(type.isEmpty ? "alert" : type) \(why)", code: "INVALID_ARGUMENT"))
|
|
375
|
+
}
|
|
376
|
+
func range() -> ClosedRange<Double>? {
|
|
377
|
+
guard let a = number(body["min"]), let b = number(body["max"]), a >= 0, b >= 0, max(a, b) > 0 else { return nil }
|
|
378
|
+
return min(a, b)...max(a, b)
|
|
379
|
+
}
|
|
380
|
+
func zone() -> Int? {
|
|
381
|
+
guard let value = number(body["zone"]), value >= 1, value.rounded() == value else { return nil }
|
|
382
|
+
return Int(value)
|
|
383
|
+
}
|
|
384
|
+
switch type {
|
|
385
|
+
case "heartRate":
|
|
386
|
+
guard let range = range() else { return invalid("needs min and max") }
|
|
387
|
+
return .success((.heartRate(range), type))
|
|
388
|
+
case "heartRateZone":
|
|
389
|
+
guard let zone = zone() else { return invalid("needs a zone from 1") }
|
|
390
|
+
return .success((.heartRate(zone: zone), type))
|
|
391
|
+
case "power":
|
|
392
|
+
guard let range = range() else { return invalid("needs min and max") }
|
|
393
|
+
return .success((.power(range, unit: .watts), type))
|
|
394
|
+
case "powerZone":
|
|
395
|
+
guard let zone = zone() else { return invalid("needs a zone from 1") }
|
|
396
|
+
return .success((.power(zone: zone), type))
|
|
397
|
+
case "speed":
|
|
398
|
+
guard let range = range() else { return invalid("needs min and max") }
|
|
399
|
+
return .success((.speed(range, unit: .metersPerSecond, metric: .current), type))
|
|
400
|
+
case "cadence":
|
|
401
|
+
guard let range = range() else { return invalid("needs min and max") }
|
|
402
|
+
return .success((.cadence(range), type))
|
|
403
|
+
default:
|
|
404
|
+
return invalid("is not an alert WorkoutKit knows")
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
static func build(_ body: [String: Any]) -> Result<Built, CraftScheduledWorkoutsError> {
|
|
409
|
+
guard let idText = body["id"] as? String, let id = UUID(uuidString: idText) else {
|
|
410
|
+
return refuse("A scheduled workout needs an id (a UUID)")
|
|
411
|
+
}
|
|
412
|
+
guard let date = date(body) else {
|
|
413
|
+
return refuse("A scheduled workout needs a date ('YYYY-MM-DD') and, when given, an hour 0-23 and a minute 0-59")
|
|
414
|
+
}
|
|
415
|
+
guard let workout = body["workout"] as? [String: Any] else { return refuse("A scheduled workout needs a workout") }
|
|
416
|
+
let activityName = workout["activity"] as? String ?? ""
|
|
417
|
+
guard let activity = activities[activityName] else {
|
|
418
|
+
return refuse("WorkoutKit has no activity '\(activityName)'")
|
|
419
|
+
}
|
|
420
|
+
guard CustomWorkout.supportsActivity(activity) else {
|
|
421
|
+
return refuse("WorkoutKit cannot schedule a custom \(activityName) workout")
|
|
422
|
+
}
|
|
423
|
+
let locationName = workout["location"] as? String ?? "unknown"
|
|
424
|
+
guard var location = locations[locationName] else {
|
|
425
|
+
return refuse("A workout's location is indoor, outdoor or unknown, not '\(locationName)'")
|
|
426
|
+
}
|
|
427
|
+
// CustomWorkout has no swimming location of its own (and no pool
|
|
428
|
+
// length; the watch asks for that when the swim starts): a pool swim
|
|
429
|
+
// is indoor, open water outdoor.
|
|
430
|
+
if activity == .swimming, location == .unknown {
|
|
431
|
+
switch workout["swimmingLocation"] as? String {
|
|
432
|
+
case "openWater": location = .outdoor
|
|
433
|
+
case "pool", nil: location = .indoor
|
|
434
|
+
default: break
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
let context = "\(activityName) (\(locationName == "unknown" && location != .unknown ? (location == .indoor ? "indoor" : "outdoor") : locationName))"
|
|
438
|
+
var dropped: [String] = []
|
|
439
|
+
|
|
440
|
+
func step(_ value: Any?, at place: String) -> Result<WorkoutStep, CraftScheduledWorkoutsError> {
|
|
441
|
+
guard let body = value as? [String: Any] else {
|
|
442
|
+
return .failure(CraftScheduledWorkoutsError(message: "\(place) needs a goal", code: "INVALID_ARGUMENT"))
|
|
443
|
+
}
|
|
444
|
+
let parsedGoal: (goal: WorkoutGoal, name: String)
|
|
445
|
+
switch goal(body["goal"]) {
|
|
446
|
+
case .success(let value): parsedGoal = value
|
|
447
|
+
case .failure(let error): return .failure(CraftScheduledWorkoutsError(message: "\(place): \(error.message)", code: error.code))
|
|
448
|
+
}
|
|
449
|
+
guard CustomWorkout.supportsGoal(parsedGoal.goal, activity: activity, location: location) else {
|
|
450
|
+
return .failure(CraftScheduledWorkoutsError(message: "WorkoutKit refuses a \(parsedGoal.name) goal for \(context) (\(place))", code: "UNSUPPORTED_GOAL"))
|
|
451
|
+
}
|
|
452
|
+
var result = WorkoutStep(goal: parsedGoal.goal)
|
|
453
|
+
if let alertBody = body["alert"], !(alertBody is NSNull) {
|
|
454
|
+
switch alert(alertBody) {
|
|
455
|
+
case .success(let parsed):
|
|
456
|
+
if CustomWorkout.supportsAlert(parsed.alert, activity: activity, location: location) {
|
|
457
|
+
result.alert = parsed.alert
|
|
458
|
+
} else {
|
|
459
|
+
dropped.append("\(place): \(parsed.name) alert, which WorkoutKit refuses for \(context)")
|
|
460
|
+
}
|
|
461
|
+
case .failure(let error):
|
|
462
|
+
dropped.append("\(place): \(error.message)")
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
if #available(iOS 18.0, *), let name = (body["displayName"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines), !name.isEmpty {
|
|
466
|
+
result.displayName = String(name.prefix(64))
|
|
467
|
+
}
|
|
468
|
+
return .success(result)
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
var warmup: WorkoutStep?
|
|
472
|
+
if let value = workout["warmup"], !(value is NSNull) {
|
|
473
|
+
switch step(value, at: "warmup") {
|
|
474
|
+
case .success(let parsed): warmup = parsed
|
|
475
|
+
case .failure(let error): return .failure(error)
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
var cooldown: WorkoutStep?
|
|
479
|
+
if let value = workout["cooldown"], !(value is NSNull) {
|
|
480
|
+
switch step(value, at: "cooldown") {
|
|
481
|
+
case .success(let parsed): cooldown = parsed
|
|
482
|
+
case .failure(let error): return .failure(error)
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
var blocks: [IntervalBlock] = []
|
|
486
|
+
for (blockIndex, value) in (workout["blocks"] as? [Any] ?? []).enumerated() {
|
|
487
|
+
let block = value as? [String: Any] ?? [:]
|
|
488
|
+
let iterations = number(block["iterations"] ?? 1).map { Int($0) } ?? 0
|
|
489
|
+
guard iterations >= 1 else { return refuse("block \(blockIndex + 1) needs iterations of 1 or more") }
|
|
490
|
+
var steps: [IntervalStep] = []
|
|
491
|
+
for (stepIndex, entry) in (block["steps"] as? [Any] ?? []).enumerated() {
|
|
492
|
+
let place = "block \(blockIndex + 1) step \(stepIndex + 1)"
|
|
493
|
+
let entry = entry as? [String: Any] ?? [:]
|
|
494
|
+
let purpose: IntervalStep.Purpose
|
|
495
|
+
switch entry["purpose"] as? String {
|
|
496
|
+
case "work": purpose = .work
|
|
497
|
+
case "recovery": purpose = .recovery
|
|
498
|
+
default: return refuse("\(place) needs a purpose: work or recovery")
|
|
499
|
+
}
|
|
500
|
+
switch step(entry["step"], at: place) {
|
|
501
|
+
case .success(let parsed): steps.append(IntervalStep(purpose, step: parsed))
|
|
502
|
+
case .failure(let error): return .failure(error)
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
guard !steps.isEmpty else { return refuse("block \(blockIndex + 1) has no steps") }
|
|
506
|
+
blocks.append(IntervalBlock(steps: steps, iterations: iterations))
|
|
507
|
+
}
|
|
508
|
+
guard warmup != nil || cooldown != nil || !blocks.isEmpty else {
|
|
509
|
+
return refuse("A scheduled workout needs at least one step")
|
|
510
|
+
}
|
|
511
|
+
let displayName = (workout["displayName"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
512
|
+
let custom = CustomWorkout(
|
|
513
|
+
activity: activity,
|
|
514
|
+
location: location,
|
|
515
|
+
displayName: displayName?.isEmpty == false ? displayName : nil,
|
|
516
|
+
warmup: warmup,
|
|
517
|
+
blocks: blocks,
|
|
518
|
+
cooldown: cooldown
|
|
519
|
+
)
|
|
520
|
+
return .success(Built(plan: WorkoutPlan(.custom(custom), id: id), date: date, droppedAlerts: dropped))
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
#endif
|
package/dist/mobile.js
CHANGED
|
@@ -981,6 +981,72 @@ var heartRate = {
|
|
|
981
981
|
onDevice: (callback) => hrSubscribe(heartRateWatch.devices, callback),
|
|
982
982
|
onState: (callback) => hrSubscribe(heartRateWatch.states, callback)
|
|
983
983
|
};
|
|
984
|
+
var SCHEDULED_AUTHORIZATIONS = ["authorized", "denied", "notDetermined", "restricted", "unsupported"];
|
|
985
|
+
function nativeScheduledWorkouts() {
|
|
986
|
+
const craft = getCraftRoot();
|
|
987
|
+
if (!craft?.scheduledWorkouts || craft.capabilities?.scheduledWorkouts !== true)
|
|
988
|
+
return;
|
|
989
|
+
return craft.scheduledWorkouts;
|
|
990
|
+
}
|
|
991
|
+
function scheduledWorkoutsUnsupported() {
|
|
992
|
+
return Object.assign(new Error("unsupported"), { code: "UNSUPPORTED" });
|
|
993
|
+
}
|
|
994
|
+
function scheduledAuthorization(value) {
|
|
995
|
+
return SCHEDULED_AUTHORIZATIONS.includes(value) ? value : "unsupported";
|
|
996
|
+
}
|
|
997
|
+
function normalizeScheduledWorkout(value) {
|
|
998
|
+
if (!value || typeof value !== "object")
|
|
999
|
+
return null;
|
|
1000
|
+
const row = value;
|
|
1001
|
+
if (typeof row.id !== "string" || !row.id || typeof row.date !== "string" || !/^\d{4}-\d{2}-\d{2}$/.test(row.date))
|
|
1002
|
+
return null;
|
|
1003
|
+
const out = { id: row.id.toLowerCase(), date: row.date, complete: row.complete === true };
|
|
1004
|
+
if (typeof row.hour === "number" && Number.isFinite(row.hour))
|
|
1005
|
+
out.hour = row.hour;
|
|
1006
|
+
if (typeof row.minute === "number" && Number.isFinite(row.minute))
|
|
1007
|
+
out.minute = row.minute;
|
|
1008
|
+
return out;
|
|
1009
|
+
}
|
|
1010
|
+
async function scheduledCall(method, normalize, ...args) {
|
|
1011
|
+
const native = nativeScheduledWorkouts();
|
|
1012
|
+
if (typeof native?.[method] !== "function")
|
|
1013
|
+
throw scheduledWorkoutsUnsupported();
|
|
1014
|
+
return normalize(await native[method](...args));
|
|
1015
|
+
}
|
|
1016
|
+
var scheduledWorkouts = {
|
|
1017
|
+
isAvailable() {
|
|
1018
|
+
return typeof nativeScheduledWorkouts()?.isSupported === "function";
|
|
1019
|
+
},
|
|
1020
|
+
supports(method) {
|
|
1021
|
+
return typeof nativeScheduledWorkouts()?.[method] === "function";
|
|
1022
|
+
},
|
|
1023
|
+
async isSupported() {
|
|
1024
|
+
const native = nativeScheduledWorkouts();
|
|
1025
|
+
if (typeof native?.isSupported !== "function")
|
|
1026
|
+
return false;
|
|
1027
|
+
try {
|
|
1028
|
+
return await native.isSupported() === true;
|
|
1029
|
+
} catch {
|
|
1030
|
+
return false;
|
|
1031
|
+
}
|
|
1032
|
+
},
|
|
1033
|
+
authorization: () => scheduledCall("authorization", scheduledAuthorization),
|
|
1034
|
+
requestAuthorization: () => scheduledCall("requestAuthorization", scheduledAuthorization),
|
|
1035
|
+
list: () => scheduledCall("list", (value) => Array.isArray(value) ? value.map(normalizeScheduledWorkout).filter((row) => row !== null) : []),
|
|
1036
|
+
schedule: (plan) => {
|
|
1037
|
+
if (!plan || typeof plan !== "object" || typeof plan.id !== "string")
|
|
1038
|
+
return Promise.reject(Object.assign(new Error("schedule needs a plan with an id"), { code: "INVALID_ARGUMENT" }));
|
|
1039
|
+
return scheduledCall("schedule", (value) => {
|
|
1040
|
+
const row = value && typeof value === "object" ? value : {};
|
|
1041
|
+
const out = { id: typeof row.id === "string" ? row.id.toLowerCase() : plan.id.toLowerCase() };
|
|
1042
|
+
if (Array.isArray(row.droppedAlerts) && row.droppedAlerts.length)
|
|
1043
|
+
out.droppedAlerts = row.droppedAlerts.filter((item) => typeof item === "string");
|
|
1044
|
+
return out;
|
|
1045
|
+
}, plan);
|
|
1046
|
+
},
|
|
1047
|
+
remove: (id) => scheduledCall("remove", (value) => value === true, id),
|
|
1048
|
+
removeAll: () => scheduledCall("removeAll", (value) => typeof value === "number" && Number.isFinite(value) ? value : 0)
|
|
1049
|
+
};
|
|
984
1050
|
var deepLinks = {
|
|
985
1051
|
async getInitialURL() {
|
|
986
1052
|
return normalizeDeepLinkURL(await getCraftRoot()?.deepLinks?.getInitialURL?.());
|
|
@@ -1319,6 +1385,12 @@ var health = {
|
|
|
1319
1385
|
throw new Error("Health data is unavailable");
|
|
1320
1386
|
return craft.health.getData(type, options);
|
|
1321
1387
|
},
|
|
1388
|
+
async saveData(type, value, options = {}) {
|
|
1389
|
+
const craft = getCraftRoot();
|
|
1390
|
+
if (!craft?.health?.saveData)
|
|
1391
|
+
throw new Error("Saving health data is unavailable");
|
|
1392
|
+
return craft.health.saveData(type, value, options);
|
|
1393
|
+
},
|
|
1322
1394
|
async saveWorkout(workout) {
|
|
1323
1395
|
const craft = getCraftRoot();
|
|
1324
1396
|
if (!craft?.health?.saveWorkout)
|
|
@@ -1479,10 +1551,13 @@ export {
|
|
|
1479
1551
|
snapshots,
|
|
1480
1552
|
share,
|
|
1481
1553
|
secureStorage,
|
|
1554
|
+
scheduledWorkoutsUnsupported,
|
|
1555
|
+
scheduledWorkouts,
|
|
1482
1556
|
refresh,
|
|
1483
1557
|
pushNotifications,
|
|
1484
1558
|
permissions,
|
|
1485
1559
|
notifications,
|
|
1560
|
+
normalizeScheduledWorkout,
|
|
1486
1561
|
normalizeNowPlaying,
|
|
1487
1562
|
normalizeHeartRateState,
|
|
1488
1563
|
normalizeHeartRateSamples,
|