Plugin natif WorkoutKit : pousse workouts intervalles vers Apple Watch
Plugin Capacitor Swift in-app (ios/App/App/CoachWorkoutKit.swift) qui utilise
le framework Apple WorkoutKit (iOS 17+ / watchOS 10+) pour construire un
CustomWorkout structuré (warmup + IntervalBlock work/rest répété + cooldown)
et présenter la sheet système iOS qui propose à l'user d'ajouter le workout
à ses workouts personnalisés Apple Watch.
API JS (côté WebView) :
Capacitor.Plugins.CoachWorkoutKit.isAvailable()
Capacitor.Plugins.CoachWorkoutKit.sendInterval({
activity, displayName, warmupMin, work, rest, repeats, cooldownMin
})
Activity supportées : running / cycling / walking / hiking
HR zones (1-5) optionnelles via HeartRateZoneAlert sur work + rest steps.
Workflow attendu user :
1. coach.hypnotruck.ch → /settings ou bouton 'Envoyer Apple Watch' depuis /calendar
2. JS appelle WK.sendInterval(...)
3. Plugin Swift construit CustomWorkout + IntervalBlock + alerts
4. WorkoutScheduler.shared.preview(plan) ouvre la sheet iOS native
5. User tape 'Ajouter aux workouts' → workout dispo dans Apple Watch
→ Exercice → Course → Workouts personnalisés → [displayName]
Build à faire sur le Mac (cap sync ios + Xcode build → install iPhone).
Phase 1 = MVP plugin + bouton test sur /settings de coach.hypnotruck.ch.
Phase 2 = bouton 'Envoyer Apple Watch' sur card du jour /calendar (convertit
la séance prévue en CustomWorkout).
Phase 3 = bibliothèque templates intervalles (10x400m, fartlek, etc.).
Project.pbxproj : ajouté 4 entrées (PBXBuildFile + PBXFileReference + group +
Sources phase) avec IDs D1BD5B1990CF3F2B9C5D000{1,2}.
This commit is contained in:
@@ -12,6 +12,7 @@
|
||||
50379B232058CBB4000EE86E /* capacitor.config.json in Resources */ = {isa = PBXBuildFile; fileRef = 50379B222058CBB4000EE86E /* capacitor.config.json */; };
|
||||
504EC3081FED79650016851F /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 504EC3071FED79650016851F /* AppDelegate.swift */; };
|
||||
C0AC4A07811751FB78AA0001 /* CoachAuth.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0AC4A07811751FB78AA0002 /* CoachAuth.swift */; };
|
||||
D1BD5B1990CF3F2B9C5D0001 /* CoachWorkoutKit.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1BD5B1990CF3F2B9C5D0002 /* CoachWorkoutKit.swift */; };
|
||||
504EC30D1FED79650016851F /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 504EC30B1FED79650016851F /* Main.storyboard */; };
|
||||
504EC30F1FED79650016851F /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 504EC30E1FED79650016851F /* Assets.xcassets */; };
|
||||
504EC3121FED79650016851F /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 504EC3101FED79650016851F /* LaunchScreen.storyboard */; };
|
||||
@@ -24,6 +25,7 @@
|
||||
504EC3041FED79650016851F /* App.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = App.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
504EC3071FED79650016851F /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
|
||||
C0AC4A07811751FB78AA0002 /* CoachAuth.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoachAuth.swift; sourceTree = "<group>"; };
|
||||
D1BD5B1990CF3F2B9C5D0002 /* CoachWorkoutKit.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoachWorkoutKit.swift; sourceTree = "<group>"; };
|
||||
504EC30C1FED79650016851F /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = "<group>"; };
|
||||
504EC30E1FED79650016851F /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
||||
504EC3111FED79650016851F /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
|
||||
@@ -69,6 +71,7 @@
|
||||
50379B222058CBB4000EE86E /* capacitor.config.json */,
|
||||
504EC3071FED79650016851F /* AppDelegate.swift */,
|
||||
C0AC4A07811751FB78AA0002 /* CoachAuth.swift */,
|
||||
D1BD5B1990CF3F2B9C5D0002 /* CoachWorkoutKit.swift */,
|
||||
504EC30B1FED79650016851F /* Main.storyboard */,
|
||||
504EC30E1FED79650016851F /* Assets.xcassets */,
|
||||
504EC3101FED79650016851F /* LaunchScreen.storyboard */,
|
||||
@@ -162,6 +165,7 @@
|
||||
files = (
|
||||
504EC3081FED79650016851F /* AppDelegate.swift in Sources */,
|
||||
C0AC4A07811751FB78AA0001 /* CoachAuth.swift in Sources */,
|
||||
D1BD5B1990CF3F2B9C5D0001 /* CoachWorkoutKit.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
|
||||
152
ios/App/App/CoachWorkoutKit.swift
Normal file
152
ios/App/App/CoachWorkoutKit.swift
Normal file
@@ -0,0 +1,152 @@
|
||||
// CoachWorkoutKit.swift
|
||||
// Plugin Capacitor natif iOS 17+ pour pousser des workouts structurés (warmup +
|
||||
// intervals + cooldown) vers l'app Exercice de l'Apple Watch via WorkoutKit.
|
||||
//
|
||||
// Usage côté JS (dans la WebView) :
|
||||
// await window.Capacitor.Plugins.CoachWorkoutKit.isAvailable()
|
||||
// → { available: bool, reason?: string }
|
||||
// await window.Capacitor.Plugins.CoachWorkoutKit.sendInterval({
|
||||
// activity: 'running' | 'cycling' | 'walking' | 'hiking',
|
||||
// displayName: 'Coach séance 6×3min Z4',
|
||||
// warmupMin: 10,
|
||||
// work: { duration_min: 3, hr_zone: 4 },
|
||||
// rest: { duration_min: 1.5, hr_zone: 2 },
|
||||
// repeats: 6,
|
||||
// cooldownMin: 10,
|
||||
// })
|
||||
// → { sent: true } si l'user a tapé "Ajouter aux workouts" dans la sheet
|
||||
// → reject avec error string sinon
|
||||
//
|
||||
// L'app présente une sheet système iOS qui demande à l'user de confirmer
|
||||
// l'ajout du workout au Watch. Le workout devient alors disponible dans
|
||||
// Apple Watch → Exercice → Workouts personnalisés → [displayName].
|
||||
|
||||
import Foundation
|
||||
import Capacitor
|
||||
#if canImport(WorkoutKit)
|
||||
import WorkoutKit
|
||||
import HealthKit
|
||||
#endif
|
||||
|
||||
@objc(CoachWorkoutKitPlugin)
|
||||
public class CoachWorkoutKitPlugin: CAPPlugin {
|
||||
|
||||
@objc func isAvailable(_ call: CAPPluginCall) {
|
||||
if #available(iOS 17.0, *) {
|
||||
call.resolve(["available": true, "min_ios": "17.0"])
|
||||
} else {
|
||||
call.resolve(["available": false, "reason": "iOS 17+ required for WorkoutKit"])
|
||||
}
|
||||
}
|
||||
|
||||
@objc func sendInterval(_ call: CAPPluginCall) {
|
||||
guard #available(iOS 17.0, *) else {
|
||||
call.reject("WorkoutKit requires iOS 17.0 or later")
|
||||
return
|
||||
}
|
||||
|
||||
let activityStr = call.getString("activity") ?? "running"
|
||||
let displayName = call.getString("displayName") ?? "Coach séance"
|
||||
let warmupMin = call.getDouble("warmupMin") ?? 10
|
||||
let cooldownMin = call.getDouble("cooldownMin") ?? 10
|
||||
let repeats = max(1, min(50, call.getInt("repeats") ?? 6))
|
||||
|
||||
let workDict = call.getObject("work") ?? [:]
|
||||
let workMin = workDict["duration_min"] as? Double ?? 3
|
||||
let workHrZone = workDict["hr_zone"] as? Int
|
||||
|
||||
let restDict = call.getObject("rest") ?? [:]
|
||||
let restMin = restDict["duration_min"] as? Double ?? 1.5
|
||||
let restHrZone = restDict["hr_zone"] as? Int
|
||||
|
||||
let activity = workoutActivity(from: activityStr)
|
||||
|
||||
Task {
|
||||
do {
|
||||
try await sendCustomWorkout(
|
||||
activity: activity,
|
||||
displayName: displayName,
|
||||
warmupMin: warmupMin,
|
||||
workMin: workMin,
|
||||
workHrZone: workHrZone,
|
||||
restMin: restMin,
|
||||
restHrZone: restHrZone,
|
||||
repeats: repeats,
|
||||
cooldownMin: cooldownMin
|
||||
)
|
||||
call.resolve(["sent": true])
|
||||
} catch {
|
||||
call.reject("Failed to send workout: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func workoutActivity(from str: String) -> HKWorkoutActivityType {
|
||||
switch str.lowercased() {
|
||||
case "cycling": return .cycling
|
||||
case "walking": return .walking
|
||||
case "hiking": return .hiking
|
||||
case "running": return .running
|
||||
default: return .running
|
||||
}
|
||||
}
|
||||
|
||||
@available(iOS 17.0, *)
|
||||
private func sendCustomWorkout(
|
||||
activity: HKWorkoutActivityType,
|
||||
displayName: String,
|
||||
warmupMin: Double,
|
||||
workMin: Double,
|
||||
workHrZone: Int?,
|
||||
restMin: Double,
|
||||
restHrZone: Int?,
|
||||
repeats: Int,
|
||||
cooldownMin: Double
|
||||
) async throws {
|
||||
// 1. Warmup step (1 fois)
|
||||
let warmupStep = WorkoutStep(goal: .time(warmupMin * 60, .seconds))
|
||||
|
||||
// 2. Work + Rest interval block (répété N fois)
|
||||
var workAlerts: [any WorkoutAlertEnumeration] = []
|
||||
if let zone = workHrZone {
|
||||
workAlerts.append(HeartRateZoneAlert(zone: zone))
|
||||
}
|
||||
let workStep = IntervalStep(
|
||||
.work,
|
||||
goal: .time(workMin * 60, .seconds),
|
||||
alert: workAlerts.first as? (any WorkoutAlertEnumeration)
|
||||
)
|
||||
|
||||
var restAlerts: [any WorkoutAlertEnumeration] = []
|
||||
if let zone = restHrZone {
|
||||
restAlerts.append(HeartRateZoneAlert(zone: zone))
|
||||
}
|
||||
let restStep = IntervalStep(
|
||||
.recovery,
|
||||
goal: .time(restMin * 60, .seconds),
|
||||
alert: restAlerts.first as? (any WorkoutAlertEnumeration)
|
||||
)
|
||||
|
||||
let intervalBlock = IntervalBlock(
|
||||
steps: [workStep, restStep],
|
||||
iterations: repeats
|
||||
)
|
||||
|
||||
// 3. Cooldown step
|
||||
let cooldownStep = WorkoutStep(goal: .time(cooldownMin * 60, .seconds))
|
||||
|
||||
// 4. Custom workout
|
||||
let custom = CustomWorkout(
|
||||
activity: activity,
|
||||
location: .outdoor,
|
||||
displayName: displayName,
|
||||
warmup: warmupStep,
|
||||
blocks: [intervalBlock],
|
||||
cooldown: cooldownStep
|
||||
)
|
||||
|
||||
// 5. Plan + preview (présente la sheet système "Ajouter à mes workouts")
|
||||
let plan = WorkoutPlan(.custom(custom))
|
||||
try await WorkoutScheduler.shared.preview(plan)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user