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