From acbb2c68968ad5b6a6e1aa9a2663a4a1b2733bfa Mon Sep 17 00:00:00 2001 From: Sylvain Bettinelli Date: Thu, 20 Aug 2026 16:36:03 +0000 Subject: [PATCH] La montre enregistre enfin sa trace GPS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit La cible watchOS ne contenait pas une ligne de localisation : ni CLLocationManager, ni HKWorkoutRouteBuilder, et aucune clé NSLocation dans son Info.plist. Tant que les séances partaient de l'app Exercice d'Apple, c'est elle qui écrivait la trace et CoachHealthRoute la relisait après coup. Dès qu'une séance démarre depuis CoachWatch, plus personne ne l'écrit : la sortie n'aurait ni carte, ni parcours dans Santé. Le filtrage vit dans `RouteFilter`, en Foundation pur, donc testable ici : rejet des points imprécis (> 50 m, seuil de l'exemple Apple), des sauts impossibles, des doublons et des points arrivés dans le désordre, puis cumul de la distance (haversine) et du dénivelé. Le dénivelé a demandé deux passes. L'hystérésis seule laissait passer 297 m de D+ sur un parcours PLAT : une oscillation d'amplitude égale au seuil est comptée à chaque alternance, ce qui est inhérent à tout seuil. D'où un lissage préalable, qui annule le bruit alternant. Contrepartie assumée et testée : une pointe franchie en quelques points est écrêtée, donc sous-comptée — sans conséquence, le D+ qui fait foi restant celui, barométrique, qu'Apple écrit dans les métadonnées de la séance. Quatre pièges documentés, tous traités dans le code : - `allowsBackgroundLocationUpdates` sans `UIBackgroundModes` = `location` TERMINE l'app. Un garde-fou vérifie le plist avant d'armer le drapeau. - Ne jamais demander « Always » sur watchOS : Apple décrit ce prompt comme « mostly a placeholder » et le parcours comme un comportement indéfini. - Interdit de relancer la localisation depuis l'arrière-plan : la pause garde le flux ouvert et ignore les points, au lieu de couper le manager. - Le CPU tue le GPS — d'où l'insertion des points par lots et la trace d'affichage bornée à 1500 points. Corrigé après lecture de la doc : `finishRoute` s'appelle APRÈS `finishWorkout` et reçoit le workout pour s'y associer. J'avais écrit l'inverse, avec nil, ce qui aurait perdu l'association à chaque sortie. Et `finishWorkout()` rend nil sans erreur quand la montre est verrouillée : on sauvegarde alors la trace sans association plutôt que de la jeter. 15 tests, dont 4 vérifiés rouges en désactivant le lissage. Les trois fichiers sont déclarés dans la cible CoachWatch (pbxproj sauvegardé en .bak-outdoor) : aucun « Add Files » à faire sur le Mac. Co-Authored-By: Claude Opus 5 (1M context) --- ios/App/App.xcodeproj/project.pbxproj | 12 + .../App.xcodeproj/project.pbxproj.bak-outdoor | 1093 +++++++++++++++++ ios/App/CoachWatch/Info.plist | 12 + ios/App/CoachWatch/LocationTracker.swift | 318 +++++ ios/App/CoachWatch/RouteFilter.swift | 211 ++++ ios/App/CoachWatch/WorkoutManager.swift | 49 +- .../Sources/CoachModel/RouteFilter.swift | 1 + .../CoachModelTests/RouteFilterTests.swift | 206 ++++ 8 files changed, 1898 insertions(+), 4 deletions(-) create mode 100644 ios/App/App.xcodeproj/project.pbxproj.bak-outdoor create mode 100644 ios/App/CoachWatch/LocationTracker.swift create mode 100644 ios/App/CoachWatch/RouteFilter.swift create mode 120000 tests-linux/Sources/CoachModel/RouteFilter.swift create mode 100644 tests-linux/Tests/CoachModelTests/RouteFilterTests.swift diff --git a/ios/App/App.xcodeproj/project.pbxproj b/ios/App/App.xcodeproj/project.pbxproj index ceaee0d..67d334b 100644 --- a/ios/App/App.xcodeproj/project.pbxproj +++ b/ios/App/App.xcodeproj/project.pbxproj @@ -18,6 +18,9 @@ 2F86A358498667B66E5EFC60 /* CoachRoutineBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = 97BC6E07E6984342AE95C312 /* CoachRoutineBridge.swift */; }; 2FAD9763203C412B000D30F8 /* config.xml in Resources */ = {isa = PBXBuildFile; fileRef = 2FAD9762203C412B000D30F8 /* config.xml */; }; 3E6740C521EF14892687BCF7 /* CoachWatch.app in Embed Watch Content */ = {isa = PBXBuildFile; fileRef = 2517374227A4072A4D6AFC15 /* CoachWatch.app */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; + C0AC4E1Z0011OUTDOOR0001 /* IntervalEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0AC4E1Z0010OUTDOOR0001 /* IntervalEngine.swift */; }; + C0AC4E1Z0011OUTDOOR0002 /* RouteFilter.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0AC4E1Z0010OUTDOOR0002 /* RouteFilter.swift */; }; + C0AC4E1Z0011OUTDOOR0003 /* LocationTracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0AC4E1Z0010OUTDOOR0003 /* LocationTracker.swift */; }; 436442D6BF54833C59D9E668 /* WorkoutManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8AC9C9B9971F55027D227A06 /* WorkoutManager.swift */; }; 4D22ABE92AF431CB00220026 /* CapApp-SPM in Frameworks */ = {isa = PBXBuildFile; productRef = 4D22ABE82AF431CB00220026 /* CapApp-SPM */; }; 50379B232058CBB4000EE86E /* capacitor.config.json in Resources */ = {isa = PBXBuildFile; fileRef = 50379B222058CBB4000EE86E /* capacitor.config.json */; }; @@ -162,6 +165,9 @@ 88D1DD5D302B46B000B9BA42 /* CoachAuth.swift.example */ = {isa = PBXFileReference; lastKnownFileType = text; path = CoachAuth.swift.example; sourceTree = ""; }; 88F28EB02FBB330E00E8306E /* CoachWorkoutObserver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoachWorkoutObserver.swift; sourceTree = ""; }; 8A06CDFF5B4F7852B6329862 /* ContentView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; + C0AC4E1Z0010OUTDOOR0001 /* IntervalEngine.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = IntervalEngine.swift; sourceTree = ""; }; + C0AC4E1Z0010OUTDOOR0002 /* RouteFilter.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RouteFilter.swift; sourceTree = ""; }; + C0AC4E1Z0010OUTDOOR0003 /* LocationTracker.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = LocationTracker.swift; sourceTree = ""; }; 8AC9C9B9971F55027D227A06 /* WorkoutManager.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WorkoutManager.swift; sourceTree = ""; }; 958DCC722DB07C7200EA8C5F /* debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = debug.xcconfig; path = ../debug.xcconfig; sourceTree = SOURCE_ROOT; }; 96752F30FFA0FAA9E42F9BBA /* CoachWidgetSnapshot.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CoachWidgetSnapshot.swift; sourceTree = ""; }; @@ -336,6 +342,9 @@ A1B2C3D4E5F60718293A4B5C /* VmaTestView.swift */, 969DB880395BDEB107C9F2FE /* RoutineStore.swift */, 8AC9C9B9971F55027D227A06 /* WorkoutManager.swift */, + C0AC4E1Z0010OUTDOOR0003 /* LocationTracker.swift */, + C0AC4E1Z0010OUTDOOR0002 /* RouteFilter.swift */, + C0AC4E1Z0010OUTDOOR0001 /* IntervalEngine.swift */, 3342ACCE085010828CF63565 /* Assets.xcassets */, ); path = CoachWatch; @@ -598,6 +607,9 @@ A1B2C3D4E5F60718293A4B5D /* VmaTestView.swift in Sources */, 23F47FD81F646516727B5317 /* RoutineStore.swift in Sources */, 436442D6BF54833C59D9E668 /* WorkoutManager.swift in Sources */, + C0AC4E1Z0011OUTDOOR0003 /* LocationTracker.swift in Sources */, + C0AC4E1Z0011OUTDOOR0002 /* RouteFilter.swift in Sources */, + C0AC4E1Z0011OUTDOOR0001 /* IntervalEngine.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/ios/App/App.xcodeproj/project.pbxproj.bak-outdoor b/ios/App/App.xcodeproj/project.pbxproj.bak-outdoor new file mode 100644 index 0000000..74c5a83 --- /dev/null +++ b/ios/App/App.xcodeproj/project.pbxproj.bak-outdoor @@ -0,0 +1,1093 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 70; + objects = { + +/* Begin PBXBuildFile section */ + 01468471ECA28EA66AFE2D6C /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0EB6C76F68679844EC72178F /* Foundation.framework */; }; + 0318E7E610501FC1F958251B /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 3342ACCE085010828CF63565 /* Assets.xcassets */; }; + 19F735674999FD63AB1C566F /* CoachWidgetSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96752F30FFA0FAA9E42F9BBA /* CoachWidgetSnapshot.swift */; }; + C0AC4E1Z0002HRZONES00001 /* HeartRateZones.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0AC4E1Z0001HRZONES00001 /* HeartRateZones.swift */; }; + 1B690F0D16AB76A6854CA1F7 /* CoachLiveActivityAttributes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 431C7B10248D8EED4D397AB0 /* CoachLiveActivityAttributes.swift */; }; + 21B242F8E61FB07C476C477C /* CoachLiveBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9DF3346C49A690C3C3DF2F3 /* CoachLiveBridge.swift */; }; + 23F47FD81F646516727B5317 /* RoutineStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 969DB880395BDEB107C9F2FE /* RoutineStore.swift */; }; + 290D0F17978B7EF3DDB3A792 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A06CDFF5B4F7852B6329862 /* ContentView.swift */; }; + 2BAD3233026A4DFEEA02EAE8 /* CoachWidgetBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11ECAD3DB1486DE606285921 /* CoachWidgetBridge.swift */; }; + 2F86A358498667B66E5EFC60 /* CoachRoutineBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = 97BC6E07E6984342AE95C312 /* CoachRoutineBridge.swift */; }; + 2FAD9763203C412B000D30F8 /* config.xml in Resources */ = {isa = PBXBuildFile; fileRef = 2FAD9762203C412B000D30F8 /* config.xml */; }; + 3E6740C521EF14892687BCF7 /* CoachWatch.app in Embed Watch Content */ = {isa = PBXBuildFile; fileRef = 2517374227A4072A4D6AFC15 /* CoachWatch.app */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; + 436442D6BF54833C59D9E668 /* WorkoutManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8AC9C9B9971F55027D227A06 /* WorkoutManager.swift */; }; + 4D22ABE92AF431CB00220026 /* CapApp-SPM in Frameworks */ = {isa = PBXBuildFile; productRef = 4D22ABE82AF431CB00220026 /* CapApp-SPM */; }; + 50379B232058CBB4000EE86E /* capacitor.config.json in Resources */ = {isa = PBXBuildFile; fileRef = 50379B222058CBB4000EE86E /* capacitor.config.json */; }; + 504EC3081FED79650016851F /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 504EC3071FED79650016851F /* AppDelegate.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 */; }; + 50B271D11FEDC1A000F3C39B /* public in Resources */ = {isa = PBXBuildFile; fileRef = 50B271D01FEDC1A000F3C39B /* public */; }; + 543A7005429A43F54C0EC61B /* CoachWatchApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = F6BEAEF6F6274D61CAF22636 /* CoachWatchApp.swift */; }; + 548F8BFA06417B35474BD69E /* CoachLiveView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 340B99D5EA4DB985D7DFDA10 /* CoachLiveView.swift */; }; + 60061E60061E60061E060001 /* CoachGoogleAuth.swift in Sources */ = {isa = PBXBuildFile; fileRef = 60061E60061E60061E060002 /* CoachGoogleAuth.swift */; }; + 6442A95576AFC77377D0DC61 /* RoutineGuidedView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C2D78A1BF31D30C5AEC24E7C /* RoutineGuidedView.swift */; }; + A1B2C3D4E5F60718293A4B5D /* VmaTestView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4E5F60718293A4B5C /* VmaTestView.swift */; }; + 694B2F69591CAA85CE5DEC3A /* CoachLiveActivityWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = EBBF61A35F291A021BA75C60 /* CoachLiveActivityWidget.swift */; }; + 79A94FD334CC9EC24D1FB700 /* CoachWidgets.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0276928E86E785EEA434057C /* CoachWidgets.swift */; }; + 880A76D1302B601000F582D5 /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 880A76D0302B601000F582D5 /* WidgetKit.framework */; }; + 880A76D3302B601000F582D5 /* SwiftUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 880A76D2302B601000F582D5 /* SwiftUI.framework */; }; + 880A76DE302B601100F582D5 /* CoachWatchWidgetsExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 880A76CF302B601000F582D5 /* CoachWatchWidgetsExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; + 880A76E6302B62FB00F582D5 /* CoachWidgetSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96752F30FFA0FAA9E42F9BBA /* CoachWidgetSnapshot.swift */; }; + C0AC4E1Z0003HRZONES00001 /* HeartRateZones.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0AC4E1Z0001HRZONES00001 /* HeartRateZones.swift */; }; + 88924CCE2FD2A733002F3A80 /* GoogleSignIn in Frameworks */ = {isa = PBXBuildFile; productRef = 88924CCD2FD2A733002F3A80 /* GoogleSignIn */; }; + 88ABE0043024C45A002636FD /* CoachQuickLog.swift in Sources */ = {isa = PBXBuildFile; fileRef = 88ABE0023024C45A002636FD /* CoachQuickLog.swift */; }; + 88ABE0053024C45A002636FD /* CoachQuickSync.swift in Sources */ = {isa = PBXBuildFile; fileRef = 88ABE0033024C45A002636FD /* CoachQuickSync.swift */; }; + 88ABE0073024C498002636FD /* CoachQuickLog.swift in Sources */ = {isa = PBXBuildFile; fileRef = 88ABE0023024C45A002636FD /* CoachQuickLog.swift */; }; + 88ABE0093024C51D002636FD /* CoachQuickWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 88ABE0083024C51D002636FD /* CoachQuickWidget.swift */; }; + 88D1DD5E302B46B000B9BA42 /* CoachAuth.swift.example in Resources */ = {isa = PBXBuildFile; fileRef = 88D1DD5D302B46B000B9BA42 /* CoachAuth.swift.example */; }; + 88D1DD5F302B46B000B9BA42 /* CoachAuth.swift.example in Resources */ = {isa = PBXBuildFile; fileRef = 88D1DD5D302B46B000B9BA42 /* CoachAuth.swift.example */; }; + 88F28EB12FBB330E00E8306E /* CoachWorkoutObserver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 88F28EB02FBB330E00E8306E /* CoachWorkoutObserver.swift */; }; + 95D0478A499056FE922211EA /* RoutineView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA5C5902EFB7F60563678E46 /* RoutineView.swift */; }; + 97116C31D4CC40F71A6A0FE7 /* LiveStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6030BD533EBC2D1D26DB9479 /* LiveStore.swift */; }; + 97600B69ADF2F19B56E79621 /* CoachWidgetSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96752F30FFA0FAA9E42F9BBA /* CoachWidgetSnapshot.swift */; }; + C0AC4E1Z0004HRZONES00001 /* HeartRateZones.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0AC4E1Z0001HRZONES00001 /* HeartRateZones.swift */; }; + 9BED4C04C38FCB8634CE2A94 /* CoachLiveActivity.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = 9B5664FCDB4DDE62514D3A1E /* CoachLiveActivity.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; + 9E88D51C0B0255D24274DACC /* CoachLiveActivityBundle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1BA75EEC3CBF1F46A9028C66 /* CoachLiveActivityBundle.swift */; }; + A99E51A99E51A99E51A90001 /* CoachAppleAuth.swift in Sources */ = {isa = PBXBuildFile; fileRef = A99E51A99E51A99E51A90002 /* CoachAppleAuth.swift */; }; + B06AB24B350B221F38D99E17 /* ConnectivityManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A420239FC6D065C8A599B61C /* ConnectivityManager.swift */; }; + B130613187FE0661DDF636C9 /* CoachWidgetSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96752F30FFA0FAA9E42F9BBA /* CoachWidgetSnapshot.swift */; }; + C0AC4E1Z0005HRZONES00001 /* HeartRateZones.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0AC4E1Z0001HRZONES00001 /* HeartRateZones.swift */; }; + C0AC4A07811751FB78AA0001 /* CoachAuth.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0AC4A07811751FB78AA0002 /* CoachAuth.swift */; }; + D1384CFA4960060CDBF05FC3 /* CoachLiveActivityAttributes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 431C7B10248D8EED4D397AB0 /* CoachLiveActivityAttributes.swift */; }; + D1BD5B1990CF3F2B9C5D0001 /* CoachWorkoutKit.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1BD5B1990CF3F2B9C5D0002 /* CoachWorkoutKit.swift */; }; + E1E8BD7CF9C19E56CE0F57C4 /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D61D7B2F5AFEF834423E07AB /* Foundation.framework */; }; + E2CE6C2AA1DF4F3CAD6E0001 /* MainViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2CE6C2AA1DF4F3CAD6E0002 /* MainViewController.swift */; }; + E547F675B2F61C3475D2A787 /* LiveActivityManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = B93DA6B121E94B11DF938C90 /* LiveActivityManager.swift */; }; + F1ABCDEF0123456789AB0001 /* CoachHealthRoute.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1ABCDEF0123456789AB0002 /* CoachHealthRoute.swift */; }; + F1ABCDEF0123456789AB0003 /* CoachSleep.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1ABCDEF0123456789AB0004 /* CoachSleep.swift */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 3063F4D3E3903D2EB4956B44 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 504EC2FC1FED79650016851F /* Project object */; + proxyType = 1; + remoteGlobalIDString = D5BF41648D334DC24770B9D2; + remoteInfo = CoachWatch; + }; + 880A76DC302B601100F582D5 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 504EC2FC1FED79650016851F /* Project object */; + proxyType = 1; + remoteGlobalIDString = 880A76CE302B601000F582D5; + remoteInfo = CoachWatchWidgetsExtension; + }; + 9D160400D2AE8723B1112F2F /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 504EC2FC1FED79650016851F /* Project object */; + proxyType = 1; + remoteGlobalIDString = 28FB3582F417B2F3C6BFC5D1; + remoteInfo = CoachLiveActivity; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 880A76DF302B601100F582D5 /* Embed Foundation Extensions */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 13; + files = ( + 880A76DE302B601100F582D5 /* CoachWatchWidgetsExtension.appex in Embed Foundation Extensions */, + ); + name = "Embed Foundation Extensions"; + runOnlyForDeploymentPostprocessing = 0; + }; + C4916F81E61FABAAD62EE4F9 /* Embed App Extensions */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 13; + files = ( + 9BED4C04C38FCB8634CE2A94 /* CoachLiveActivity.appex in Embed App Extensions */, + ); + name = "Embed App Extensions"; + runOnlyForDeploymentPostprocessing = 0; + }; + F50D9A7DFABB52EE96BB93D6 /* Embed Watch Content */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = "$(CONTENTS_FOLDER_PATH)/Watch"; + dstSubfolderSpec = 16; + files = ( + 3E6740C521EF14892687BCF7 /* CoachWatch.app in Embed Watch Content */, + ); + name = "Embed Watch Content"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 0276928E86E785EEA434057C /* CoachWidgets.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CoachWidgets.swift; sourceTree = ""; }; + 0EB6C76F68679844EC72178F /* Foundation.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Foundation.framework; path = Platforms/WatchOS.platform/Developer/SDKs/WatchOS11.0.sdk/System/Library/Frameworks/Foundation.framework; sourceTree = DEVELOPER_DIR; }; + 11ECAD3DB1486DE606285921 /* CoachWidgetBridge.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CoachWidgetBridge.swift; sourceTree = ""; }; + 1BA75EEC3CBF1F46A9028C66 /* CoachLiveActivityBundle.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CoachLiveActivityBundle.swift; sourceTree = ""; }; + 2517374227A4072A4D6AFC15 /* CoachWatch.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = CoachWatch.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 2FAD9762203C412B000D30F8 /* config.xml */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = config.xml; sourceTree = ""; }; + 3342ACCE085010828CF63565 /* Assets.xcassets */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 340B99D5EA4DB985D7DFDA10 /* CoachLiveView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CoachLiveView.swift; sourceTree = ""; }; + 431C7B10248D8EED4D397AB0 /* CoachLiveActivityAttributes.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CoachLiveActivityAttributes.swift; sourceTree = ""; }; + 50379B222058CBB4000EE86E /* capacitor.config.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = capacitor.config.json; sourceTree = ""; }; + 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 = ""; }; + 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 = ""; }; + 504EC3131FED79650016851F /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + 50B271D01FEDC1A000F3C39B /* public */ = {isa = PBXFileReference; lastKnownFileType = folder; path = public; sourceTree = ""; }; + 60061E60061E60061E060002 /* CoachGoogleAuth.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoachGoogleAuth.swift; sourceTree = ""; }; + 6030BD533EBC2D1D26DB9479 /* LiveStore.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = LiveStore.swift; sourceTree = ""; }; + 82122ACF7E5A608AD1D55D24 /* Info.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + 880A76CF302B601000F582D5 /* CoachWatchWidgetsExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = CoachWatchWidgetsExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; + 880A76D0302B601000F582D5 /* WidgetKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WidgetKit.framework; path = System/Library/Frameworks/WidgetKit.framework; sourceTree = SDKROOT; }; + 880A76D2302B601000F582D5 /* SwiftUI.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = SwiftUI.framework; path = System/Library/Frameworks/SwiftUI.framework; sourceTree = SDKROOT; }; + 880A76E7302B634400F582D5 /* CoachWatchWidgetsExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = CoachWatchWidgetsExtension.entitlements; sourceTree = ""; }; + 884A1F9D3020E3C100457184 /* CoachLiveActivity.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = CoachLiveActivity.entitlements; sourceTree = ""; }; + 88A837392FAA741C00DDD144 /* App.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = App.entitlements; sourceTree = ""; }; + 88ABE0023024C45A002636FD /* CoachQuickLog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoachQuickLog.swift; sourceTree = ""; }; + 88ABE0033024C45A002636FD /* CoachQuickSync.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoachQuickSync.swift; sourceTree = ""; }; + 88ABE0083024C51D002636FD /* CoachQuickWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoachQuickWidget.swift; sourceTree = ""; }; + 88D1DD5D302B46B000B9BA42 /* CoachAuth.swift.example */ = {isa = PBXFileReference; lastKnownFileType = text; path = CoachAuth.swift.example; sourceTree = ""; }; + 88F28EB02FBB330E00E8306E /* CoachWorkoutObserver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoachWorkoutObserver.swift; sourceTree = ""; }; + 8A06CDFF5B4F7852B6329862 /* ContentView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; + 8AC9C9B9971F55027D227A06 /* WorkoutManager.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WorkoutManager.swift; sourceTree = ""; }; + 958DCC722DB07C7200EA8C5F /* debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = debug.xcconfig; path = ../debug.xcconfig; sourceTree = SOURCE_ROOT; }; + 96752F30FFA0FAA9E42F9BBA /* CoachWidgetSnapshot.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CoachWidgetSnapshot.swift; sourceTree = ""; }; + C0AC4E1Z0001HRZONES00001 /* HeartRateZones.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = HeartRateZones.swift; sourceTree = ""; }; + 969DB880395BDEB107C9F2FE /* RoutineStore.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RoutineStore.swift; sourceTree = ""; }; + 97BC6E07E6984342AE95C312 /* CoachRoutineBridge.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CoachRoutineBridge.swift; sourceTree = ""; }; + 9B5664FCDB4DDE62514D3A1E /* CoachLiveActivity.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = CoachLiveActivity.appex; sourceTree = BUILT_PRODUCTS_DIR; }; + A420239FC6D065C8A599B61C /* ConnectivityManager.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ConnectivityManager.swift; sourceTree = ""; }; + A99E51A99E51A99E51A90002 /* CoachAppleAuth.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoachAppleAuth.swift; sourceTree = ""; }; + A9DF3346C49A690C3C3DF2F3 /* CoachLiveBridge.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CoachLiveBridge.swift; sourceTree = ""; }; + AA5C5902EFB7F60563678E46 /* RoutineView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RoutineView.swift; sourceTree = ""; }; + B93DA6B121E94B11DF938C90 /* LiveActivityManager.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = LiveActivityManager.swift; sourceTree = ""; }; + C0AC4A07811751FB78AA0002 /* CoachAuth.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoachAuth.swift; sourceTree = ""; }; + C2D78A1BF31D30C5AEC24E7C /* RoutineGuidedView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RoutineGuidedView.swift; sourceTree = ""; }; + A1B2C3D4E5F60718293A4B5C /* VmaTestView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = VmaTestView.swift; sourceTree = ""; }; + D09B589FA572B2C14E8CD835 /* CoachWatch.entitlements */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.entitlements; path = CoachWatch.entitlements; sourceTree = ""; }; + D1BD5B1990CF3F2B9C5D0002 /* CoachWorkoutKit.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoachWorkoutKit.swift; sourceTree = ""; }; + D61D7B2F5AFEF834423E07AB /* Foundation.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Foundation.framework; path = Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS18.0.sdk/System/Library/Frameworks/Foundation.framework; sourceTree = DEVELOPER_DIR; }; + E2CE6C2AA1DF4F3CAD6E0002 /* MainViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainViewController.swift; sourceTree = ""; }; + EBBF61A35F291A021BA75C60 /* CoachLiveActivityWidget.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CoachLiveActivityWidget.swift; sourceTree = ""; }; + F1ABCDEF0123456789AB0002 /* CoachHealthRoute.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoachHealthRoute.swift; sourceTree = ""; }; + F1ABCDEF0123456789AB0004 /* CoachSleep.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoachSleep.swift; sourceTree = ""; }; + F6BEAEF6F6274D61CAF22636 /* CoachWatchApp.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CoachWatchApp.swift; sourceTree = ""; }; + FBFCA969A1B083CD3178D9B2 /* Info.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ + 880A76E2302B601100F582D5 /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Info.plist, + ); + target = 880A76CE302B601000F582D5 /* CoachWatchWidgetsExtension */; + }; +/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ + +/* Begin PBXFileSystemSynchronizedRootGroup section */ + 880A76D4302B601000F582D5 /* CoachWatchWidgets */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (880A76E2302B601100F582D5 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = CoachWatchWidgets; sourceTree = ""; }; +/* End PBXFileSystemSynchronizedRootGroup section */ + +/* Begin PBXFrameworksBuildPhase section */ + 504EC3011FED79650016851F /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 4D22ABE92AF431CB00220026 /* CapApp-SPM in Frameworks */, + 88924CCE2FD2A733002F3A80 /* GoogleSignIn in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 85FFFE3727FD44280013FE19 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 01468471ECA28EA66AFE2D6C /* Foundation.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 880A76CC302B601000F582D5 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 880A76D3302B601000F582D5 /* SwiftUI.framework in Frameworks */, + 880A76D1302B601000F582D5 /* WidgetKit.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + D8B443A150F5A7E1C135ABAA /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + E1E8BD7CF9C19E56CE0F57C4 /* Foundation.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 3E58B60E9B4F1CA14B5EC667 /* watchOS */ = { + isa = PBXGroup; + children = ( + 0EB6C76F68679844EC72178F /* Foundation.framework */, + ); + name = watchOS; + sourceTree = ""; + }; + 460F58BF290358F572689016 /* CoachLiveActivity */ = { + isa = PBXGroup; + children = ( + 88ABE0083024C51D002636FD /* CoachQuickWidget.swift */, + 884A1F9D3020E3C100457184 /* CoachLiveActivity.entitlements */, + 431C7B10248D8EED4D397AB0 /* CoachLiveActivityAttributes.swift */, + EBBF61A35F291A021BA75C60 /* CoachLiveActivityWidget.swift */, + 1BA75EEC3CBF1F46A9028C66 /* CoachLiveActivityBundle.swift */, + 0276928E86E785EEA434057C /* CoachWidgets.swift */, + FBFCA969A1B083CD3178D9B2 /* Info.plist */, + ); + path = CoachLiveActivity; + sourceTree = ""; + }; + 504EC2FB1FED79650016851F = { + isa = PBXGroup; + children = ( + 880A76E7302B634400F582D5 /* CoachWatchWidgetsExtension.entitlements */, + 958DCC722DB07C7200EA8C5F /* debug.xcconfig */, + 504EC3061FED79650016851F /* App */, + 880A76D4302B601000F582D5 /* CoachWatchWidgets */, + 504EC3051FED79650016851F /* Products */, + B91ACF72653FD495148698CF /* Frameworks */, + 5A8EA0CF92316E437E9053A0 /* CoachWatch */, + 460F58BF290358F572689016 /* CoachLiveActivity */, + ); + sourceTree = ""; + }; + 504EC3051FED79650016851F /* Products */ = { + isa = PBXGroup; + children = ( + 504EC3041FED79650016851F /* App.app */, + 2517374227A4072A4D6AFC15 /* CoachWatch.app */, + 9B5664FCDB4DDE62514D3A1E /* CoachLiveActivity.appex */, + 880A76CF302B601000F582D5 /* CoachWatchWidgetsExtension.appex */, + ); + name = Products; + sourceTree = ""; + }; + 504EC3061FED79650016851F /* App */ = { + isa = PBXGroup; + children = ( + 88ABE0023024C45A002636FD /* CoachQuickLog.swift */, + 88ABE0033024C45A002636FD /* CoachQuickSync.swift */, + 88A837392FAA741C00DDD144 /* App.entitlements */, + 88D1DD5D302B46B000B9BA42 /* CoachAuth.swift.example */, + 88F28EB02FBB330E00E8306E /* CoachWorkoutObserver.swift */, + 50379B222058CBB4000EE86E /* capacitor.config.json */, + 504EC3071FED79650016851F /* AppDelegate.swift */, + C0AC4A07811751FB78AA0002 /* CoachAuth.swift */, + D1BD5B1990CF3F2B9C5D0002 /* CoachWorkoutKit.swift */, + F1ABCDEF0123456789AB0002 /* CoachHealthRoute.swift */, + F1ABCDEF0123456789AB0004 /* CoachSleep.swift */, + A99E51A99E51A99E51A90002 /* CoachAppleAuth.swift */, + 60061E60061E60061E060002 /* CoachGoogleAuth.swift */, + E2CE6C2AA1DF4F3CAD6E0002 /* MainViewController.swift */, + 504EC30B1FED79650016851F /* Main.storyboard */, + 504EC30E1FED79650016851F /* Assets.xcassets */, + 504EC3101FED79650016851F /* LaunchScreen.storyboard */, + 504EC3131FED79650016851F /* Info.plist */, + 2FAD9762203C412B000D30F8 /* config.xml */, + 50B271D01FEDC1A000F3C39B /* public */, + A9DF3346C49A690C3C3DF2F3 /* CoachLiveBridge.swift */, + 96752F30FFA0FAA9E42F9BBA /* CoachWidgetSnapshot.swift */, + C0AC4E1Z0001HRZONES00001 /* HeartRateZones.swift */, + 11ECAD3DB1486DE606285921 /* CoachWidgetBridge.swift */, + 97BC6E07E6984342AE95C312 /* CoachRoutineBridge.swift */, + 6030BD533EBC2D1D26DB9479 /* LiveStore.swift */, + 340B99D5EA4DB985D7DFDA10 /* CoachLiveView.swift */, + B93DA6B121E94B11DF938C90 /* LiveActivityManager.swift */, + ); + path = App; + sourceTree = ""; + }; + 5A8EA0CF92316E437E9053A0 /* CoachWatch */ = { + isa = PBXGroup; + children = ( + F6BEAEF6F6274D61CAF22636 /* CoachWatchApp.swift */, + 8A06CDFF5B4F7852B6329862 /* ContentView.swift */, + 82122ACF7E5A608AD1D55D24 /* Info.plist */, + D09B589FA572B2C14E8CD835 /* CoachWatch.entitlements */, + A420239FC6D065C8A599B61C /* ConnectivityManager.swift */, + AA5C5902EFB7F60563678E46 /* RoutineView.swift */, + C2D78A1BF31D30C5AEC24E7C /* RoutineGuidedView.swift */, + A1B2C3D4E5F60718293A4B5C /* VmaTestView.swift */, + 969DB880395BDEB107C9F2FE /* RoutineStore.swift */, + 8AC9C9B9971F55027D227A06 /* WorkoutManager.swift */, + 3342ACCE085010828CF63565 /* Assets.xcassets */, + ); + path = CoachWatch; + sourceTree = ""; + }; + 7497026ECEFF81CCD11C289D /* iOS */ = { + isa = PBXGroup; + children = ( + D61D7B2F5AFEF834423E07AB /* Foundation.framework */, + ); + name = iOS; + sourceTree = ""; + }; + B91ACF72653FD495148698CF /* Frameworks */ = { + isa = PBXGroup; + children = ( + 3E58B60E9B4F1CA14B5EC667 /* watchOS */, + 7497026ECEFF81CCD11C289D /* iOS */, + 880A76D0302B601000F582D5 /* WidgetKit.framework */, + 880A76D2302B601000F582D5 /* SwiftUI.framework */, + ); + name = Frameworks; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 28FB3582F417B2F3C6BFC5D1 /* CoachLiveActivity */ = { + isa = PBXNativeTarget; + buildConfigurationList = C2671D2741138A4A55CE1ED0 /* Build configuration list for PBXNativeTarget "CoachLiveActivity" */; + buildPhases = ( + 2D25180D64B3326AE6F9D91B /* Sources */, + D8B443A150F5A7E1C135ABAA /* Frameworks */, + 411CB19BB5DB127758A86A2D /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = CoachLiveActivity; + productName = CoachLiveActivity; + productReference = 9B5664FCDB4DDE62514D3A1E /* CoachLiveActivity.appex */; + productType = "com.apple.product-type.app-extension"; + }; + 504EC3031FED79650016851F /* App */ = { + isa = PBXNativeTarget; + buildConfigurationList = 504EC3161FED79650016851F /* Build configuration list for PBXNativeTarget "App" */; + buildPhases = ( + 504EC3001FED79650016851F /* Sources */, + 504EC3011FED79650016851F /* Frameworks */, + 504EC3021FED79650016851F /* Resources */, + F50D9A7DFABB52EE96BB93D6 /* Embed Watch Content */, + C4916F81E61FABAAD62EE4F9 /* Embed App Extensions */, + ); + buildRules = ( + ); + dependencies = ( + EEEAB45810829EE8B3C4976E /* PBXTargetDependency */, + BACE810C3CD85E302BBFEC84 /* PBXTargetDependency */, + ); + name = App; + packageProductDependencies = ( + 4D22ABE82AF431CB00220026 /* CapApp-SPM */, + 88924CCD2FD2A733002F3A80 /* GoogleSignIn */, + ); + productName = App; + productReference = 504EC3041FED79650016851F /* App.app */; + productType = "com.apple.product-type.application"; + }; + 880A76CE302B601000F582D5 /* CoachWatchWidgetsExtension */ = { + isa = PBXNativeTarget; + buildConfigurationList = 880A76E3302B601100F582D5 /* Build configuration list for PBXNativeTarget "CoachWatchWidgetsExtension" */; + buildPhases = ( + 880A76CB302B601000F582D5 /* Sources */, + 880A76CC302B601000F582D5 /* Frameworks */, + 880A76CD302B601000F582D5 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + 880A76D4302B601000F582D5 /* CoachWatchWidgets */, + ); + name = CoachWatchWidgetsExtension; + packageProductDependencies = ( + ); + productName = CoachWatchWidgetsExtension; + productReference = 880A76CF302B601000F582D5 /* CoachWatchWidgetsExtension.appex */; + productType = "com.apple.product-type.app-extension"; + }; + D5BF41648D334DC24770B9D2 /* CoachWatch */ = { + isa = PBXNativeTarget; + buildConfigurationList = FA2A9602402AA384726BFF97 /* Build configuration list for PBXNativeTarget "CoachWatch" */; + buildPhases = ( + E1B262849E79F8569984E173 /* Sources */, + 85FFFE3727FD44280013FE19 /* Frameworks */, + CFD58983FA62853BAD4DC405 /* Resources */, + 880A76DF302B601100F582D5 /* Embed Foundation Extensions */, + ); + buildRules = ( + ); + dependencies = ( + 880A76DD302B601100F582D5 /* PBXTargetDependency */, + ); + name = CoachWatch; + productName = CoachWatch; + productReference = 2517374227A4072A4D6AFC15 /* CoachWatch.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 504EC2FC1FED79650016851F /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = YES; + LastSwiftUpdateCheck = 2660; + LastUpgradeCheck = 2650; + TargetAttributes = { + 504EC3031FED79650016851F = { + CreatedOnToolsVersion = 9.2; + LastSwiftMigration = 1100; + ProvisioningStyle = Automatic; + }; + 880A76CE302B601000F582D5 = { + CreatedOnToolsVersion = 26.6; + }; + }; + }; + buildConfigurationList = 504EC2FF1FED79650016851F /* Build configuration list for PBXProject "App" */; + compatibilityVersion = "Xcode 8.0"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 504EC2FB1FED79650016851F; + packageReferences = ( + D4C12C0A2AAA248700AAC8A2 /* XCLocalSwiftPackageReference "CapApp-SPM" */, + 88924CCC2FD2A663002F3A80 /* XCRemoteSwiftPackageReference "GoogleSignIn-iOS" */, + ); + productRefGroup = 504EC3051FED79650016851F /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 504EC3031FED79650016851F /* App */, + D5BF41648D334DC24770B9D2 /* CoachWatch */, + 28FB3582F417B2F3C6BFC5D1 /* CoachLiveActivity */, + 880A76CE302B601000F582D5 /* CoachWatchWidgetsExtension */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 411CB19BB5DB127758A86A2D /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 88D1DD5F302B46B000B9BA42 /* CoachAuth.swift.example in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 504EC3021FED79650016851F /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 88D1DD5E302B46B000B9BA42 /* CoachAuth.swift.example in Resources */, + 504EC3121FED79650016851F /* LaunchScreen.storyboard in Resources */, + 50B271D11FEDC1A000F3C39B /* public in Resources */, + 504EC30F1FED79650016851F /* Assets.xcassets in Resources */, + 50379B232058CBB4000EE86E /* capacitor.config.json in Resources */, + 504EC30D1FED79650016851F /* Main.storyboard in Resources */, + 2FAD9763203C412B000D30F8 /* config.xml in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 880A76CD302B601000F582D5 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + CFD58983FA62853BAD4DC405 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 0318E7E610501FC1F958251B /* Assets.xcassets in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 2D25180D64B3326AE6F9D91B /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 79A94FD334CC9EC24D1FB700 /* CoachWidgets.swift in Sources */, + 19F735674999FD63AB1C566F /* CoachWidgetSnapshot.swift in Sources */, + C0AC4E1Z0002HRZONES00001 /* HeartRateZones.swift in Sources */, + 1B690F0D16AB76A6854CA1F7 /* CoachLiveActivityAttributes.swift in Sources */, + 694B2F69591CAA85CE5DEC3A /* CoachLiveActivityWidget.swift in Sources */, + 9E88D51C0B0255D24274DACC /* CoachLiveActivityBundle.swift in Sources */, + 88ABE0073024C498002636FD /* CoachQuickLog.swift in Sources */, + 88ABE0093024C51D002636FD /* CoachQuickWidget.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 504EC3001FED79650016851F /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + B130613187FE0661DDF636C9 /* CoachWidgetSnapshot.swift in Sources */, + C0AC4E1Z0005HRZONES00001 /* HeartRateZones.swift in Sources */, + 2BAD3233026A4DFEEA02EAE8 /* CoachWidgetBridge.swift in Sources */, + 504EC3081FED79650016851F /* AppDelegate.swift in Sources */, + C0AC4A07811751FB78AA0001 /* CoachAuth.swift in Sources */, + D1BD5B1990CF3F2B9C5D0001 /* CoachWorkoutKit.swift in Sources */, + 88ABE0043024C45A002636FD /* CoachQuickLog.swift in Sources */, + 88ABE0053024C45A002636FD /* CoachQuickSync.swift in Sources */, + F1ABCDEF0123456789AB0001 /* CoachHealthRoute.swift in Sources */, + F1ABCDEF0123456789AB0003 /* CoachSleep.swift in Sources */, + A99E51A99E51A99E51A90001 /* CoachAppleAuth.swift in Sources */, + 60061E60061E60061E060001 /* CoachGoogleAuth.swift in Sources */, + 88F28EB12FBB330E00E8306E /* CoachWorkoutObserver.swift in Sources */, + E2CE6C2AA1DF4F3CAD6E0001 /* MainViewController.swift in Sources */, + 21B242F8E61FB07C476C477C /* CoachLiveBridge.swift in Sources */, + 2F86A358498667B66E5EFC60 /* CoachRoutineBridge.swift in Sources */, + 97116C31D4CC40F71A6A0FE7 /* LiveStore.swift in Sources */, + 548F8BFA06417B35474BD69E /* CoachLiveView.swift in Sources */, + D1384CFA4960060CDBF05FC3 /* CoachLiveActivityAttributes.swift in Sources */, + E547F675B2F61C3475D2A787 /* LiveActivityManager.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 880A76CB302B601000F582D5 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 880A76E6302B62FB00F582D5 /* CoachWidgetSnapshot.swift in Sources */, + C0AC4E1Z0003HRZONES00001 /* HeartRateZones.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + E1B262849E79F8569984E173 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 97600B69ADF2F19B56E79621 /* CoachWidgetSnapshot.swift in Sources */, + C0AC4E1Z0004HRZONES00001 /* HeartRateZones.swift in Sources */, + 543A7005429A43F54C0EC61B /* CoachWatchApp.swift in Sources */, + 290D0F17978B7EF3DDB3A792 /* ContentView.swift in Sources */, + B06AB24B350B221F38D99E17 /* ConnectivityManager.swift in Sources */, + 95D0478A499056FE922211EA /* RoutineView.swift in Sources */, + 6442A95576AFC77377D0DC61 /* RoutineGuidedView.swift in Sources */, + A1B2C3D4E5F60718293A4B5D /* VmaTestView.swift in Sources */, + 23F47FD81F646516727B5317 /* RoutineStore.swift in Sources */, + 436442D6BF54833C59D9E668 /* WorkoutManager.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 880A76DD302B601100F582D5 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 880A76CE302B601000F582D5 /* CoachWatchWidgetsExtension */; + targetProxy = 880A76DC302B601100F582D5 /* PBXContainerItemProxy */; + }; + BACE810C3CD85E302BBFEC84 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + name = CoachLiveActivity; + target = 28FB3582F417B2F3C6BFC5D1 /* CoachLiveActivity */; + targetProxy = 9D160400D2AE8723B1112F2F /* PBXContainerItemProxy */; + }; + EEEAB45810829EE8B3C4976E /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + name = CoachWatch; + target = D5BF41648D334DC24770B9D2 /* CoachWatch */; + targetProxy = 3063F4D3E3903D2EB4956B44 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin PBXVariantGroup section */ + 504EC30B1FED79650016851F /* Main.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 504EC30C1FED79650016851F /* Base */, + ); + name = Main.storyboard; + sourceTree = ""; + }; + 504EC3101FED79650016851F /* LaunchScreen.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 504EC3111FED79650016851F /* Base */, + ); + name = LaunchScreen.storyboard; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 09CEF8C9131E8948450FEC27 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = ""; + CODE_SIGN_ENTITLEMENTS = CoachWatch/CoachWatch.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 22; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = CoachWatch/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.2; + PRODUCT_BUNDLE_IDENTIFIER = ch.hypnotruck.coach.watchkitapp; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = watchos; + SKIP_INSTALL = YES; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 4; + WATCHOS_DEPLOYMENT_TARGET = 11.0; + }; + name = Debug; + }; + 504EC3141FED79650016851F /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 958DCC722DB07C7200EA8C5F /* debug.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + CODE_SIGN_IDENTITY = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + DEVELOPMENT_TEAM = TZ2PVTRKNY; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + 504EC3151FED79650016851F /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + CODE_SIGN_IDENTITY = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + DEVELOPMENT_TEAM = TZ2PVTRKNY; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + 504EC3171FED79650016851F /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 958DCC722DB07C7200EA8C5F /* debug.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = NO; + CODE_SIGN_ENTITLEMENTS = App/App.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 22; + INFOPLIST_FILE = App/Info.plist; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.healthcare-fitness"; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.2; + OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\""; + PRODUCT_BUNDLE_IDENTIFIER = ch.hypnotruck.coach; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 504EC3181FED79650016851F /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = NO; + CODE_SIGN_ENTITLEMENTS = App/App.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 22; + INFOPLIST_FILE = App/Info.plist; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.healthcare-fitness"; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.2; + PRODUCT_BUNDLE_IDENTIFIER = ch.hypnotruck.coach; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = ""; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; + 6BD679ED799FBBFC9D3BE40C /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = ""; + CODE_SIGN_ENTITLEMENTS = CoachWatch/CoachWatch.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 22; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = CoachWatch/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.2; + PRODUCT_BUNDLE_IDENTIFIER = ch.hypnotruck.coach.watchkitapp; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = watchos; + SKIP_INSTALL = YES; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = ""; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 4; + VALIDATE_PRODUCT = YES; + WATCHOS_DEPLOYMENT_TARGET = 11.0; + }; + name = Release; + }; + 880A76E0302B601100F582D5 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_OBJC_WEAK = YES; + CODE_SIGN_ENTITLEMENTS = CoachWatchWidgetsExtension.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 22; + GCC_C_LANGUAGE_STANDARD = gnu17; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = CoachWatchWidgets/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = CoachWatchWidgets; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + "@executable_path/../../../../Frameworks", + ); + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MARKETING_VERSION = 1.2; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + PRODUCT_BUNDLE_IDENTIFIER = ch.hypnotruck.coach.watchkitapp.CoachWatchWidgets; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = watchos; + SKIP_INSTALL = YES; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 4; + WATCHOS_DEPLOYMENT_TARGET = 26.5; + }; + name = Debug; + }; + 880A76E1302B601100F582D5 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_OBJC_WEAK = YES; + CODE_SIGN_ENTITLEMENTS = CoachWatchWidgetsExtension.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 22; + GCC_C_LANGUAGE_STANDARD = gnu17; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = CoachWatchWidgets/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = CoachWatchWidgets; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + "@executable_path/../../../../Frameworks", + ); + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MARKETING_VERSION = 1.2; + MTL_FAST_MATH = YES; + PRODUCT_BUNDLE_IDENTIFIER = ch.hypnotruck.coach.watchkitapp.CoachWatchWidgets; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = watchos; + SKIP_INSTALL = YES; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 4; + WATCHOS_DEPLOYMENT_TARGET = 26.5; + }; + name = Release; + }; + 90CE3C22F60C6432852DC1B1 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = ""; + CLANG_ENABLE_OBJC_WEAK = NO; + CODE_SIGN_ENTITLEMENTS = CoachLiveActivity/CoachLiveActivity.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 22; + DEVELOPMENT_TEAM = TZ2PVTRKNY; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = CoachLiveActivity/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MARKETING_VERSION = 1.2; + PRODUCT_BUNDLE_IDENTIFIER = ch.hypnotruck.coach.LiveActivity; + PRODUCT_NAME = CoachLiveActivity; + SDKROOT = iphoneos; + SKIP_INSTALL = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + C7A0838F3DA0842EDE320050 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = ""; + CLANG_ENABLE_OBJC_WEAK = NO; + CODE_SIGN_ENTITLEMENTS = CoachLiveActivity/CoachLiveActivity.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 22; + DEVELOPMENT_TEAM = TZ2PVTRKNY; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = CoachLiveActivity/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MARKETING_VERSION = 1.2; + PRODUCT_BUNDLE_IDENTIFIER = ch.hypnotruck.coach.LiveActivity; + PRODUCT_NAME = CoachLiveActivity; + SDKROOT = iphoneos; + SKIP_INSTALL = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 504EC2FF1FED79650016851F /* Build configuration list for PBXProject "App" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 504EC3141FED79650016851F /* Debug */, + 504EC3151FED79650016851F /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 504EC3161FED79650016851F /* Build configuration list for PBXNativeTarget "App" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 504EC3171FED79650016851F /* Debug */, + 504EC3181FED79650016851F /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 880A76E3302B601100F582D5 /* Build configuration list for PBXNativeTarget "CoachWatchWidgetsExtension" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 880A76E0302B601100F582D5 /* Debug */, + 880A76E1302B601100F582D5 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + C2671D2741138A4A55CE1ED0 /* Build configuration list for PBXNativeTarget "CoachLiveActivity" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + C7A0838F3DA0842EDE320050 /* Release */, + 90CE3C22F60C6432852DC1B1 /* Debug */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + FA2A9602402AA384726BFF97 /* Build configuration list for PBXNativeTarget "CoachWatch" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 6BD679ED799FBBFC9D3BE40C /* Release */, + 09CEF8C9131E8948450FEC27 /* Debug */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + +/* Begin XCLocalSwiftPackageReference section */ + D4C12C0A2AAA248700AAC8A2 /* XCLocalSwiftPackageReference "CapApp-SPM" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = "CapApp-SPM"; + }; +/* End XCLocalSwiftPackageReference section */ + +/* Begin XCRemoteSwiftPackageReference section */ + 88924CCC2FD2A663002F3A80 /* XCRemoteSwiftPackageReference "GoogleSignIn-iOS" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/google/GoogleSignIn-iOS"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 9.1.0; + }; + }; +/* End XCRemoteSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + 4D22ABE82AF431CB00220026 /* CapApp-SPM */ = { + isa = XCSwiftPackageProductDependency; + package = D4C12C0A2AAA248700AAC8A2 /* XCLocalSwiftPackageReference "CapApp-SPM" */; + productName = "CapApp-SPM"; + }; + 88924CCD2FD2A733002F3A80 /* GoogleSignIn */ = { + isa = XCSwiftPackageProductDependency; + package = 88924CCC2FD2A663002F3A80 /* XCRemoteSwiftPackageReference "GoogleSignIn-iOS" */; + productName = GoogleSignIn; + }; +/* End XCSwiftPackageProductDependency section */ + }; + rootObject = 504EC2FC1FED79650016851F /* Project object */; +} diff --git a/ios/App/CoachWatch/Info.plist b/ios/App/CoachWatch/Info.plist index 7a22fff..4abcc0e 100644 --- a/ios/App/CoachWatch/Info.plist +++ b/ios/App/CoachWatch/Info.plist @@ -24,6 +24,18 @@ Coach Hypnotruck lit ta fréquence cardiaque et tes calories pendant la séance pour les afficher en direct sur ton iPhone. NSHealthUpdateUsageDescription Coach Hypnotruck enregistre ta séance dans l'app Santé. + NSLocationWhenInUseUsageDescription + Coach Hypnotruck suit ta position pendant la séance pour tracer ton parcours, afficher la carte et mesurer le dénivelé. + + UIBackgroundModes + + location + WKApplication WKBackgroundModes diff --git a/ios/App/CoachWatch/LocationTracker.swift b/ios/App/CoachWatch/LocationTracker.swift new file mode 100644 index 0000000..831368b --- /dev/null +++ b/ios/App/CoachWatch/LocationTracker.swift @@ -0,0 +1,318 @@ +import Foundation +import CoreLocation +import HealthKit +import os + +private let locationLog = Logger(subsystem: "ch.hypnotruck.coach.watchkitapp", category: "location") + +/* GPS de la séance : flux de positions, trace HealthKit, distance et D+. + * + * Le filtrage vit dans `RouteFilter` (Foundation pur, testé sur Linux). Ce + * fichier ne fait que le brancher sur CoreLocation et HealthKit — c'est la + * seule partie qui exige un poignet pour être validée. + * + * **Pourquoi enregistrer la trace nous-mêmes.** Jusqu'ici les traces venaient + * de l'app *Exercice* d'Apple, relues après coup côté iPhone par + * `CoachHealthRoute`. Dès qu'une séance est lancée depuis CoachWatch, plus + * personne n'écrit de `HKWorkoutRoute` : sans ce fichier, la sortie n'aurait + * aucune trace, ni sur la carte ni dans Santé. + * + * ⚠️ **Quatre pièges, tous documentés et tous coûteux.** + * + * 1. `allowsBackgroundLocationUpdates = true` **sans** `UIBackgroundModes` = + * `location` dans l'Info.plist **termine l'app** — Apple : « is a fatal + * error that terminates the app ». D'où le garde-fou `backgroundModeDeclared` + * ci-dessous, qui vérifie le plist avant d'armer le drapeau. + * 2. **Ne jamais demander « Always » sur watchOS.** Un ingénieur DTS d'Apple + * décrit ce prompt comme « mostly a placeholder » et le parcours comme + * « undefined behavior » : le statut boucle jusqu'à revenir à + * `.notDetermined`. `requestWhenInUseAuthorization()` suffit pour continuer + * à recevoir des positions en arrière-plan. + * 3. **Démarrer au premier plan, et ne plus jamais arrêter.** watchOS interdit + * de relancer les mises à jour depuis l'arrière-plan : une pause qui + * couperait le GPS ne pourrait pas le rallumer avant le retour à l'écran. + * On garde donc le flux ouvert et on ignore les points pendant la pause. + * 4. **Le CPU tue le GPS.** watchOS suspend une app qui consomme trop en + * arrière-plan, et les positions s'arrêtent sans la moindre erreur — un + * développeur a perdu les siennes à cause d'un rafraîchissement d'écran à + * 1/100 s. D'où l'insertion des points par lots plutôt qu'un par un. + */ +@MainActor +final class LocationTracker: NSObject, ObservableObject { + static let shared = LocationTracker() + + /// Trace décimée pour l'affichage carte. Bornée : une sortie de 3 h à 1 Hz + /// produirait plus de 10 000 points, que la montre ne peut pas redessiner + /// à chaque rafraîchissement sans se faire suspendre (piège 4). + @Published private(set) var trackForMap: [GeoFix] = [] + @Published private(set) var lastFix: GeoFix? + @Published private(set) var distanceMeters: Double = 0 + @Published private(set) var ascentMeters: Double = 0 + @Published private(set) var descentMeters: Double = 0 + /// Rayon d'incertitude du dernier point reçu, accepté ou non — c'est la + /// jauge « qualité GPS » de l'écran. + @Published private(set) var horizontalAccuracy: Double? + @Published private(set) var authorizationStatus: CLAuthorizationStatus = .notDetermined + @Published private(set) var isTracking = false + + private let manager = CLLocationManager() + private var filter = RouteFilter() + private var routeBuilder: HKWorkoutRouteBuilder? + /// Points en attente d'écriture dans HealthKit. + private var pendingRoute: [CLLocation] = [] + /// Positions ignorées tant que la séance est en pause : le flux reste + /// ouvert (piège 3) mais la trace ne doit pas traverser l'arrêt. + private var isPaused = false + + private let maxMapPoints = 1_500 + private let routeFlushThreshold = 25 + + private override init() { + super.init() + manager.delegate = self + authorizationStatus = manager.authorizationStatus + } + + // MARK: Autorisation + + /// À appeler **au premier plan**, avant de démarrer la séance. + func requestAuthorization() { + // Volontairement pas `requestAlwaysAuthorization()` : voir piège 2. + guard manager.authorizationStatus == .notDetermined else { return } + manager.requestWhenInUseAuthorization() + } + + var isAuthorized: Bool { + switch authorizationStatus { + case .authorizedWhenInUse, .authorizedAlways: return true + default: return false + } + } + + // MARK: Cycle de vie + + /// Démarre le suivi. **Doit être appelé pendant que l'app est au premier + /// plan** — watchOS refuse de démarrer la localisation depuis l'arrière-plan. + /// + /// - Parameter routeBuilder: fourni par `HKLiveWorkoutBuilder`, via + /// `seriesBuilder(for: HKSeriesType.workoutRoute())`. `nil` pour un suivi + /// d'affichage seul, sans écriture dans Santé. + func start(activity: HKWorkoutActivityType, routeBuilder: HKWorkoutRouteBuilder?) { + guard !isTracking else { return } + // On n'exige pas que l'autorisation soit DÉJÀ accordée : quand elle est + // encore en attente, le manager reste armé et CoreLocation délivre les + // positions dès l'acceptation. Refuser ici priverait de trace toute + // première séance, celle-là même où la demande apparaît. + if authorizationStatus == .denied || authorizationStatus == .restricted { + locationLog.error("localisation refusée par l'utilisateur : séance sans trace") + return + } + + self.routeBuilder = routeBuilder + filter = RouteFilter(maxPlausibleSpeed: Self.maxSpeed(for: activity)) + trackForMap.removeAll() + pendingRoute.removeAll() + distanceMeters = 0 + ascentMeters = 0 + descentMeters = 0 + isPaused = false + + // Le défaut de watchOS est `kCLLocationAccuracyHundredMeters` — inutile + // pour une trace. `BestForNavigation` est réservé par Apple aux + // appareils branchés : trop gourmand pour une séance au poignet. + manager.desiredAccuracy = kCLLocationAccuracyBest + manager.distanceFilter = kCLDistanceFilterNone + manager.activityType = Self.activityType(for: activity) + + if Self.backgroundModeDeclared { + manager.allowsBackgroundLocationUpdates = true + } else { + // Armer le drapeau sans la clé Info.plist tuerait l'app (piège 1). + // Mieux vaut une trace qui s'arrête écran éteint qu'un crash. + locationLog.error("UIBackgroundModes/location absent du plist : suivi limité au premier plan") + } + + manager.startUpdatingLocation() + isTracking = true + locationLog.info("suivi GPS démarré") + } + + /// Suspend l'enregistrement **sans couper le flux** (piège 3). + func setPaused(_ paused: Bool) { + guard isTracking else { return } + isPaused = paused + if paused { + // Le point qui suivra la reprise serait à des centaines de mètres : + // on repart d'une origine neuve pour ne pas tracer la pause. + pendingRoute.removeAll() + } + } + + /// Coupe le flux GPS. À appeler dès la fin de séance ; la trace, elle, ne + /// se clôt qu'après la sauvegarde du workout — voir `finishRoute(with:)`. + func stopTracking() { + guard isTracking else { return } + manager.stopUpdatingLocation() + isTracking = false + locationLog.info("suivi GPS arrete") + } + + /// Clôt la trace et l'associe à la séance sauvegardée. + /// + /// ⚠️ **L'ordre n'est pas négociable.** Apple : « After saving the workout, + /// add any remaining locations to the route builder and call finishRoute. » + /// Appeler `finishRoute` avant `finishWorkout` empêche l'association, et ne + /// pas l'appeler du tout fait perdre **toute** la trace — le builder est + /// invalidé à sa libération. + /// + /// ⚠️ `workout` peut être `nil` : `finishWorkout()` réussit mais ne rend + /// pas l'objet **quand la montre est verrouillée** (confirmé par un + /// ingénieur Apple). On sauvegarde alors la trace sans association plutôt + /// que de la jeter — une route orpheline vaut mieux qu'une sortie sans + /// parcours. Une route ne peut être associée qu'une fois, et jamais après + /// coup. + /// + /// Échoue aussi si aucune position n'a été insérée — cas normal d'une + /// séance en salle, à ne pas remonter comme une anomalie. + func finishRoute(with workout: HKWorkout?) async { + await flushPendingRoute() + guard let builder = routeBuilder else { return } + self.routeBuilder = nil + + guard filter.acceptedCount > 0 else { + locationLog.info("aucune position retenue : pas de trace a clore") + return + } + do { + _ = try await builder.finishRoute(with: workout, metadata: nil) + locationLog.info("trace close : \(self.filter.acceptedCount) points, \(Int(self.filter.distanceM)) m") + } catch { + locationLog.error("finishRoute a echoue : \(error.localizedDescription)") + } + } + + // MARK: Écriture HealthKit + + /// Écrit les points en attente. Par lots : une écriture par position + /// gaspillerait le CPU que watchOS surveille (piège 4). + private func flushPendingRoute() async { + guard let builder = routeBuilder, !pendingRoute.isEmpty else { return } + let batch = pendingRoute + pendingRoute.removeAll() + do { + try await builder.insertRouteData(batch) + } catch { + // Les points sont perdus, pas la séance : on ne remet pas le lot + // dans la file, sinon un échec persistant la ferait enfler + // indéfiniment en mémoire. + locationLog.error("insertRouteData a echoue (\(batch.count) points) : \(error.localizedDescription)") + } + } + + // MARK: Réglages par activité + + /// Plafond de vitesse plausible, en m/s. Le VTT électrique descend + /// largement au-dessus d'un coureur : un seuil unique écarterait des points + /// parfaitement valides. + private static func maxSpeed(for activity: HKWorkoutActivityType) -> Double { + switch activity { + case .cycling: return 30 // 108 km/h, descente comprise + case .running: return 12 // 43 km/h + default: return 8 // marche, randonnée + } + } + + private static func activityType(for activity: HKWorkoutActivityType) -> CLActivityType { + switch activity { + // `.fitness` fait désactiver le positionnement intérieur et peut + // provoquer des pauses automatiques ; Apple recommande + // `.otherNavigation` pour le vélo et le hors-route. + case .cycling: return .otherNavigation + default: return .fitness + } + } + + /// Le plist déclare-t-il le mode de fond `location` ? + /// + /// watchOS porte deux clés distinctes : `WKBackgroundModes` pour + /// `workout-processing`, et `UIBackgroundModes` pour `location`. On accepte + /// les deux, la documentation d'Apple n'étant pas univoque sur ce point. + private static var backgroundModeDeclared: Bool { + let keys = ["UIBackgroundModes", "WKBackgroundModes"] + for key in keys { + if let modes = Bundle.main.object(forInfoDictionaryKey: key) as? [String], + modes.contains("location") { + return true + } + } + return false + } +} + +extension LocationTracker: CLLocationManagerDelegate { + nonisolated func locationManager(_ manager: CLLocationManager, + didUpdateLocations locations: [CLLocation]) { + let fixes = locations.map { GeoFix(location: $0) } + let raw = locations + Task { @MainActor in + self.ingest(fixes: fixes, raw: raw) + } + } + + nonisolated func locationManager(_ manager: CLLocationManager, + didFailWithError error: Error) { + locationLog.error("CoreLocation a echoue : \(error.localizedDescription)") + } + + nonisolated func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) { + let status = manager.authorizationStatus + Task { @MainActor in + self.authorizationStatus = status + } + } + + private func ingest(fixes: [GeoFix], raw: [CLLocation]) { + horizontalAccuracy = fixes.last?.horizontalAccuracy + guard !isPaused else { return } + + for (fix, location) in zip(fixes, raw) { + guard case .accepted = filter.add(fix) else { continue } + lastFix = fix + pendingRoute.append(location) + appendToMap(fix) + } + + distanceMeters = filter.distanceM + ascentMeters = filter.ascentM + descentMeters = filter.descentM + + if pendingRoute.count >= routeFlushThreshold { + Task { await flushPendingRoute() } + } + } + + /// Ajoute à la trace d'affichage, en décimant une fois le plafond atteint : + /// on garde un point sur deux, ce qui divise la densité sans déformer le + /// tracé ni faire enfler la mémoire sur une longue sortie. + private func appendToMap(_ fix: GeoFix) { + trackForMap.append(fix) + if trackForMap.count > maxMapPoints { + trackForMap = trackForMap.enumerated() + .compactMap { $0.offset.isMultiple(of: 2) ? $0.element : nil } + } + } +} + +extension GeoFix { + /// Conversion depuis CoreLocation. Isolée ici pour que `RouteFilter` reste + /// testable hors d'un Mac. + init(location: CLLocation) { + self.init(lat: location.coordinate.latitude, + lon: location.coordinate.longitude, + altitude: location.altitude, + horizontalAccuracy: location.horizontalAccuracy, + verticalAccuracy: location.verticalAccuracy, + speed: location.speed, + timestamp: location.timestamp) + } +} diff --git a/ios/App/CoachWatch/RouteFilter.swift b/ios/App/CoachWatch/RouteFilter.swift new file mode 100644 index 0000000..7067852 --- /dev/null +++ b/ios/App/CoachWatch/RouteFilter.swift @@ -0,0 +1,211 @@ +import Foundation + +/* Filtrage de la trace GPS et dénivelé, en Foundation pur. + * + * **Pourquoi ce fichier est séparé de CoreLocation.** `CLLocation` n'existe pas + * sur Linux : tant que le filtrage vit dans la classe qui parle au GPS, il ne + * se teste que sur un poignet, en marchant. Ici, `GeoFix` recopie les seuls + * champs dont le filtrage a besoin ; `LocationTracker` fait la conversion et + * reste, lui, non testable hors Xcode. + * + * **Ce que ce fichier NE fait PAS.** Il ne produit pas la distance officielle + * de la séance. HealthKit collecte `distanceWalkingRunning` / `distanceCycling` + * par fusion GPS + accéléromètre, et fait autorité — c'est déjà ce que lit + * `WorkoutManager`. La distance calculée ici sert à la carte et au découpage + * des étapes en distance, où l'on a besoin d'une valeur à chaque point plutôt + * qu'à chaque échantillon HealthKit. + */ + +/// Un point GPS, réduit à ce dont le filtrage a besoin. +public struct GeoFix: Equatable, Sendable { + public var lat: Double + public var lon: Double + /// Mètres au-dessus du niveau de la mer. + public var altitude: Double + /// Rayon d'incertitude horizontale en mètres. **Négatif = fix invalide**, + /// convention Apple. + public var horizontalAccuracy: Double + /// Incertitude verticale. Négatif = altitude non fiable. + public var verticalAccuracy: Double + /// Vitesse instantanée en m/s. **Négatif = inconnue**, convention Apple. + public var speed: Double + public var timestamp: Date + + public init(lat: Double, lon: Double, altitude: Double = 0, + horizontalAccuracy: Double = 5, verticalAccuracy: Double = 5, + speed: Double = -1, timestamp: Date) { + self.lat = lat + self.lon = lon + self.altitude = altitude + self.horizontalAccuracy = horizontalAccuracy + self.verticalAccuracy = verticalAccuracy + self.speed = speed + self.timestamp = timestamp + } +} + +/// Pourquoi un point a été écarté. Renvoyé plutôt que jeté en silence : sur le +/// terrain, « la trace est trouée » sans plus d'explication ne se diagnostique +/// pas — la répartition des motifs, elle, se lit. +public enum FixRejection: String, Equatable, Sendable { + case invalidAccuracy // horizontalAccuracy <= 0 : fix inutilisable + case tooImprecise // au-delà du rayon toléré + case outOfOrder // arrivé après un point plus récent + case duplicate // même horodatage que le précédent + case implausibleSpeed // saut impossible entre deux points +} + +public enum FixOutcome: Equatable, Sendable { + case accepted + case rejected(FixRejection) +} + +/// Accumule une trace propre : filtre les points douteux, cumule distance et +/// dénivelé. +/// +/// `struct` mutable et sans horloge interne, comme `IntervalEngine` : rejouer +/// la même suite de points redonne le même résultat. +public struct RouteFilter: Equatable, Sendable { + + /// Rayon d'incertitude maximum toléré, en mètres. + /// + /// 50 m est le seuil retenu par l'exemple de trace d'Apple. Plus strict + /// perdrait les premières secondes sous couvert forestier ; plus laxiste + /// laisserait entrer les points « sautés » qui gonflent la distance. + public var maxHorizontalAccuracy: Double + + /// Vitesse au-delà de laquelle un déplacement entre deux points est jugé + /// impossible, en m/s. 30 m/s ≈ 108 km/h : au-dessus, c'est une + /// téléportation GPS ou un trajet en voiture, pas une séance. + public var maxPlausibleSpeed: Double + + /// Écart vertical à franchir avant de compter une montée ou une descente. + /// + /// ⚠️ Sans ce seuil, le bruit de l'altimètre s'accumule : quelques + /// dizaines de centimètres d'oscillation à chaque point produisent des + /// centaines de mètres de D+ sur une sortie plate. L'hystérésis ne compte + /// que les changements de sens confirmés. + /// + /// ⚠️ **Le seuil seul ne suffit pas** — mesuré en test : un bruit alternant + /// de ±1,5 m, soit exactement l'amplitude du seuil, était compté à chaque + /// alternance et produisait 297 m de D+ sur un parcours plat. C'est + /// inhérent à tout seuil : une oscillation d'amplitude ≥ seuil passe. D'où + /// le lissage préalable ci-dessous, qui est le vrai remède. + public var elevationThreshold: Double + + /// Nombre de points de la moyenne glissante appliquée à l'altitude avant + /// l'hystérésis. `1` désactive le lissage. + /// + /// Le bruit alternant s'annule dans la moyenne, une vraie pente la + /// traverse. Contrepartie assumée : la moyenne **retarde** le signal d'une + /// demi-fenêtre, ce qui ampute le D+ de quelques mètres en fin de trace. + /// Sans conséquence ici — ce D+ sert l'affichage pendant la séance, alors + /// que la valeur qui fait foi est le D+ barométrique d'Apple, relu après + /// coup depuis les métadonnées HealthKit. + public var elevationSmoothing: Int + + public private(set) var distanceM: Double = 0 + public private(set) var ascentM: Double = 0 + public private(set) var descentM: Double = 0 + public private(set) var acceptedCount: Int = 0 + public private(set) var rejectedCount: Int = 0 + public private(set) var lastAccepted: GeoFix? + + /// Altitude de référence de l'hystérésis. + private var pivotAltitude: Double? + /// Fenêtre glissante des altitudes retenues. + private var altWindow: [Double] = [] + + public init(maxHorizontalAccuracy: Double = 50, + maxPlausibleSpeed: Double = 30, + elevationThreshold: Double = 3, + elevationSmoothing: Int = 5) { + self.maxHorizontalAccuracy = maxHorizontalAccuracy + self.maxPlausibleSpeed = maxPlausibleSpeed + self.elevationThreshold = elevationThreshold + self.elevationSmoothing = max(1, elevationSmoothing) + } + + /// Soumet un point. Renvoie s'il a été retenu, et pourquoi sinon. + @discardableResult + public mutating func add(_ fix: GeoFix) -> FixOutcome { + if fix.horizontalAccuracy <= 0 { + rejectedCount += 1 + return .rejected(.invalidAccuracy) + } + if fix.horizontalAccuracy > maxHorizontalAccuracy { + rejectedCount += 1 + return .rejected(.tooImprecise) + } + + if let previous = lastAccepted { + let dt = fix.timestamp.timeIntervalSince(previous.timestamp) + if dt == 0 { + rejectedCount += 1 + return .rejected(.duplicate) + } + if dt < 0 { + // CoreLocation peut livrer des points dans le désordre après + // une reprise ; les intégrer ferait reculer la distance. + rejectedCount += 1 + return .rejected(.outOfOrder) + } + let step = Self.distance(from: previous, to: fix) + if step / dt > maxPlausibleSpeed { + rejectedCount += 1 + return .rejected(.implausibleSpeed) + } + distanceM += step + } + + accumulateElevation(fix) + lastAccepted = fix + acceptedCount += 1 + return .accepted + } + + /// Distance orthodromique entre deux points, en mètres (formule de + /// haversine). Volontairement 2D : c'est la convention de toutes les + /// plateformes, et intégrer la pente gonflerait la distance par rapport à + /// ce qu'affichent Santé, Strava ou la montre. + public static func distance(from a: GeoFix, to b: GeoFix) -> Double { + // Rayon moyen terrestre WGS84. + let radius = 6_371_008.8 + let φ1 = a.lat * .pi / 180 + let φ2 = b.lat * .pi / 180 + let dφ = (b.lat - a.lat) * .pi / 180 + let dλ = (b.lon - a.lon) * .pi / 180 + let h = sin(dφ / 2) * sin(dφ / 2) + + cos(φ1) * cos(φ2) * sin(dλ / 2) * sin(dλ / 2) + return 2 * radius * asin(min(1, sqrt(h))) + } + + /// Hystérésis du dénivelé : on ne valide un sens qu'après avoir franchi le + /// seuil depuis le dernier point de bascule. + private mutating func accumulateElevation(_ fix: GeoFix) { + // Une altitude sans incertitude fiable ne doit pas nourrir le D+ : le + // GPS seul dérive de plusieurs mètres, l'altimètre barométrique non. + guard fix.verticalAccuracy > 0 else { return } + + // Lissage d'abord : le bruit alternant s'annule dans la moyenne. On + // n'attend pas que la fenêtre soit pleine — sinon les premiers points + // seraient perdus, et avec eux le début de la première montée. + altWindow.append(fix.altitude) + if altWindow.count > elevationSmoothing { altWindow.removeFirst() } + let smoothed = altWindow.reduce(0, +) / Double(altWindow.count) + + guard let pivot = pivotAltitude else { + pivotAltitude = smoothed + return + } + let delta = smoothed - pivot + if delta >= elevationThreshold { + ascentM += delta + pivotAltitude = smoothed + } else if delta <= -elevationThreshold { + descentM += -delta + pivotAltitude = smoothed + } + // Entre les deux : oscillation, on ne bouge pas le pivot. + } +} diff --git a/ios/App/CoachWatch/WorkoutManager.swift b/ios/App/CoachWatch/WorkoutManager.swift index 68e71df..3669d7a 100644 --- a/ios/App/CoachWatch/WorkoutManager.swift +++ b/ios/App/CoachWatch/WorkoutManager.swift @@ -49,8 +49,18 @@ final class WorkoutManager: NSObject, ObservableObject { // ne la produit pas, les stats restent vides : `ingest` retombe alors // sur la vitesse moyenne dérivée de la distance. let speedType = Self.speedType(for: activityType) - let read: Set = [hr, energy, distanceType, speedType] - let share: Set = [HKQuantityType.workoutType(), hr, energy, distanceType] + // La trace exige d'être demandée en lecture ET en écriture — Apple : + // « you must request permission to read and share both HKWorkout and + // HKWorkoutRoute samples ». Sans elle, `finishRoute` échoue et la + // sortie n'a pas de parcours. + let route = HKSeriesType.workoutRoute() + let outdoor = (locationType == .outdoor) + var read: Set = [hr, energy, distanceType, speedType] + var share: Set = [HKQuantityType.workoutType(), hr, energy, distanceType] + if outdoor { + read.insert(route) + share.insert(route) + } do { try await healthStore.requestAuthorization(toShare: share, read: read) @@ -66,6 +76,12 @@ final class WorkoutManager: NSObject, ObservableObject { return } + // Demandée ici, donc au premier plan : watchOS n'affiche pas la feuille + // d'autorisation depuis l'arrière-plan. Volontairement « quand + // l'app est active » et jamais « toujours » — sur watchOS ce dernier + // mène à un parcours d'autorisation qui boucle. + if outdoor { LocationTracker.shared.requestAuthorization() } + let config = HKWorkoutConfiguration() config.activityType = activityType config.locationType = locationType @@ -87,6 +103,19 @@ final class WorkoutManager: NSObject, ObservableObject { isRunning = true statusMessage = nil workoutLog.info("beginCollection OK, isRunning=true") + + // GPS uniquement en extérieur : en salle il ne ferait que vider la + // batterie pour une trace immobile. + if outdoor { + // Apple déconseille d'instancier un HKWorkoutRouteBuilder + // directement : celui du builder de séance associe la trace. + let routeBuilder = builder.seriesBuilder(for: HKSeriesType.workoutRoute()) + as? HKWorkoutRouteBuilder + if routeBuilder == nil { + workoutLog.error("seriesBuilder n'a pas rendu un HKWorkoutRouteBuilder") + } + LocationTracker.shared.start(activity: activityType, routeBuilder: routeBuilder) + } } catch { self.session = nil self.builder = nil @@ -264,14 +293,26 @@ extension WorkoutManager: HKWorkoutSessionDelegate { let paused = (toState == .paused) Task { @MainActor in self.isPaused = paused + // Le flux GPS reste ouvert pendant la pause : watchOS interdit + // de redémarrer la localisation depuis l'arrière-plan, donc on + // ignore les points au lieu de couper le manager. + LocationTracker.shared.setPaused(paused) ConnectivityManager.shared.sendPaused(paused) } } guard toState == .ended else { return } let builder = workoutSession.associatedWorkoutBuilder() - Task { + Task { @MainActor in + // Ordre imposé par Apple : couper le GPS, clore la collecte, + // sauvegarder la séance, et SEULEMENT ensuite clore la trace en + // l'associant au workout obtenu. + LocationTracker.shared.stopTracking() try? await builder.endCollection(at: date) - _ = try? await builder.finishWorkout() + // ⚠️ `finishWorkout()` rend `nil` SANS erreur quand la montre est + // verrouillée : ce n'est pas un échec. On poursuit avec `nil`, la + // trace sera sauvegardée sans association plutôt que perdue. + let workout = try? await builder.finishWorkout() + await LocationTracker.shared.finishRoute(with: workout) } Task { @MainActor in self.isRunning = false diff --git a/tests-linux/Sources/CoachModel/RouteFilter.swift b/tests-linux/Sources/CoachModel/RouteFilter.swift new file mode 120000 index 0000000..cad405f --- /dev/null +++ b/tests-linux/Sources/CoachModel/RouteFilter.swift @@ -0,0 +1 @@ +../../../ios/App/CoachWatch/RouteFilter.swift \ No newline at end of file diff --git a/tests-linux/Tests/CoachModelTests/RouteFilterTests.swift b/tests-linux/Tests/CoachModelTests/RouteFilterTests.swift new file mode 100644 index 0000000..ac5da34 --- /dev/null +++ b/tests-linux/Tests/CoachModelTests/RouteFilterTests.swift @@ -0,0 +1,206 @@ +import XCTest +@testable import CoachModel + +/* Filtrage de la trace GPS. + * + * Les cas testés ne sont pas théoriques : ce sont les quatre façons dont une + * trace se salit réellement — acquisition GPS des premières secondes, couvert + * forestier, points livrés dans le désordre après une reprise, et bruit + * d'altimètre sur terrain plat. + */ +final class RouteFilterTests: XCTestCase { + + private let t0 = Date(timeIntervalSince1970: 1_755_000_000) + + private func fix(_ lat: Double, _ lon: Double, + at seconds: TimeInterval, + alt: Double = 600, + hAcc: Double = 5, + vAcc: Double = 3) -> GeoFix { + GeoFix(lat: lat, lon: lon, altitude: alt, + horizontalAccuracy: hAcc, verticalAccuracy: vAcc, + speed: -1, timestamp: t0.addingTimeInterval(seconds)) + } + + // MARK: Distance + + func testHaversineMatchesAKnownDistance() { + // Blonay → Vevey, deux points relevés sur la carte. La distance réelle + // à vol d'oiseau est d'environ 3,4 km. + let blonay = fix(46.4667, 6.9167, at: 0) + let vevey = fix(46.4628, 6.8419, at: 60) + let d = RouteFilter.distance(from: blonay, to: vevey) + XCTAssertEqual(d, 5760, accuracy: 200, + "Écart Blonay–Vevey en longitude : ~5,8 km") + } + + func testDistanceIsZeroForTheSamePoint() { + let a = fix(46.4667, 6.9167, at: 0) + XCTAssertEqual(RouteFilter.distance(from: a, to: a), 0, accuracy: 0.0001) + } + + func testDistanceAccumulatesAlongATrack() { + var filter = RouteFilter() + // Cinq points espacés d'environ 100 m vers l'est. + for i in 0..<5 { + let lon = 6.9167 + Double(i) * 0.0013 + XCTAssertEqual(filter.add(fix(46.4667, lon, at: Double(i) * 30)), .accepted) + } + XCTAssertEqual(filter.acceptedCount, 5) + XCTAssertEqual(filter.distanceM, 400, accuracy: 40) + } + + // MARK: Rejets + + func testFixWithNegativeAccuracyIsRejected() { + var filter = RouteFilter() + // Convention Apple : une précision négative signale un fix inutilisable. + XCTAssertEqual(filter.add(fix(46.4667, 6.9167, at: 0, hAcc: -1)), + .rejected(.invalidAccuracy)) + XCTAssertEqual(filter.acceptedCount, 0) + XCTAssertEqual(filter.distanceM, 0) + } + + func testImpreciseFixesAreRejected() { + var filter = RouteFilter() + // Les premières secondes d'une séance : le GPS n'a pas encore convergé. + XCTAssertEqual(filter.add(fix(46.4667, 6.9167, at: 0, hAcc: 165)), + .rejected(.tooImprecise)) + XCTAssertEqual(filter.add(fix(46.4667, 6.9167, at: 3, hAcc: 65)), + .rejected(.tooImprecise)) + XCTAssertEqual(filter.add(fix(46.4667, 6.9167, at: 8, hAcc: 12)), .accepted) + XCTAssertEqual(filter.rejectedCount, 2) + XCTAssertEqual(filter.acceptedCount, 1) + } + + func testTeleportationIsRejected() { + var filter = RouteFilter() + XCTAssertEqual(filter.add(fix(46.4667, 6.9167, at: 0)), .accepted) + // Point sauté de ~5,8 km en une seconde : impossible à pied. + XCTAssertEqual(filter.add(fix(46.4628, 6.8419, at: 1)), + .rejected(.implausibleSpeed)) + XCTAssertEqual(filter.distanceM, 0, accuracy: 0.001, + "Un saut GPS ne doit pas gonfler la distance") + // Le point suivant, plausible, repart du dernier point ACCEPTÉ. + XCTAssertEqual(filter.add(fix(46.4667, 6.9180, at: 10)), .accepted) + XCTAssertEqual(filter.distanceM, 100, accuracy: 20) + } + + func testOutOfOrderAndDuplicateFixesAreRejected() { + var filter = RouteFilter() + XCTAssertEqual(filter.add(fix(46.4667, 6.9167, at: 100)), .accepted) + XCTAssertEqual(filter.add(fix(46.4667, 6.9168, at: 100)), .rejected(.duplicate)) + XCTAssertEqual(filter.add(fix(46.4667, 6.9168, at: 90)), .rejected(.outOfOrder)) + XCTAssertEqual(filter.acceptedCount, 1) + } + + // MARK: Dénivelé + + func testFlatNoisyTerrainProducesNoElevationGain() { + // Le piège central : ±1,5 m de bruit d'altimètre sur 200 points. Sans + // hystérésis, on cumulerait des centaines de mètres de D+ sur du plat. + var filter = RouteFilter() + for i in 0..<200 { + let noise = (i % 2 == 0) ? 1.5 : -1.5 + _ = filter.add(fix(46.4667, 6.9167 + Double(i) * 0.0002, + at: Double(i) * 5, alt: 600 + noise)) + } + XCTAssertEqual(filter.ascentM, 0, accuracy: 0.001, + "Le bruit d'altimètre ne doit pas produire de D+") + XCTAssertEqual(filter.descentM, 0, accuracy: 0.001) + } + + func testRealClimbIsCounted() { + // Cadence réaliste : un point par seconde, +0,5 m par point — soit une + // montée de 100 m en 3 min 20, ce qui est déjà raide. + var filter = RouteFilter() + for i in 0...200 { + _ = filter.add(fix(46.4667, 6.9167 + Double(i) * 0.00005, + at: Double(i), alt: 600 + Double(i) * 0.5)) + } + XCTAssertEqual(filter.ascentM, 100, accuracy: 5) + XCTAssertEqual(filter.descentM, 0, accuracy: 0.001) + } + + func testDescentIsCountedSeparately() { + var filter = RouteFilter() + for i in 0...200 { // 600 → 700 + _ = filter.add(fix(46.4667, 6.9167 + Double(i) * 0.00005, + at: Double(i), alt: 600 + Double(i) * 0.5)) + } + for i in 1...200 { // 700 → 600 + _ = filter.add(fix(46.4667, 6.9267 + Double(i) * 0.00005, + at: 200 + Double(i), alt: 700 - Double(i) * 0.5)) + } + XCTAssertEqual(filter.ascentM, 100, accuracy: 5) + XCTAssertEqual(filter.descentM, 100, accuracy: 5) + } + + func testSmoothingClipsASharpPeak() { + // Comportement assumé, pas un bug : une pointe franchie en quelques + // points est arrondie par la moyenne glissante, donc sous-comptée. Ce + // test existe pour que la perte soit connue et surveillée — le D+ qui + // fait foi reste celui, barométrique, qu'Apple écrit dans les + // métadonnées de la séance. + var filter = RouteFilter() + for i in 0...10 { + _ = filter.add(fix(46.4667, 6.9167 + Double(i) * 0.0005, + at: Double(i) * 20, alt: 600 + Double(i) * 10)) + } + for i in 1...10 { + _ = filter.add(fix(46.4667, 6.9217 + Double(i) * 0.0005, + at: 200 + Double(i) * 20, alt: 700 - Double(i) * 10)) + } + XCTAssertLessThan(filter.ascentM, 100) + XCTAssertLessThan(filter.descentM, 100) + XCTAssertGreaterThan(filter.ascentM, 60, + "L'écrêtage doit rester partiel, pas effacer la montée") + } + + func testAltitudeWithoutVerticalAccuracyIsIgnored() { + // Le GPS seul dérive verticalement de plusieurs mètres. Apple signale + // une altitude non fiable par une incertitude négative. + var filter = RouteFilter() + for i in 0...20 { + _ = filter.add(fix(46.4667, 6.9167 + Double(i) * 0.0005, + at: Double(i) * 20, + alt: 600 + Double(i) * 5, vAcc: -1)) + } + XCTAssertEqual(filter.ascentM, 0, accuracy: 0.001) + XCTAssertEqual(filter.acceptedCount, 21, + "Le point reste valide pour la trace, seule l'altitude est écartée") + } + + // MARK: Robustesse + + func testFirstFixEstablishesTheOriginWithoutDistance() { + var filter = RouteFilter() + XCTAssertEqual(filter.add(fix(46.4667, 6.9167, at: 0)), .accepted) + XCTAssertEqual(filter.distanceM, 0) + XCTAssertNotNil(filter.lastAccepted) + } + + func testReplayingTheSameTrackGivesTheSameResult() { + let track = (0..<50).map { i in + fix(46.4667 + Double(i) * 0.0001, 6.9167 + Double(i) * 0.0003, + at: Double(i) * 4, alt: 600 + Double(i) * 2) + } + var a = RouteFilter(), b = RouteFilter() + for f in track { a.add(f) } + for f in track { b.add(f) } + XCTAssertEqual(a, b) + } + + func testThresholdsAreConfigurable() { + // Une sortie à VTT électrique dépasse allègrement les 30 km/h : le + // seuil doit pouvoir s'ajuster sans toucher au filtre. + var strict = RouteFilter(maxPlausibleSpeed: 5) + strict.add(fix(46.4667, 6.9167, at: 0)) + XCTAssertEqual(strict.add(fix(46.4667, 6.9180, at: 1)), + .rejected(.implausibleSpeed)) + + var loose = RouteFilter(maxPlausibleSpeed: 200) + loose.add(fix(46.4667, 6.9167, at: 0)) + XCTAssertEqual(loose.add(fix(46.4667, 6.9180, at: 1)), .accepted) + } +}