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 */; };
|
50379B232058CBB4000EE86E /* capacitor.config.json in Resources */ = {isa = PBXBuildFile; fileRef = 50379B222058CBB4000EE86E /* capacitor.config.json */; };
|
||||||
504EC3081FED79650016851F /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 504EC3071FED79650016851F /* AppDelegate.swift */; };
|
504EC3081FED79650016851F /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 504EC3071FED79650016851F /* AppDelegate.swift */; };
|
||||||
C0AC4A07811751FB78AA0001 /* CoachAuth.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0AC4A07811751FB78AA0002 /* CoachAuth.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 */; };
|
504EC30D1FED79650016851F /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 504EC30B1FED79650016851F /* Main.storyboard */; };
|
||||||
504EC30F1FED79650016851F /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 504EC30E1FED79650016851F /* Assets.xcassets */; };
|
504EC30F1FED79650016851F /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 504EC30E1FED79650016851F /* Assets.xcassets */; };
|
||||||
504EC3121FED79650016851F /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 504EC3101FED79650016851F /* LaunchScreen.storyboard */; };
|
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; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
504EC3111FED79650016851F /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
|
||||||
@@ -69,6 +71,7 @@
|
|||||||
50379B222058CBB4000EE86E /* capacitor.config.json */,
|
50379B222058CBB4000EE86E /* capacitor.config.json */,
|
||||||
504EC3071FED79650016851F /* AppDelegate.swift */,
|
504EC3071FED79650016851F /* AppDelegate.swift */,
|
||||||
C0AC4A07811751FB78AA0002 /* CoachAuth.swift */,
|
C0AC4A07811751FB78AA0002 /* CoachAuth.swift */,
|
||||||
|
D1BD5B1990CF3F2B9C5D0002 /* CoachWorkoutKit.swift */,
|
||||||
504EC30B1FED79650016851F /* Main.storyboard */,
|
504EC30B1FED79650016851F /* Main.storyboard */,
|
||||||
504EC30E1FED79650016851F /* Assets.xcassets */,
|
504EC30E1FED79650016851F /* Assets.xcassets */,
|
||||||
504EC3101FED79650016851F /* LaunchScreen.storyboard */,
|
504EC3101FED79650016851F /* LaunchScreen.storyboard */,
|
||||||
@@ -162,6 +165,7 @@
|
|||||||
files = (
|
files = (
|
||||||
504EC3081FED79650016851F /* AppDelegate.swift in Sources */,
|
504EC3081FED79650016851F /* AppDelegate.swift in Sources */,
|
||||||
C0AC4A07811751FB78AA0001 /* CoachAuth.swift in Sources */,
|
C0AC4A07811751FB78AA0001 /* CoachAuth.swift in Sources */,
|
||||||
|
D1BD5B1990CF3F2B9C5D0001 /* CoachWorkoutKit.swift in Sources */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
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