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:
Sylvain Bettinelli
2026-05-08 09:28:15 +00:00
parent fc3ca4ed64
commit 2d3e149c0a
2 changed files with 156 additions and 0 deletions

View File

@@ -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;
};

View 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)
}
}