From 2d3e149c0a4df2dd3f0dcb53c4e393fed787f4cc Mon Sep 17 00:00:00 2001 From: Sylvain Bettinelli Date: Fri, 8 May 2026 09:28:15 +0000 Subject: [PATCH] Plugin natif WorkoutKit : pousse workouts intervalles vers Apple Watch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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}. --- ios/App/App.xcodeproj/project.pbxproj | 4 + ios/App/App/CoachWorkoutKit.swift | 152 ++++++++++++++++++++++++++ 2 files changed, 156 insertions(+) create mode 100644 ios/App/App/CoachWorkoutKit.swift diff --git a/ios/App/App.xcodeproj/project.pbxproj b/ios/App/App.xcodeproj/project.pbxproj index 8d768b5..7a67dcd 100644 --- a/ios/App/App.xcodeproj/project.pbxproj +++ b/ios/App/App.xcodeproj/project.pbxproj @@ -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 = ""; }; C0AC4A07811751FB78AA0002 /* CoachAuth.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoachAuth.swift; sourceTree = ""; }; + D1BD5B1990CF3F2B9C5D0002 /* CoachWorkoutKit.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoachWorkoutKit.swift; sourceTree = ""; }; 504EC30C1FED79650016851F /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; 504EC30E1FED79650016851F /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 504EC3111FED79650016851F /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; @@ -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; }; diff --git a/ios/App/App/CoachWorkoutKit.swift b/ios/App/App/CoachWorkoutKit.swift new file mode 100644 index 0000000..7a8571c --- /dev/null +++ b/ios/App/App/CoachWorkoutKit.swift @@ -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) + } +}