feat: implement Haptika PPG and Watch V1

This commit is contained in:
Sacha
2026-07-31 16:19:42 +02:00
parent 6b7d4e0728
commit b9930b7378
249 changed files with 46475 additions and 122 deletions
+463 -8
View File
@@ -7,26 +7,120 @@
objects = {
/* Begin PBXBuildFile section */
09FC73E86512568662FCE68E /* WatchMeasurementView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5F6CA55B03D1BB7CE459E8E3 /* WatchMeasurementView.swift */; };
0C80B921A6F3F58F76C31292 /* libPods-HaptikaApp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5DCACB8F33CDC322A6C60F78 /* libPods-HaptikaApp.a */; };
114BDE84D8AA9241891F063A /* regulation-protocols.v1.json in Resources */ = {isa = PBXBuildFile; fileRef = 9EDE70EB42E24C3B38B51541 /* regulation-protocols.v1.json */; };
13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 13B07FB51A68108700A75B9A /* Images.xcassets */; };
140882F922D2F73D8BDA7471 /* WatchResourceArbiter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0D44511883F77E9921F0A31A /* WatchResourceArbiter.swift */; };
1497DAF82CAFD24F955F103F /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 13B07FB81A68108700A75B9A /* PrivacyInfo.xcprivacy */; };
1BD3026EBCA93A2454A9B571 /* WatchStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE56816B3C44D7F4EDE469C4 /* WatchStore.swift */; };
2754CCE8D2EB3703FA37D055 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = AF06B58CBAC27F9902D1B4A5 /* Assets.xcassets */; };
4BCA2B05E3DE6C7AAB303B80 /* WatchAppModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 111670E33699C47D3A93AC41 /* WatchAppModel.swift */; };
4E8C31BD8AC8CA86BADD6118 /* RegulationProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6B35DC921291116A06F055C /* RegulationProtocol.swift */; };
52352554AE62A5DBE62E1F80 /* WatchCoherenceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8D79C037057C356A5804C171 /* WatchCoherenceView.swift */; };
57EE4CD6E860841A6FBE3E31 /* WatchHistoryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7825C4ECEEDB6FBFAC4913B /* WatchHistoryView.swift */; };
761780ED2CA45674006654EE /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 761780EC2CA45674006654EE /* AppDelegate.swift */; };
77BA4CD21D33B597ECD2FDE7 /* WatchBeforeAfterFlowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 816CF2099D6657DF684CC745 /* WatchBeforeAfterFlowView.swift */; };
78F476213C9C09971A280386 /* WatchModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 32D728BA3B4A37EEAC4733EB /* WatchModels.swift */; };
7EE01244D179E8FF19B34AB1 /* WatchRootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D442ED95A5832B6D76BE3836 /* WatchRootView.swift */; };
81AB9BB82411601600AC10FF /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 81AB9BB72411601600AC10FF /* LaunchScreen.storyboard */; };
85CD8DEF4D32F6F143395961 /* WatchCoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = ED5549BB07ECF7888A132AE9 /* WatchCoreTests.swift */; };
8B283CAD2473B5F13BFB92FD /* WatchBaseline.swift in Sources */ = {isa = PBXBuildFile; fileRef = E742A36D6693717080FD19DB /* WatchBaseline.swift */; };
93FBBB939E9FBC3D0DECCC37 /* WatchExercisesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5C7116D54CE5D0470079330 /* WatchExercisesView.swift */; };
A03E880ED4913D3E67FA728C /* WatchHapticService.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA7A2A9E8A782CA3D7F79124 /* WatchHapticService.swift */; };
A5EF56A31E4A899ADF00A55C /* WatchConnectivityService.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDE53A2DF65CFBE48E21F8AF /* WatchConnectivityService.swift */; };
B823EAA6372CFAB8566CB32C /* HaptikaWatchApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D800E0EB5A1B0DA16064C06 /* HaptikaWatchApp.swift */; };
C0DCCDA0101428B2C431DF72 /* WatchCardiacMirrorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 45C777EEF16A3B58BB485723 /* WatchCardiacMirrorView.swift */; };
CAE5DC58530D8CEB55E32C3E /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 78E5969515006E0E77675C21 /* Foundation.framework */; };
CC2201B4509C4CF1F751EDF3 /* WatchTheme.swift in Sources */ = {isa = PBXBuildFile; fileRef = A845A00D463125569C206227 /* WatchTheme.swift */; };
D7C2CEDAA071025E0CFBA522 /* WatchMeasurementValidator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C621C028FA5CDAE7018D032 /* WatchMeasurementValidator.swift */; };
E0CE73E6D3D989EDA8C0E890 /* WatchHealthMeasurementService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D9ECA1A2507EBE3D6CBBA66F /* WatchHealthMeasurementService.swift */; };
F7A804FDCA84B13EE87ED3FC /* WatchSyncEnvelope.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7E2F44A1D3FF2919ED02C7BC /* WatchSyncEnvelope.swift */; };
F8117E6E1721ACDD6B88F7C1 /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 78E5969515006E0E77675C21 /* Foundation.framework */; };
FACC8C666D0D4D58E2E18B40 /* Haptika Watch App.app in Embed Watch Content */ = {isa = PBXBuildFile; fileRef = 9DB449283FD6350D75DDC204 /* Haptika Watch App.app */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
1DC509C0BED42BBA3A0DAE4E /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = 83CBB9F71A601CBA00E9B192 /* Project object */;
proxyType = 1;
remoteGlobalIDString = AB289AE8AED259F5623A2E42;
remoteInfo = "Haptika Watch App";
};
2FE1ADCC102A2D599E060B6D /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = 83CBB9F71A601CBA00E9B192 /* Project object */;
proxyType = 1;
remoteGlobalIDString = AB289AE8AED259F5623A2E42;
remoteInfo = "Haptika Watch App";
};
/* End PBXContainerItemProxy section */
/* Begin PBXCopyFilesBuildPhase section */
EFAEDF4DE57326DECDBE233E /* Embed Watch Content */ = {
isa = PBXCopyFilesBuildPhase;
buildActionMask = 2147483647;
dstPath = "";
dstSubfolderSpec = 16;
files = (
FACC8C666D0D4D58E2E18B40 /* Haptika Watch App.app in Embed Watch Content */,
);
name = "Embed Watch Content";
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXCopyFilesBuildPhase section */
/* Begin PBXFileReference section */
0142DCF485AB5826009D8407 /* Info.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
0D44511883F77E9921F0A31A /* WatchResourceArbiter.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WatchResourceArbiter.swift; sourceTree = "<group>"; };
111670E33699C47D3A93AC41 /* WatchAppModel.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WatchAppModel.swift; sourceTree = "<group>"; };
13B07F961A680F5B00A75B9A /* HaptikaApp.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = HaptikaApp.app; sourceTree = BUILT_PRODUCTS_DIR; };
13B07FB51A68108700A75B9A /* Images.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Images.xcassets; path = HaptikaApp/Images.xcassets; sourceTree = "<group>"; };
13B07FB61A68108700A75B9A /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = Info.plist; path = HaptikaApp/Info.plist; sourceTree = "<group>"; };
13B07FB81A68108700A75B9A /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = PrivacyInfo.xcprivacy; path = HaptikaApp/PrivacyInfo.xcprivacy; sourceTree = "<group>"; };
2D800E0EB5A1B0DA16064C06 /* HaptikaWatchApp.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = HaptikaWatchApp.swift; sourceTree = "<group>"; };
32D728BA3B4A37EEAC4733EB /* WatchModels.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WatchModels.swift; sourceTree = "<group>"; };
3B4392A12AC88292D35C810B /* Pods-HaptikaApp.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-HaptikaApp.debug.xcconfig"; path = "Target Support Files/Pods-HaptikaApp/Pods-HaptikaApp.debug.xcconfig"; sourceTree = "<group>"; };
3C621C028FA5CDAE7018D032 /* WatchMeasurementValidator.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WatchMeasurementValidator.swift; sourceTree = "<group>"; };
45C777EEF16A3B58BB485723 /* WatchCardiacMirrorView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WatchCardiacMirrorView.swift; sourceTree = "<group>"; };
5709B34CF0A7D63546082F79 /* Pods-HaptikaApp.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-HaptikaApp.release.xcconfig"; path = "Target Support Files/Pods-HaptikaApp/Pods-HaptikaApp.release.xcconfig"; sourceTree = "<group>"; };
5DCACB8F33CDC322A6C60F78 /* libPods-HaptikaApp.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-HaptikaApp.a"; sourceTree = BUILT_PRODUCTS_DIR; };
5F6CA55B03D1BB7CE459E8E3 /* WatchMeasurementView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WatchMeasurementView.swift; sourceTree = "<group>"; };
761780EC2CA45674006654EE /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = AppDelegate.swift; path = HaptikaApp/AppDelegate.swift; sourceTree = "<group>"; };
78E5969515006E0E77675C21 /* 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; };
7E2F44A1D3FF2919ED02C7BC /* WatchSyncEnvelope.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WatchSyncEnvelope.swift; sourceTree = "<group>"; };
816CF2099D6657DF684CC745 /* WatchBeforeAfterFlowView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WatchBeforeAfterFlowView.swift; sourceTree = "<group>"; };
81AB9BB72411601600AC10FF /* LaunchScreen.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; name = LaunchScreen.storyboard; path = HaptikaApp/LaunchScreen.storyboard; sourceTree = "<group>"; };
8D79C037057C356A5804C171 /* WatchCoherenceView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WatchCoherenceView.swift; sourceTree = "<group>"; };
9DB449283FD6350D75DDC204 /* Haptika Watch App.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Haptika Watch App.app"; sourceTree = BUILT_PRODUCTS_DIR; };
9EDE70EB42E24C3B38B51541 /* regulation-protocols.v1.json */ = {isa = PBXFileReference; includeInIndex = 1; path = "regulation-protocols.v1.json"; sourceTree = "<group>"; };
A845A00D463125569C206227 /* WatchTheme.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WatchTheme.swift; sourceTree = "<group>"; };
A9FDC3E5AA62750BDCA0CB59 /* HaptikaWatchTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = HaptikaWatchTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
AF06B58CBAC27F9902D1B4A5 /* Assets.xcassets */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
BF8BE67FB283769179D55328 /* HaptikaWatch.entitlements */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.entitlements; path = HaptikaWatch.entitlements; sourceTree = "<group>"; };
CDE53A2DF65CFBE48E21F8AF /* WatchConnectivityService.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WatchConnectivityService.swift; sourceTree = "<group>"; };
CE56816B3C44D7F4EDE469C4 /* WatchStore.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WatchStore.swift; sourceTree = "<group>"; };
D442ED95A5832B6D76BE3836 /* WatchRootView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WatchRootView.swift; sourceTree = "<group>"; };
D5C7116D54CE5D0470079330 /* WatchExercisesView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WatchExercisesView.swift; sourceTree = "<group>"; };
D7825C4ECEEDB6FBFAC4913B /* WatchHistoryView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WatchHistoryView.swift; sourceTree = "<group>"; };
D9ECA1A2507EBE3D6CBBA66F /* WatchHealthMeasurementService.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WatchHealthMeasurementService.swift; sourceTree = "<group>"; };
E6B35DC921291116A06F055C /* RegulationProtocol.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RegulationProtocol.swift; sourceTree = "<group>"; };
E742A36D6693717080FD19DB /* WatchBaseline.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WatchBaseline.swift; sourceTree = "<group>"; };
ED297162215061F000B7C4FE /* JavaScriptCore.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = JavaScriptCore.framework; path = System/Library/Frameworks/JavaScriptCore.framework; sourceTree = SDKROOT; };
ED5549BB07ECF7888A132AE9 /* WatchCoreTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WatchCoreTests.swift; sourceTree = "<group>"; };
FA7A2A9E8A782CA3D7F79124 /* WatchHapticService.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WatchHapticService.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
0F146085D8726D5484AC45CF /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
F8117E6E1721ACDD6B88F7C1 /* Foundation.framework in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
13B07F8C1A680F5B00A75B9A /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
@@ -35,9 +129,32 @@
);
runOnlyForDeploymentPostprocessing = 0;
};
B6BFDAFC89FF560DC3940EE4 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
CAE5DC58530D8CEB55E32C3E /* Foundation.framework in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
0AF569BB6E060433D5185B95 /* Core */ = {
isa = PBXGroup;
children = (
E6B35DC921291116A06F055C /* RegulationProtocol.swift */,
E742A36D6693717080FD19DB /* WatchBaseline.swift */,
3C621C028FA5CDAE7018D032 /* WatchMeasurementValidator.swift */,
32D728BA3B4A37EEAC4733EB /* WatchModels.swift */,
0D44511883F77E9921F0A31A /* WatchResourceArbiter.swift */,
CE56816B3C44D7F4EDE469C4 /* WatchStore.swift */,
7E2F44A1D3FF2919ED02C7BC /* WatchSyncEnvelope.swift */,
);
name = Core;
path = Core;
sourceTree = "<group>";
};
13B07FAE1A68108700A75B9A /* HaptikaApp */ = {
isa = PBXGroup;
children = (
@@ -55,10 +172,20 @@
children = (
ED297162215061F000B7C4FE /* JavaScriptCore.framework */,
5DCACB8F33CDC322A6C60F78 /* libPods-HaptikaApp.a */,
A545FB6B835260DD2FAABBBD /* watchOS */,
);
name = Frameworks;
sourceTree = "<group>";
};
47085F82A7AB6010AA4FB195 /* HaptikaWatchTests */ = {
isa = PBXGroup;
children = (
ED5549BB07ECF7888A132AE9 /* WatchCoreTests.swift */,
);
name = HaptikaWatchTests;
path = HaptikaWatchTests;
sourceTree = "<group>";
};
832341AE1AAA6A7D00B99B32 /* Libraries */ = {
isa = PBXGroup;
children = (
@@ -74,6 +201,9 @@
83CBBA001A601CBA00E9B192 /* Products */,
2D16E6871FA4F8E400B85C8A /* Frameworks */,
BBD78D7AC51CEA395F1C20DB /* Pods */,
A378E27FE3D44AD2EEF64FC9 /* HaptikaShared */,
47085F82A7AB6010AA4FB195 /* HaptikaWatchTests */,
E900FA2623A3C868FA97EFEE /* HaptikaWatch */,
);
indentWidth = 2;
sourceTree = "<group>";
@@ -84,10 +214,40 @@
isa = PBXGroup;
children = (
13B07F961A680F5B00A75B9A /* HaptikaApp.app */,
9DB449283FD6350D75DDC204 /* Haptika Watch App.app */,
A9FDC3E5AA62750BDCA0CB59 /* HaptikaWatchTests.xctest */,
);
name = Products;
sourceTree = "<group>";
};
A378E27FE3D44AD2EEF64FC9 /* HaptikaShared */ = {
isa = PBXGroup;
children = (
9EDE70EB42E24C3B38B51541 /* regulation-protocols.v1.json */,
);
name = HaptikaShared;
path = ../shared;
sourceTree = "<group>";
};
A545FB6B835260DD2FAABBBD /* watchOS */ = {
isa = PBXGroup;
children = (
78E5969515006E0E77675C21 /* Foundation.framework */,
);
name = watchOS;
sourceTree = "<group>";
};
B06321B98A4E85D4005C94CB /* Services */ = {
isa = PBXGroup;
children = (
CDE53A2DF65CFBE48E21F8AF /* WatchConnectivityService.swift */,
FA7A2A9E8A782CA3D7F79124 /* WatchHapticService.swift */,
D9ECA1A2507EBE3D6CBBA66F /* WatchHealthMeasurementService.swift */,
);
name = Services;
path = Services;
sourceTree = "<group>";
};
BBD78D7AC51CEA395F1C20DB /* Pods */ = {
isa = PBXGroup;
children = (
@@ -97,6 +257,47 @@
path = Pods;
sourceTree = "<group>";
};
E900FA2623A3C868FA97EFEE /* HaptikaWatch */ = {
isa = PBXGroup;
children = (
EB11F95E679D64159FCFDE76 /* App */,
0AF569BB6E060433D5185B95 /* Core */,
B06321B98A4E85D4005C94CB /* Services */,
F1595BA33FD735C10E065549 /* Views */,
AF06B58CBAC27F9902D1B4A5 /* Assets.xcassets */,
0142DCF485AB5826009D8407 /* Info.plist */,
BF8BE67FB283769179D55328 /* HaptikaWatch.entitlements */,
);
name = HaptikaWatch;
path = HaptikaWatch;
sourceTree = "<group>";
};
EB11F95E679D64159FCFDE76 /* App */ = {
isa = PBXGroup;
children = (
2D800E0EB5A1B0DA16064C06 /* HaptikaWatchApp.swift */,
111670E33699C47D3A93AC41 /* WatchAppModel.swift */,
);
name = App;
path = App;
sourceTree = "<group>";
};
F1595BA33FD735C10E065549 /* Views */ = {
isa = PBXGroup;
children = (
816CF2099D6657DF684CC745 /* WatchBeforeAfterFlowView.swift */,
45C777EEF16A3B58BB485723 /* WatchCardiacMirrorView.swift */,
8D79C037057C356A5804C171 /* WatchCoherenceView.swift */,
D5C7116D54CE5D0470079330 /* WatchExercisesView.swift */,
D7825C4ECEEDB6FBFAC4913B /* WatchHistoryView.swift */,
5F6CA55B03D1BB7CE459E8E3 /* WatchMeasurementView.swift */,
D442ED95A5832B6D76BE3836 /* WatchRootView.swift */,
A845A00D463125569C206227 /* WatchTheme.swift */,
);
name = Views;
path = Views;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
@@ -111,16 +312,53 @@
00DD1BFF1BD5951E006B06BC /* Bundle React Native code and images */,
00EEFC60759A1932668264C0 /* [CP] Embed Pods Frameworks */,
E235C05ADACE081382539298 /* [CP] Copy Pods Resources */,
EFAEDF4DE57326DECDBE233E /* Embed Watch Content */,
);
buildRules = (
);
dependencies = (
4017CDC02DF1C913443BA87D /* PBXTargetDependency */,
);
name = HaptikaApp;
productName = HaptikaApp;
productReference = 13B07F961A680F5B00A75B9A /* HaptikaApp.app */;
productType = "com.apple.product-type.application";
};
AB289AE8AED259F5623A2E42 /* Haptika Watch App */ = {
isa = PBXNativeTarget;
buildConfigurationList = C9284034880924E3E8455E96 /* Build configuration list for PBXNativeTarget "Haptika Watch App" */;
buildPhases = (
2803498A232C8DD92F5D6055 /* Sources */,
0F146085D8726D5484AC45CF /* Frameworks */,
8BA43C7D9C0E69C3DFF61044 /* Resources */,
);
buildRules = (
);
dependencies = (
);
name = "Haptika Watch App";
productName = "Haptika Watch App";
productReference = 9DB449283FD6350D75DDC204 /* Haptika Watch App.app */;
productType = "com.apple.product-type.application";
};
EA92A2DC9365C1414126C0B1 /* HaptikaWatchTests */ = {
isa = PBXNativeTarget;
buildConfigurationList = 774FEB5EB351F846855029C1 /* Build configuration list for PBXNativeTarget "HaptikaWatchTests" */;
buildPhases = (
CD9C4519B70F549386BFF6FF /* Sources */,
B6BFDAFC89FF560DC3940EE4 /* Frameworks */,
B8FDE984F342D03CB0CC5583 /* Resources */,
);
buildRules = (
);
dependencies = (
CE1B0BDBE98F17187CFC4F67 /* PBXTargetDependency */,
);
name = HaptikaWatchTests;
productName = HaptikaWatchTests;
productReference = A9FDC3E5AA62750BDCA0CB59 /* HaptikaWatchTests.xctest */;
productType = "com.apple.product-type.bundle.unit-test";
};
/* End PBXNativeTarget section */
/* Begin PBXProject section */
@@ -132,6 +370,15 @@
13B07F861A680F5B00A75B9A = {
LastSwiftMigration = 1120;
};
AB289AE8AED259F5623A2E42 = {
CreatedOnToolsVersion = 26.0;
ProvisioningStyle = Automatic;
};
EA92A2DC9365C1414126C0B1 = {
CreatedOnToolsVersion = 26.0;
ProvisioningStyle = Automatic;
TestTargetID = AB289AE8AED259F5623A2E42;
};
};
};
buildConfigurationList = 83CBB9FA1A601CBA00E9B192 /* Build configuration list for PBXProject "HaptikaApp" */;
@@ -148,6 +395,8 @@
projectRoot = "";
targets = (
13B07F861A680F5B00A75B9A /* HaptikaApp */,
AB289AE8AED259F5623A2E42 /* Haptika Watch App */,
EA92A2DC9365C1414126C0B1 /* HaptikaWatchTests */,
);
};
/* End PBXProject section */
@@ -159,6 +408,23 @@
files = (
81AB9BB82411601600AC10FF /* LaunchScreen.storyboard in Resources */,
13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */,
1497DAF82CAFD24F955F103F /* PrivacyInfo.xcprivacy in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
8BA43C7D9C0E69C3DFF61044 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
2754CCE8D2EB3703FA37D055 /* Assets.xcassets in Resources */,
114BDE84D8AA9241891F063A /* regulation-protocols.v1.json in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
B8FDE984F342D03CB0CC5583 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -189,10 +455,14 @@
inputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-HaptikaApp/Pods-HaptikaApp-frameworks-${CONFIGURATION}-input-files.xcfilelist",
);
inputPaths = (
);
name = "[CP] Embed Pods Frameworks";
outputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-HaptikaApp/Pods-HaptikaApp-frameworks-${CONFIGURATION}-output-files.xcfilelist",
);
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-HaptikaApp/Pods-HaptikaApp-frameworks.sh\"\n";
@@ -228,10 +498,14 @@
inputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-HaptikaApp/Pods-HaptikaApp-resources-${CONFIGURATION}-input-files.xcfilelist",
);
inputPaths = (
);
name = "[CP] Copy Pods Resources";
outputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-HaptikaApp/Pods-HaptikaApp-resources-${CONFIGURATION}-output-files.xcfilelist",
);
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-HaptikaApp/Pods-HaptikaApp-resources.sh\"\n";
@@ -248,8 +522,58 @@
);
runOnlyForDeploymentPostprocessing = 0;
};
2803498A232C8DD92F5D6055 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
B823EAA6372CFAB8566CB32C /* HaptikaWatchApp.swift in Sources */,
4BCA2B05E3DE6C7AAB303B80 /* WatchAppModel.swift in Sources */,
4E8C31BD8AC8CA86BADD6118 /* RegulationProtocol.swift in Sources */,
8B283CAD2473B5F13BFB92FD /* WatchBaseline.swift in Sources */,
D7C2CEDAA071025E0CFBA522 /* WatchMeasurementValidator.swift in Sources */,
78F476213C9C09971A280386 /* WatchModels.swift in Sources */,
140882F922D2F73D8BDA7471 /* WatchResourceArbiter.swift in Sources */,
1BD3026EBCA93A2454A9B571 /* WatchStore.swift in Sources */,
F7A804FDCA84B13EE87ED3FC /* WatchSyncEnvelope.swift in Sources */,
A5EF56A31E4A899ADF00A55C /* WatchConnectivityService.swift in Sources */,
A03E880ED4913D3E67FA728C /* WatchHapticService.swift in Sources */,
E0CE73E6D3D989EDA8C0E890 /* WatchHealthMeasurementService.swift in Sources */,
77BA4CD21D33B597ECD2FDE7 /* WatchBeforeAfterFlowView.swift in Sources */,
C0DCCDA0101428B2C431DF72 /* WatchCardiacMirrorView.swift in Sources */,
52352554AE62A5DBE62E1F80 /* WatchCoherenceView.swift in Sources */,
93FBBB939E9FBC3D0DECCC37 /* WatchExercisesView.swift in Sources */,
57EE4CD6E860841A6FBE3E31 /* WatchHistoryView.swift in Sources */,
09FC73E86512568662FCE68E /* WatchMeasurementView.swift in Sources */,
7EE01244D179E8FF19B34AB1 /* WatchRootView.swift in Sources */,
CC2201B4509C4CF1F751EDF3 /* WatchTheme.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
CD9C4519B70F549386BFF6FF /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
85CD8DEF4D32F6F143395961 /* WatchCoreTests.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
/* Begin PBXTargetDependency section */
4017CDC02DF1C913443BA87D /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
name = "Haptika Watch App";
target = AB289AE8AED259F5623A2E42 /* Haptika Watch App */;
targetProxy = 2FE1ADCC102A2D599E060B6D /* PBXContainerItemProxy */;
};
CE1B0BDBE98F17187CFC4F67 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
name = "Haptika Watch App";
target = AB289AE8AED259F5623A2E42 /* Haptika Watch App */;
targetProxy = 1DC509C0BED42BBA3A0DAE4E /* PBXContainerItemProxy */;
};
/* End PBXTargetDependency section */
/* Begin XCBuildConfiguration section */
13B07F941A680F5B00A75B9A /* Debug */ = {
isa = XCBuildConfiguration;
@@ -257,10 +581,12 @@
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = HaptikaApp/HaptikaApp.entitlements;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = R537R56465;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = HaptikaApp/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 15.1;
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
@@ -271,12 +597,12 @@
"-ObjC",
"-lc++",
);
PRODUCT_BUNDLE_IDENTIFIER = "org.reactjs.native.example.$(PRODUCT_NAME:rfc1034identifier)";
PRODUCT_BUNDLE_IDENTIFIER = com.sachagaulin.haptika.r537r56465.dev;
PRODUCT_NAME = HaptikaApp;
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
TARGETED_DEVICE_FAMILY = 1;
VERSIONING_SYSTEM = "apple-generic";
};
name = Debug;
@@ -287,9 +613,11 @@
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = HaptikaApp/HaptikaApp.entitlements;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = R537R56465;
INFOPLIST_FILE = HaptikaApp/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 15.1;
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
@@ -300,15 +628,33 @@
"-ObjC",
"-lc++",
);
PRODUCT_BUNDLE_IDENTIFIER = "org.reactjs.native.example.$(PRODUCT_NAME:rfc1034identifier)";
PRODUCT_BUNDLE_IDENTIFIER = com.sachagaulin.haptika.r537r56465.dev;
PRODUCT_NAME = HaptikaApp;
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
TARGETED_DEVICE_FAMILY = 1;
VERSIONING_SYSTEM = "apple-generic";
};
name = Release;
};
736D5E5CBB3D4C53DB7FEBE2 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
DEVELOPMENT_TEAM = R537R56465;
GENERATE_INFOPLIST_FILE = YES;
PRODUCT_BUNDLE_IDENTIFIER = com.sachagaulin.haptika.r537r56465.dev.watchtests;
SDKROOT = watchos;
SUPPORTED_PLATFORMS = "watchos watchsimulator";
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = 4;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Haptika Watch App.app/Haptika Watch App";
VALIDATE_PRODUCT = YES;
WATCHOS_DEPLOYMENT_TARGET = 11.0;
};
name = Release;
};
83CBBA201A601CBA00E9B192 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
@@ -358,7 +704,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 15.1;
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
LD_RUNPATH_SEARCH_PATHS = (
/usr/lib/swift,
"$(inherited)",
@@ -370,6 +716,10 @@
);
MTL_ENABLE_DEBUG_INFO = YES;
ONLY_ACTIVE_ARCH = YES;
OTHER_CFLAGS = (
"$(inherited)",
"-DRCT_REMOVE_LEGACY_ARCH=1",
);
OTHER_CPLUSPLUSFLAGS = (
"$(OTHER_CFLAGS)",
"-DFOLLY_NO_CONFIG",
@@ -377,8 +727,14 @@
"-DFOLLY_USE_LIBCPP=1",
"-DFOLLY_CFG_NO_COROUTINES=1",
"-DFOLLY_HAVE_CLOCK_GETTIME=1",
"-DRCT_REMOVE_LEGACY_ARCH=1",
);
PODFILE_DIR = "$(SRCROOT)";
REACT_NATIVE_PATH = "${PODS_ROOT}/../../node_modules/react-native";
SDKROOT = iphoneos;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited) DEBUG";
SWIFT_ENABLE_EXPLICIT_MODULES = NO;
USE_HERMES = true;
};
name = Debug;
};
@@ -424,7 +780,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 15.1;
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
LD_RUNPATH_SEARCH_PATHS = (
/usr/lib/swift,
"$(inherited)",
@@ -435,6 +791,10 @@
"\"$(inherited)\"",
);
MTL_ENABLE_DEBUG_INFO = NO;
OTHER_CFLAGS = (
"$(inherited)",
"-DRCT_REMOVE_LEGACY_ARCH=1",
);
OTHER_CPLUSPLUSFLAGS = (
"$(OTHER_CFLAGS)",
"-DFOLLY_NO_CONFIG",
@@ -442,12 +802,89 @@
"-DFOLLY_USE_LIBCPP=1",
"-DFOLLY_CFG_NO_COROUTINES=1",
"-DFOLLY_HAVE_CLOCK_GETTIME=1",
"-DRCT_REMOVE_LEGACY_ARCH=1",
);
PODFILE_DIR = "$(SRCROOT)";
REACT_NATIVE_PATH = "${PODS_ROOT}/../../node_modules/react-native";
SDKROOT = iphoneos;
SWIFT_ENABLE_EXPLICIT_MODULES = NO;
USE_HERMES = true;
VALIDATE_PRODUCT = YES;
};
name = Release;
};
A6B4F5957EED7AFABF739D57 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_ENTITLEMENTS = HaptikaWatch/HaptikaWatch.entitlements;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = R537R56465;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = HaptikaWatch/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.sachagaulin.haptika.r537r56465.dev.watchkitapp;
PRODUCT_MODULE_NAME = HaptikaWatch;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = watchos;
SKIP_INSTALL = YES;
SUPPORTED_PLATFORMS = "watchos watchsimulator";
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = 4;
VALIDATE_PRODUCT = YES;
WATCHOS_DEPLOYMENT_TARGET = 11.0;
};
name = Release;
};
AA137F90108381911B05309E /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
DEVELOPMENT_TEAM = R537R56465;
GENERATE_INFOPLIST_FILE = YES;
PRODUCT_BUNDLE_IDENTIFIER = com.sachagaulin.haptika.r537r56465.dev.watchtests;
SDKROOT = watchos;
SUPPORTED_PLATFORMS = "watchos watchsimulator";
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = 4;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Haptika Watch App.app/Haptika Watch App";
WATCHOS_DEPLOYMENT_TARGET = 11.0;
};
name = Debug;
};
D11D33BA631ABF82C65B2BFC /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_ENTITLEMENTS = HaptikaWatch/HaptikaWatch.entitlements;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = R537R56465;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = HaptikaWatch/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.sachagaulin.haptika.r537r56465.dev.watchkitapp;
PRODUCT_MODULE_NAME = HaptikaWatch;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = watchos;
SKIP_INSTALL = YES;
SUPPORTED_PLATFORMS = "watchos watchsimulator";
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = 4;
WATCHOS_DEPLOYMENT_TARGET = 11.0;
};
name = Debug;
};
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
@@ -460,6 +897,15 @@
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
774FEB5EB351F846855029C1 /* Build configuration list for PBXNativeTarget "HaptikaWatchTests" */ = {
isa = XCConfigurationList;
buildConfigurations = (
736D5E5CBB3D4C53DB7FEBE2 /* Release */,
AA137F90108381911B05309E /* Debug */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
83CBB9FA1A601CBA00E9B192 /* Build configuration list for PBXProject "HaptikaApp" */ = {
isa = XCConfigurationList;
buildConfigurations = (
@@ -469,6 +915,15 @@
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
C9284034880924E3E8455E96 /* Build configuration list for PBXNativeTarget "Haptika Watch App" */ = {
isa = XCConfigurationList;
buildConfigurations = (
A6B4F5957EED7AFABF739D57 /* Release */,
D11D33BA631ABF82C65B2BFC /* Debug */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */
};
rootObject = 83CBB9F71A601CBA00E9B192 /* Project object */;
@@ -0,0 +1,91 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "2660"
version = "1.7">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES"
buildArchitectures = "Automatic">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "AB289AE8AED259F5623A2E42"
BuildableName = "Haptika Watch App.app"
BlueprintName = "Haptika Watch App"
ReferencedContainer = "container:HaptikaApp.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES"
shouldAutocreateTestPlan = "YES">
<Testables>
<TestableReference
skipped = "NO"
parallelizable = "NO">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "EA92A2DC9365C1414126C0B1"
BuildableName = ".xctest"
BlueprintName = "HaptikaWatchTests"
ReferencedContainer = "container:HaptikaApp.xcodeproj">
</BuildableReference>
</TestableReference>
</Testables>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "AB289AE8AED259F5623A2E42"
BuildableName = "Haptika Watch App.app"
BlueprintName = "Haptika Watch App"
ReferencedContainer = "container:HaptikaApp.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "AB289AE8AED259F5623A2E42"
BuildableName = "Haptika Watch App.app"
BlueprintName = "Haptika Watch App"
ReferencedContainer = "container:HaptikaApp.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>
+10
View File
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "group:HaptikaApp.xcodeproj">
</FileRef>
<FileRef
location = "group:Pods/Pods.xcodeproj">
</FileRef>
</Workspace>
+10
View File
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.developer.default-data-protection</key>
<string>NSFileProtectionComplete</string>
<key>com.apple.developer.healthkit</key>
<true/>
</dict>
</plist>
+5 -11
View File
@@ -7,7 +7,7 @@
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleDisplayName</key>
<string>HaptikaApp</string>
<string>Haptika</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
@@ -28,14 +28,15 @@
<true/>
<key>NSAppTransportSecurity</key>
<dict>
<!-- Do not change NSAllowsArbitraryLoads to true, or you will risk app rejection! -->
<key>NSAllowsArbitraryLoads</key>
<false/>
<key>NSAllowsLocalNetworking</key>
<true/>
</dict>
<key>NSLocationWhenInUseUsageDescription</key>
<string></string>
<key>NSCameraUsageDescription</key>
<string>Haptika utilise la caméra arrière et le flash pour mesurer localement un signal pulsatile pendant environ 60 secondes. Aucune image n’est enregistrée.</string>
<key>NSHealthShareUsageDescription</key>
<string>Haptika lit, avec votre accord, votre fréquence cardiaque, votre fréquence au repos et votre SDNN comme contexte historique facultatif. Ces données ne remplacent jamais la mesure caméra.</string>
<key>UILaunchStoryboardName</key>
<string>LaunchScreen</string>
<key>UIRequiredDeviceCapabilities</key>
@@ -46,13 +47,6 @@
<array>
<string>UIInterfaceOrientationPortrait</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
</array>
<key>UIViewControllerBasedStatusBarAppearance</key>
<false/>
</dict>
+2
View File
@@ -33,5 +33,7 @@
<array/>
<key>NSPrivacyTracking</key>
<false/>
<key>NSPrivacyTrackingDomains</key>
<array/>
</dict>
</plist>
@@ -0,0 +1,14 @@
import SwiftUI
@main
struct HaptikaWatchApp: App {
@StateObject private var model = WatchAppModel()
var body: some Scene {
WindowGroup {
WatchRootView()
.environmentObject(model)
.task { model.start() }
}
}
}
+128
View File
@@ -0,0 +1,128 @@
import Combine
import Foundation
import WatchKit
@MainActor
public final class WatchAppModel: ObservableObject {
@Published public var hasCompletedOnboarding: Bool {
didSet { defaults.set(hasCompletedOnboarding, forKey: onboardingKey) }
}
@Published public var selectedTab: WatchHomeDestination?
public let store: WatchStore
public let measurement: WatchHealthMeasurementService
public let haptics: WatchHapticService
public let connectivity: WatchConnectivityService
private let defaults: UserDefaults
private let onboardingKey = "haptika.watch.onboarding.completed.v1"
private let deviceIDKey = "haptika.watch.device.id.v1"
private var sequence: UInt64
public var deviceID: String {
if let value = defaults.string(forKey: deviceIDKey) { return value }
let value = UUID().uuidString
defaults.set(value, forKey: deviceIDKey)
return value
}
public init(
defaults: UserDefaults = .standard,
store: WatchStore? = nil,
measurement: WatchHealthMeasurementService? = nil,
haptics: WatchHapticService? = nil,
connectivity: WatchConnectivityService? = nil
) {
self.defaults = defaults
self.store = store ?? WatchStore(defaults: defaults)
self.measurement = measurement ?? WatchHealthMeasurementService()
self.haptics = haptics ?? WatchHapticService()
self.connectivity = connectivity ?? WatchConnectivityService()
hasCompletedOnboarding = defaults.bool(forKey: onboardingKey)
sequence = UInt64(defaults.integer(forKey: "haptika.watch.sequence.v1"))
wireServices()
}
public func start() {
connectivity.activate()
connectivity.flush(outbox: store.outbox)
}
public func enqueueMeasurement(_ measurement: WatchMeasurementSummaryV1) {
guard let envelope = makeEnvelope(kind: .measurementUpsert, payload: measurement) else {
store.upsert(measurement)
return
}
store.upsert(measurement, enqueue: envelope)
connectivity.transfer(envelope)
}
public func enqueueSession(_ session: WatchRegulationSessionV1) {
guard let envelope = makeEnvelope(kind: .regulationSessionUpsert, payload: session) else {
store.upsert(session)
return
}
store.upsert(session, enqueue: envelope)
connectivity.transfer(envelope)
}
public func deleteMeasurement(_ measurement: WatchMeasurementSummaryV1) {
let tombstone = WatchDeletionTombstoneV1(entityID: measurement.id, entityKind: "measurement")
let envelope = makeEnvelope(kind: .tombstone, payload: tombstone)
_ = store.deleteMeasurement(id: measurement.id, envelope: envelope)
if let envelope { connectivity.transfer(envelope) }
}
public func saveFeeling(_ feeling: Int, for measurement: WatchMeasurementSummaryV1) {
var updated = measurement
updated.feeling = min(5, max(1, feeling))
updated.feelingRecordedAt = Date()
updated.updatedAt = Date()
enqueueMeasurement(updated)
}
private func wireServices() {
measurement.onCompleted = { [weak self] summary in self?.enqueueMeasurement(summary) }
measurement.onLiveHeartRate = { [weak self] sample in
guard let self,
let envelope = self.makeEnvelope(
kind: .liveHeartRate,
payload: WatchLiveHeartRateV1(bpm: sample.bpm, sampledAt: sample.date)
) else { return }
self.connectivity.sendLive(envelope)
}
connectivity.onAcknowledgement = { [weak self] ids in self?.store.acknowledge(eventIDs: ids) }
connectivity.onEnvelope = { [weak self] envelope in
guard let self else { return }
if envelope.kind == .preferences,
let preferences = try? envelope.decodePayload(WatchPreferencesV1.self) {
self.store.preferences = preferences
} else if envelope.kind == .measurementUpsert,
let measurement = try? envelope.decodePayload(WatchMeasurementSummaryV1.self) {
self.store.upsert(measurement)
} else if envelope.kind == .regulationSessionUpsert,
let session = try? envelope.decodePayload(WatchRegulationSessionV1.self) {
self.store.upsert(session)
} else if envelope.kind == .tombstone,
let tombstone = try? envelope.decodePayload(WatchDeletionTombstoneV1.self) {
self.store.apply(tombstone)
}
}
}
private func makeEnvelope<Payload: Encodable>(kind: WatchSyncKind, payload: Payload) -> WatchSyncEnvelopeV1? {
sequence += 1
defaults.set(Int(sequence), forKey: "haptika.watch.sequence.v1")
return try? .init(deviceID: deviceID, sequence: sequence, kind: kind, payload: payload)
}
}
public enum WatchHomeDestination: String, Hashable, Identifiable {
case measure
case coherence
case exercises
case cardiacMirror
case history
public var id: String { rawValue }
}
@@ -0,0 +1,20 @@
{
"colors" : [
{
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0.922",
"green" : "0.741",
"red" : "0.314"
}
},
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -0,0 +1,13 @@
{
"images" : [
{
"idiom" : "watch-marketing",
"scale" : "1x",
"size" : "1024x1024"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -0,0 +1,181 @@
import Foundation
public enum RegulationFamily: String, Codable, Sendable {
case breathe
case ground
case recenter
}
public enum RegulationMode: String, Codable, Sendable {
case haptic
case liveHeartRate
}
public enum RegulationPhaseKind: String, Codable, Sendable {
case inhale
case hold
case exhale
case anchor
}
public struct RegulationPhase: Codable, Equatable, Sendable, Identifiable {
public var id: String { "\(kind.rawValue)-\(durationSeconds)-\(label)" }
public let kind: RegulationPhaseKind
public let durationSeconds: TimeInterval
public let label: String
public init(kind: RegulationPhaseKind, durationSeconds: TimeInterval, label: String) {
self.kind = kind
self.durationSeconds = durationSeconds
self.label = label
}
}
public struct WatchRegulationProtocol: Codable, Equatable, Sendable, Identifiable {
public let id: String
public let version: Int
public let family: RegulationFamily
public let mode: RegulationMode
public let eyebrow: String
public let title: String
public let summary: String
public let durationSeconds: TimeInterval
public let phases: [RegulationPhase]
public init(
id: String,
version: Int = 1,
family: RegulationFamily,
mode: RegulationMode,
eyebrow: String,
title: String,
summary: String,
durationSeconds: TimeInterval,
phases: [RegulationPhase]
) {
self.id = id
self.version = version
self.family = family
self.mode = mode
self.eyebrow = eyebrow
self.title = title
self.summary = summary
self.durationSeconds = durationSeconds
self.phases = phases
}
}
public enum WatchProtocolCatalog {
private struct Manifest: Decodable {
let schemaVersion: Int
let protocols: [ManifestProtocol]
}
private struct ManifestProtocol: Decodable {
let kind: String
let family: RegulationFamily
let mode: String
let eyebrow: String
let title: String
let description: String
let durationSeconds: TimeInterval
let phases: [RegulationPhase]
var watchProtocol: WatchRegulationProtocol {
WatchRegulationProtocol(
id: kind,
version: 1,
family: family,
mode: mode == "haptic" ? .haptic : .liveHeartRate,
eyebrow: eyebrow,
title: title,
summary: description,
durationSeconds: durationSeconds,
phases: phases
)
}
}
public static let protocols: [WatchRegulationProtocol] = loadBundledManifest() ?? fallbackProtocols
private static let fallbackProtocols: [WatchRegulationProtocol] = [
.init(id: "myCoherence", family: .breathe, mode: .liveHeartRate, eyebrow: "BIOFEEDBACK", title: "Ma Cohérence", summary: "Respiration visuelle 5–5 avec tendance cardiaque.", durationSeconds: 180, phases: [
.init(kind: .inhale, durationSeconds: 5, label: "Inspirez"),
.init(kind: .exhale, durationSeconds: 5, label: "Expirez"),
]),
.init(id: "coherence55", family: .breathe, mode: .haptic, eyebrow: "ÉQUILIBRE", title: "Cohérence 5–5", summary: "Une respiration régulière pour retrouver un rythme calme.", durationSeconds: 300, phases: [
.init(kind: .inhale, durationSeconds: 5, label: "Inspirez"),
.init(kind: .exhale, durationSeconds: 5, label: "Expirez lentement"),
]),
.init(id: "box4444", family: .breathe, mode: .haptic, eyebrow: "STABILITÉ", title: "Respiration carrée", summary: "Quatre temps égaux pour structurer l’attention.", durationSeconds: 240, phases: [
.init(kind: .inhale, durationSeconds: 4, label: "Inspirez"),
.init(kind: .hold, durationSeconds: 4, label: "Gardez l’air"),
.init(kind: .exhale, durationSeconds: 4, label: "Expirez"),
.init(kind: .hold, durationSeconds: 4, label: "Restez vide"),
]),
.init(id: "breathing478", family: .breathe, mode: .haptic, eyebrow: "RALENTISSEMENT", title: "Respiration 4–7–8", summary: "Un cycle court avec une expiration longue.", durationSeconds: 76, phases: [
.init(kind: .inhale, durationSeconds: 4, label: "Inspirez"),
.init(kind: .hold, durationSeconds: 7, label: "Gardez l’air"),
.init(kind: .exhale, durationSeconds: 8, label: "Expirez lentement"),
]),
.init(id: "relax46", family: .breathe, mode: .haptic, eyebrow: "RELÂCHEMENT", title: "Expiration 4–6", summary: "Une expiration plus longue pour accompagner la détente.", durationSeconds: 180, phases: [
.init(kind: .inhale, durationSeconds: 4, label: "Inspirez"),
.init(kind: .exhale, durationSeconds: 6, label: "Expirez lentement"),
]),
.init(id: "sosStress", family: .recenter, mode: .haptic, eyebrow: "RETOUR AU CALME", title: "SOS tension", summary: "Trois impulsions puis un repère toutes les dix secondes.", durationSeconds: 62, phases: [
.init(kind: .anchor, durationSeconds: 62, label: "Restez avec la sensation"),
]),
.init(id: "microAnchor", family: .ground, mode: .haptic, eyebrow: "FORMAT COURT", title: "Micro-ancrage", summary: "Un repère tactile discret pour revenir aux sensations.", durationSeconds: 37, phases: [
.init(kind: .anchor, durationSeconds: 37, label: "Sentez les points de contact"),
]),
]
public static func protocolWithID(_ id: String) -> WatchRegulationProtocol? {
protocols.first { $0.id == id }
}
private static func loadBundledManifest(bundle: Bundle = .main) -> [WatchRegulationProtocol]? {
guard let url = bundle.url(forResource: "regulation-protocols.v1", withExtension: "json"),
let data = try? Data(contentsOf: url),
let manifest = try? JSONDecoder().decode(Manifest.self, from: data),
manifest.schemaVersion == 1,
manifest.protocols.count == 7 else {
return nil
}
return manifest.protocols.map(\.watchProtocol)
}
}
public struct RegulationPhasePosition: Equatable, Sendable {
public let phaseIndex: Int
public let phase: RegulationPhase
public let progress: Double
public let cycleIndex: Int
}
public enum RegulationPhaseEngine {
public static func position(
in regulation: WatchRegulationProtocol,
elapsedSeconds: TimeInterval
) -> RegulationPhasePosition? {
guard !regulation.phases.isEmpty else { return nil }
let cycleDuration = regulation.phases.reduce(0) { $0 + $1.durationSeconds }
guard cycleDuration > 0 else { return nil }
let clamped = max(0, min(elapsedSeconds, max(0, regulation.durationSeconds - 0.001)))
let cycleElapsed = clamped.truncatingRemainder(dividingBy: cycleDuration)
var cursor: TimeInterval = 0
for (index, phase) in regulation.phases.enumerated() {
let end = cursor + phase.durationSeconds
if cycleElapsed < end || index == regulation.phases.count - 1 {
return .init(
phaseIndex: index,
phase: phase,
progress: min(1, max(0, (cycleElapsed - cursor) / phase.durationSeconds)),
cycleIndex: Int(clamped / cycleDuration)
)
}
cursor = end
}
return nil
}
}
+72
View File
@@ -0,0 +1,72 @@
import Foundation
public enum WatchBaselineMaturity: String, Codable, Sendable {
case unavailable
case provisional
case personalized
}
public enum WatchBaselineComparison: String, Codable, Sendable {
case unavailable
case usual
case slightlyDifferent
case clearlyDifferent
}
public struct WatchBaselineSnapshot: Codable, Equatable, Sendable {
public let maturity: WatchBaselineMaturity
public let comparableMeasurements: Int
public let medianBPM: Double?
public let medianAbsoluteDeviation: Double?
public init(measurements: [WatchMeasurementSummaryV1]) {
let values = measurements
.filter { $0.source == .appleWatchOptical && $0.isValid && $0.quality >= 0.65 && $0.context.isBaselineComparable }
.compactMap(\.averageBPM)
.filter(\.isFinite)
comparableMeasurements = values.count
medianBPM = Self.median(values)
medianAbsoluteDeviation = medianBPM.map { median in
Self.median(values.map { abs($0 - median) }) ?? 0
}
if values.count >= 10 {
maturity = .personalized
} else if values.count >= 5 {
maturity = .provisional
} else {
maturity = .unavailable
}
}
public func compare(bpm: Double?) -> WatchBaselineComparison {
guard maturity != .unavailable, let bpm, bpm.isFinite, let medianBPM else {
return .unavailable
}
let scale = max(medianAbsoluteDeviation ?? 0, 2)
let robustDistance = abs(bpm - medianBPM) / scale
if robustDistance < 2 { return .usual }
if robustDistance < 4 { return .slightlyDifferent }
return .clearlyDifferent
}
private static func median(_ values: [Double]) -> Double? {
guard !values.isEmpty else { return nil }
let sorted = values.sorted()
let middle = sorted.count / 2
return sorted.count.isMultiple(of: 2)
? (sorted[middle - 1] + sorted[middle]) / 2
: sorted[middle]
}
}
public extension WatchBaselineComparison {
var frenchLabel: String {
switch self {
case .unavailable: "Baseline en construction"
case .usual: "Habituel"
case .slightlyDifferent: "Légèrement différent"
case .clearlyDifferent: "Clairement différent"
}
}
}
@@ -0,0 +1,73 @@
import Foundation
public enum WatchMeasurementInvalidReason: String, Codable, CaseIterable, Sendable {
case noValidHeartRate
case insufficientSamples
case insufficientCoverage
case staleData
case interrupted
}
public struct WatchMeasurementValidation: Equatable, Sendable {
public let validSamples: [WatchHeartRateSampleV1]
public let currentBPM: Double?
public let averageBPM: Double?
public let minimumBPM: Double?
public let maximumBPM: Double?
public let coverageSeconds: TimeInterval
public let maximumGapSeconds: TimeInterval
public let quality: Double
public let invalidReasons: [WatchMeasurementInvalidReason]
public var isValid: Bool { invalidReasons.isEmpty }
}
public enum WatchMeasurementValidator {
public static let minimumBPM = 35.0
public static let maximumBPM = 220.0
public static let minimumSamples = 8
public static let minimumCoverageSeconds = 45.0
public static let maximumSilenceSeconds = 15.0
public static let targetDurationSeconds = 60.0
public static func validate(
samples: [WatchHeartRateSampleV1],
interrupted: Bool = false
) -> WatchMeasurementValidation {
let filtered = samples
.filter { $0.bpm.isFinite && (minimumBPM ... maximumBPM).contains($0.bpm) }
.sorted { $0.date < $1.date }
let coverage = max(0, (filtered.last?.date.timeIntervalSince(filtered.first?.date ?? Date())) ?? 0)
let gaps = zip(filtered, filtered.dropFirst()).map { $1.date.timeIntervalSince($0.date) }
let maximumGap = max(0, gaps.max() ?? 0)
let bpms = filtered.map(\.bpm)
let average = bpms.isEmpty ? nil : bpms.reduce(0, +) / Double(bpms.count)
var reasons: [WatchMeasurementInvalidReason] = []
if filtered.isEmpty { reasons.append(.noValidHeartRate) }
if filtered.count < minimumSamples { reasons.append(.insufficientSamples) }
if coverage < minimumCoverageSeconds { reasons.append(.insufficientCoverage) }
if maximumGap > maximumSilenceSeconds { reasons.append(.staleData) }
if interrupted { reasons.append(.interrupted) }
let sampleScore = min(1, Double(filtered.count) / Double(minimumSamples))
let coverageScore = min(1, coverage / targetDurationSeconds)
let continuityScore = maximumGap <= maximumSilenceSeconds
? 1
: max(0, maximumSilenceSeconds / maximumGap)
let quality = min(1, max(0, sampleScore * coverageScore * continuityScore))
return WatchMeasurementValidation(
validSamples: filtered,
currentBPM: filtered.last?.bpm,
averageBPM: average,
minimumBPM: bpms.min(),
maximumBPM: bpms.max(),
coverageSeconds: coverage,
maximumGapSeconds: maximumGap,
quality: quality,
invalidReasons: reasons
)
}
}
+377
View File
@@ -0,0 +1,377 @@
import Foundation
public enum MeasurementSource: String, Codable, CaseIterable, Sendable {
case iphoneCameraPPG
case appleWatchOptical
}
public struct WatchHeartRateSampleV1: Codable, Equatable, Sendable, Identifiable {
public var id: UUID
public var date: Date
public var bpm: Double
public init(id: UUID = UUID(), date: Date, bpm: Double) {
self.id = id
self.date = date
self.bpm = bpm
}
}
public struct WatchDeviceIdentityV1: Codable, Equatable, Sendable {
public var id: String
public var name: String
public var model: String?
public var systemVersion: String?
public init(id: String, name: String, model: String?, systemVersion: String?) {
self.id = id
self.name = name
self.model = model
self.systemVersion = systemVersion
}
private enum CodingKeys: String, CodingKey { case id, name, model, systemVersion }
public func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(id, forKey: .id)
try container.encode(name, forKey: .name)
if let model { try container.encode(model, forKey: .model) } else { try container.encodeNil(forKey: .model) }
if let systemVersion { try container.encode(systemVersion, forKey: .systemVersion) } else { try container.encodeNil(forKey: .systemVersion) }
}
}
public enum WatchMeasurementPosture: String, Codable, CaseIterable, Sendable {
case seated
case standing
case lying
case unknown
}
public struct WatchMeasurementContextV1: Codable, Equatable, Sendable {
public var posture: WatchMeasurementPosture
public var isAtRest: Bool
public var recentCaffeine: Bool
public var recentMeal: Bool
public var recentExercise: Bool
public var note: String?
public init(
posture: WatchMeasurementPosture = .unknown,
isAtRest: Bool = true,
recentCaffeine: Bool = false,
recentMeal: Bool = false,
recentExercise: Bool = false,
note: String? = nil
) {
self.posture = posture
self.isAtRest = isAtRest
self.recentCaffeine = recentCaffeine
self.recentMeal = recentMeal
self.recentExercise = recentExercise
self.note = note
}
public var isBaselineComparable: Bool {
isAtRest && !recentCaffeine && !recentMeal && !recentExercise
}
}
public struct WatchMeasurementSummaryV1: Codable, Equatable, Sendable, Identifiable {
public static let schemaVersion = 1
public var id: UUID
public var updatedAt: Date
public var startedAt: Date
public var endedAt: Date
public var source: MeasurementSource
public var device: WatchDeviceIdentityV1
public var currentBPM: Double?
public var averageBPM: Double?
public var minimumBPM: Double?
public var maximumBPM: Double?
public var samples: [WatchHeartRateSampleV1]
public var coverageSeconds: TimeInterval
public var quality: Double
public var isValid: Bool
public var invalidReasons: [WatchMeasurementInvalidReason]
public var context: WatchMeasurementContextV1
public var feeling: Int?
public var feelingRecordedAt: Date?
public init(
id: UUID = UUID(),
updatedAt: Date = Date(),
startedAt: Date,
endedAt: Date,
source: MeasurementSource = .appleWatchOptical,
device: WatchDeviceIdentityV1,
currentBPM: Double?,
averageBPM: Double?,
minimumBPM: Double?,
maximumBPM: Double?,
samples: [WatchHeartRateSampleV1],
coverageSeconds: TimeInterval,
quality: Double,
isValid: Bool,
invalidReasons: [WatchMeasurementInvalidReason] = [],
context: WatchMeasurementContextV1 = .init(),
feeling: Int? = nil,
feelingRecordedAt: Date? = nil
) {
self.id = id
self.updatedAt = updatedAt
self.startedAt = startedAt
self.endedAt = endedAt
self.source = source
self.device = device
self.currentBPM = currentBPM
self.averageBPM = averageBPM
self.minimumBPM = minimumBPM
self.maximumBPM = maximumBPM
self.samples = samples
self.coverageSeconds = coverageSeconds
self.quality = quality
self.isValid = isValid
self.invalidReasons = invalidReasons
self.context = context
self.feeling = feeling
self.feelingRecordedAt = feelingRecordedAt
}
private enum CodingKeys: String, CodingKey {
case schemaVersion, id, updatedAt, startedAt, endedAt, source, device
case currentBpm, averageBpm, minimumBpm, maximumBpm, samples, coverage, quality
case valid, invalidReason, context, feeling
}
private struct WireSample: Codable {
let offsetSeconds: TimeInterval
let bpm: Double
}
private struct WireContext: Codable {
let atRest: Bool
let posture: String?
let recentExercise: Bool?
let recentCaffeine: Bool?
let recentMeal: Bool?
}
private struct WireFeeling: Codable {
let score: Int
let recordedAt: Date
}
public func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(Self.schemaVersion, forKey: .schemaVersion)
try container.encode(id, forKey: .id)
try container.encode(updatedAt, forKey: .updatedAt)
try container.encode(startedAt, forKey: .startedAt)
try container.encode(endedAt, forKey: .endedAt)
try container.encode(source, forKey: .source)
try container.encode(device, forKey: .device)
if let currentBPM { try container.encode(currentBPM, forKey: .currentBpm) }
else { try container.encodeNil(forKey: .currentBpm) }
try container.encodeIfPresent(averageBPM, forKey: .averageBpm)
try container.encodeIfPresent(minimumBPM, forKey: .minimumBpm)
try container.encodeIfPresent(maximumBPM, forKey: .maximumBpm)
try container.encode(samples.map { WireSample(offsetSeconds: max(0, $0.date.timeIntervalSince(startedAt)), bpm: $0.bpm) }, forKey: .samples)
try container.encode(min(1, max(0, coverageSeconds / 60)), forKey: .coverage)
try container.encode(quality, forKey: .quality)
try container.encode(isValid, forKey: .valid)
if let invalidReason = invalidReasons.first?.rawValue { try container.encode(invalidReason, forKey: .invalidReason) }
else { try container.encodeNil(forKey: .invalidReason) }
try container.encode(WireContext(
atRest: context.isAtRest,
posture: context.posture == .unknown ? nil : context.posture.rawValue,
recentExercise: context.recentExercise,
recentCaffeine: context.recentCaffeine,
recentMeal: context.recentMeal
), forKey: .context)
if let feeling {
try container.encode(WireFeeling(score: feeling, recordedAt: feelingRecordedAt ?? updatedAt), forKey: .feeling)
} else {
try container.encodeNil(forKey: .feeling)
}
}
public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
guard try container.decode(Int.self, forKey: .schemaVersion) == Self.schemaVersion else {
throw DecodingError.dataCorruptedError(forKey: .schemaVersion, in: container, debugDescription: "Unsupported Watch measurement schema")
}
id = try container.decode(UUID.self, forKey: .id)
updatedAt = try container.decode(Date.self, forKey: .updatedAt)
startedAt = try container.decode(Date.self, forKey: .startedAt)
endedAt = try container.decode(Date.self, forKey: .endedAt)
source = try container.decode(MeasurementSource.self, forKey: .source)
device = try container.decode(WatchDeviceIdentityV1.self, forKey: .device)
currentBPM = try container.decodeIfPresent(Double.self, forKey: .currentBpm)
averageBPM = try container.decodeIfPresent(Double.self, forKey: .averageBpm)
minimumBPM = try container.decodeIfPresent(Double.self, forKey: .minimumBpm)
maximumBPM = try container.decodeIfPresent(Double.self, forKey: .maximumBpm)
let wireSamples = try container.decode([WireSample].self, forKey: .samples)
let decodedStartedAt = startedAt
samples = wireSamples.map { .init(date: decodedStartedAt.addingTimeInterval($0.offsetSeconds), bpm: $0.bpm) }
coverageSeconds = try container.decode(Double.self, forKey: .coverage) * 60
quality = try container.decode(Double.self, forKey: .quality)
isValid = try container.decode(Bool.self, forKey: .valid)
invalidReasons = (try container.decodeIfPresent(String.self, forKey: .invalidReason))
.flatMap(WatchMeasurementInvalidReason.init(rawValue:)).map { [$0] } ?? []
let wireContext = try container.decode(WireContext.self, forKey: .context)
context = .init(
posture: wireContext.posture.flatMap(WatchMeasurementPosture.init(rawValue:)) ?? .unknown,
isAtRest: wireContext.atRest,
recentCaffeine: wireContext.recentCaffeine ?? false,
recentMeal: wireContext.recentMeal ?? false,
recentExercise: wireContext.recentExercise ?? false
)
let wireFeeling = try container.decodeIfPresent(WireFeeling.self, forKey: .feeling)
feeling = wireFeeling?.score
feelingRecordedAt = wireFeeling?.recordedAt
}
}
public enum WatchRegulationSessionState: String, Codable, Sendable {
case inProgress
case awaitingAfter
case completed
case cancelled
}
public struct WatchRegulationSessionV1: Codable, Equatable, Sendable, Identifiable {
public static let currentSchemaVersion = 1
public let schemaVersion = 1
public var id: UUID
public var protocolID: String
public var protocolVersion: Int = 1
public var startedAt: Date
public var endedAt: Date
public var durationSeconds: TimeInterval
public var state: WatchRegulationSessionState
public var beforeMeasurementID: UUID?
public var afterMeasurementID: UUID?
public var feelingBefore: Int?
public var feelingAfter: Int?
public var healthKitWorkoutID: UUID?
public var updatedAt: Date
public init(
id: UUID = UUID(),
protocolID: String,
protocolVersion: Int = 1,
startedAt: Date,
endedAt: Date,
durationSeconds: TimeInterval,
state: WatchRegulationSessionState,
beforeMeasurementID: UUID? = nil,
afterMeasurementID: UUID? = nil,
feelingBefore: Int? = nil,
feelingAfter: Int? = nil,
healthKitWorkoutID: UUID? = nil,
updatedAt: Date = Date()
) {
self.id = id
self.protocolID = protocolID
self.protocolVersion = protocolVersion
self.startedAt = startedAt
self.endedAt = endedAt
self.durationSeconds = durationSeconds
self.state = state
self.beforeMeasurementID = beforeMeasurementID
self.afterMeasurementID = afterMeasurementID
self.feelingBefore = feelingBefore
self.feelingAfter = feelingAfter
self.healthKitWorkoutID = healthKitWorkoutID
self.updatedAt = updatedAt
}
private enum CodingKeys: String, CodingKey {
case schemaVersion, id, protocolID = "protocolId", startedAt, endedAt, durationSeconds, state
case beforeMeasurementID = "beforeMeasurementId", afterMeasurementID = "afterMeasurementId"
case feelingBefore, feelingAfter, healthKitWorkoutID = "workoutUUID", updatedAt
}
public func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(schemaVersion, forKey: .schemaVersion)
try container.encode(id, forKey: .id)
try container.encode(protocolID, forKey: .protocolID)
try container.encode(startedAt, forKey: .startedAt)
try container.encode(endedAt, forKey: .endedAt)
try container.encode(durationSeconds, forKey: .durationSeconds)
try container.encode(state, forKey: .state)
try container.encode(updatedAt, forKey: .updatedAt)
if let beforeMeasurementID { try container.encode(beforeMeasurementID, forKey: .beforeMeasurementID) }
else { try container.encodeNil(forKey: .beforeMeasurementID) }
if let afterMeasurementID { try container.encode(afterMeasurementID, forKey: .afterMeasurementID) }
else { try container.encodeNil(forKey: .afterMeasurementID) }
if let feelingBefore { try container.encode(feelingBefore, forKey: .feelingBefore) }
else { try container.encodeNil(forKey: .feelingBefore) }
if let feelingAfter { try container.encode(feelingAfter, forKey: .feelingAfter) }
else { try container.encodeNil(forKey: .feelingAfter) }
if let healthKitWorkoutID { try container.encode(healthKitWorkoutID, forKey: .healthKitWorkoutID) }
else { try container.encodeNil(forKey: .healthKitWorkoutID) }
}
public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
guard try container.decode(Int.self, forKey: .schemaVersion) == 1 else {
throw DecodingError.dataCorruptedError(forKey: .schemaVersion, in: container, debugDescription: "Unsupported Watch session schema")
}
id = try container.decode(UUID.self, forKey: .id)
protocolID = try container.decode(String.self, forKey: .protocolID)
protocolVersion = 1
startedAt = try container.decode(Date.self, forKey: .startedAt)
endedAt = try container.decode(Date.self, forKey: .endedAt)
durationSeconds = try container.decode(TimeInterval.self, forKey: .durationSeconds)
state = try container.decode(WatchRegulationSessionState.self, forKey: .state)
beforeMeasurementID = try container.decodeIfPresent(UUID.self, forKey: .beforeMeasurementID)
afterMeasurementID = try container.decodeIfPresent(UUID.self, forKey: .afterMeasurementID)
feelingBefore = try container.decodeIfPresent(Int.self, forKey: .feelingBefore)
feelingAfter = try container.decodeIfPresent(Int.self, forKey: .feelingAfter)
healthKitWorkoutID = try container.decodeIfPresent(UUID.self, forKey: .healthKitWorkoutID)
updatedAt = try container.decode(Date.self, forKey: .updatedAt)
}
}
public struct WatchDeletionTombstoneV1: Codable, Equatable, Sendable, Identifiable {
public let schemaVersion = 1
public let id: UUID = UUID()
public let entityID: UUID
public let entityKind: String
public let deletedAt: Date
public let updatedAt: Date
public init(entityID: UUID, entityKind: String, deletedAt: Date = Date(), updatedAt: Date = Date()) {
self.entityID = entityID
self.entityKind = entityKind
self.deletedAt = deletedAt
self.updatedAt = updatedAt
}
private enum CodingKeys: String, CodingKey {
case schemaVersion, entityID = "entityId", entityKind, deletedAt, updatedAt
}
}
public struct WatchPreferencesV1: Codable, Equatable, Sendable {
public var schemaVersion = 1
public var saveMindAndBodyWorkouts: Bool
public var protocolCatalogVersion: String
public var updatedAt: Date
public init(
saveMindAndBodyWorkouts: Bool = true,
protocolCatalogVersion: String = "1.0.0",
updatedAt: Date = Date()
) {
self.saveMindAndBodyWorkouts = saveMindAndBodyWorkouts
self.protocolCatalogVersion = protocolCatalogVersion
self.updatedAt = updatedAt
}
}
@@ -0,0 +1,40 @@
import Foundation
public enum WatchExclusiveResource: String, Codable, Sendable {
case heartRateMeasurement
case hapticRegulation
}
public enum WatchResourceError: LocalizedError, Equatable {
case busy(WatchExclusiveResource)
case releaseMismatch
public var errorDescription: String? {
switch self {
case let .busy(resource): "Une activité \(resource.rawValue) est déjà en cours."
case .releaseMismatch: "La ressource libérée ne correspond pas à l’activité en cours."
}
}
}
public actor WatchResourceArbiter {
public static let shared = WatchResourceArbiter()
public private(set) var current: WatchExclusiveResource?
public init() {}
public func acquire(_ resource: WatchExclusiveResource) throws {
if let current { throw WatchResourceError.busy(current) }
current = resource
}
public func release(_ resource: WatchExclusiveResource) throws {
guard current == resource else { throw WatchResourceError.releaseMismatch }
current = nil
}
public func forceRelease() {
current = nil
}
}
+134
View File
@@ -0,0 +1,134 @@
import Combine
import Foundation
@MainActor
public final class WatchStore: ObservableObject {
@Published public private(set) var measurements: [WatchMeasurementSummaryV1] = []
@Published public private(set) var sessions: [WatchRegulationSessionV1] = []
@Published public private(set) var outbox: [WatchSyncEnvelopeV1] = []
@Published public private(set) var tombstones: [WatchDeletionTombstoneV1] = []
@Published public var preferences: WatchPreferencesV1 {
didSet { persist() }
}
public var baseline: WatchBaselineSnapshot { .init(measurements: measurements) }
private let defaults: UserDefaults
private let storageKey: String
private struct Snapshot: Codable {
var measurements: [WatchMeasurementSummaryV1]
var sessions: [WatchRegulationSessionV1]
var outbox: [WatchSyncEnvelopeV1]
var tombstones: [WatchDeletionTombstoneV1]
var preferences: WatchPreferencesV1
}
public init(defaults: UserDefaults = .standard, storageKey: String = "haptika.watch.store.v1") {
self.defaults = defaults
self.storageKey = storageKey
if let data = defaults.data(forKey: storageKey),
let snapshot = try? WatchCoding.decoder.decode(Snapshot.self, from: data) {
measurements = snapshot.measurements
sessions = snapshot.sessions
outbox = snapshot.outbox
tombstones = snapshot.tombstones
preferences = snapshot.preferences
} else {
preferences = .init()
}
}
public func upsert(_ measurement: WatchMeasurementSummaryV1, enqueue envelope: WatchSyncEnvelopeV1? = nil) {
if let tombstone = tombstones.first(where: { $0.entityID == measurement.id }),
tombstone.updatedAt >= measurement.updatedAt { return }
measurements.removeAll { $0.id == measurement.id && $0.updatedAt <= measurement.updatedAt }
if !measurements.contains(where: { $0.id == measurement.id }) {
measurements.append(measurement)
}
measurements.sort { $0.startedAt > $1.startedAt }
measurements = Array(measurements.prefix(30))
if let envelope { enqueue(envelope) }
persist()
}
public func upsert(_ session: WatchRegulationSessionV1, enqueue envelope: WatchSyncEnvelopeV1? = nil) {
if let tombstone = tombstones.first(where: { $0.entityID == session.id }),
tombstone.updatedAt >= session.updatedAt { return }
sessions.removeAll { $0.id == session.id && $0.updatedAt <= session.updatedAt }
if !sessions.contains(where: { $0.id == session.id }) {
sessions.append(session)
}
sessions.sort { $0.startedAt > $1.startedAt }
sessions = Array(sessions.prefix(20))
if let envelope { enqueue(envelope) }
persist()
}
@discardableResult
public func deleteMeasurement(id: UUID, envelope: WatchSyncEnvelopeV1? = nil) -> WatchDeletionTombstoneV1 {
measurements.removeAll { $0.id == id }
let tombstone = WatchDeletionTombstoneV1(entityID: id, entityKind: "measurement")
tombstones.removeAll { $0.entityID == id }
tombstones.append(tombstone)
if let envelope { enqueue(envelope) }
persist()
return tombstone
}
@discardableResult
public func deleteSession(id: UUID, envelope: WatchSyncEnvelopeV1? = nil) -> WatchDeletionTombstoneV1 {
sessions.removeAll { $0.id == id }
let tombstone = WatchDeletionTombstoneV1(entityID: id, entityKind: "regulationSession")
tombstones.removeAll { $0.entityID == id }
tombstones.append(tombstone)
if let envelope { enqueue(envelope) }
persist()
return tombstone
}
public func apply(_ tombstone: WatchDeletionTombstoneV1) {
if tombstone.entityKind == "measurement" {
measurements.removeAll { $0.id == tombstone.entityID }
} else if tombstone.entityKind == "regulationSession" {
sessions.removeAll { $0.id == tombstone.entityID }
}
if let existing = tombstones.first(where: { $0.entityID == tombstone.entityID }),
existing.updatedAt >= tombstone.updatedAt { return }
tombstones.removeAll { $0.entityID == tombstone.entityID }
tombstones.append(tombstone)
persist()
}
public func enqueue(_ envelope: WatchSyncEnvelopeV1) {
guard !outbox.contains(where: { $0.eventID == envelope.eventID }) else { return }
outbox.append(envelope)
persist()
}
public func acknowledge(eventIDs: Set<UUID>) {
outbox.removeAll { eventIDs.contains($0.eventID) }
persist()
}
public func replaceFromCompanion(
measurements incomingMeasurements: [WatchMeasurementSummaryV1],
sessions incomingSessions: [WatchRegulationSessionV1]
) {
for measurement in incomingMeasurements { upsert(measurement) }
for session in incomingSessions { upsert(session) }
}
private func persist() {
let snapshot = Snapshot(
measurements: measurements,
sessions: sessions,
outbox: outbox,
tombstones: tombstones,
preferences: preferences
)
if let data = try? WatchCoding.encoder.encode(snapshot) {
defaults.set(data, forKey: storageKey)
}
}
}
@@ -0,0 +1,169 @@
import Foundation
public enum WatchSyncKind: String, Codable, Sendable {
case liveHeartRate
case measurementUpsert
case regulationSessionUpsert
case feelingUpsert
case tombstone
case preferences
case acknowledgement = "ack"
}
/// The payload is encoded JSON data. Keeping the transport wrapper non-generic lets the
/// offline outbox contain measurements, sessions and tombstones in a single queue.
public struct WatchSyncEnvelopeV1: Codable, Equatable, Sendable, Identifiable {
public static let schemaVersion = 1
public var id: UUID { eventID }
public let eventID: UUID
public let deviceID: String
public let sequence: UInt64
public let sentAt: Date
public let kind: WatchSyncKind
public let payload: Data
public init(
eventID: UUID = UUID(),
deviceID: String,
sequence: UInt64,
sentAt: Date = Date(),
kind: WatchSyncKind,
payload: Data
) {
self.eventID = eventID
self.deviceID = deviceID
self.sequence = sequence
self.sentAt = sentAt
self.kind = kind
self.payload = payload
}
public init<Payload: Encodable>(
eventID: UUID = UUID(),
deviceID: String,
sequence: UInt64,
sentAt: Date = Date(),
kind: WatchSyncKind,
payload: Payload,
encoder: JSONEncoder = WatchCoding.encoder
) throws {
self.init(
eventID: eventID,
deviceID: deviceID,
sequence: sequence,
sentAt: sentAt,
kind: kind,
payload: try encoder.encode(payload)
)
}
public func decodePayload<Payload: Decodable>(
_ type: Payload.Type,
decoder: JSONDecoder = WatchCoding.decoder
) throws -> Payload {
try decoder.decode(type, from: payload)
}
private enum CodingKeys: String, CodingKey {
case schemaVersion, eventID = "eventId", deviceID = "deviceId", sequence, sentAt, kind, payload
}
public func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(Self.schemaVersion, forKey: .schemaVersion)
try container.encode(eventID, forKey: .eventID)
try container.encode(deviceID, forKey: .deviceID)
try container.encode(sequence, forKey: .sequence)
try container.encode(sentAt, forKey: .sentAt)
try container.encode(kind, forKey: .kind)
try container.encode(try WatchJSONValue(data: payload), forKey: .payload)
}
public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
guard try container.decode(Int.self, forKey: .schemaVersion) == Self.schemaVersion else {
throw DecodingError.dataCorruptedError(forKey: .schemaVersion, in: container, debugDescription: "Unsupported Watch envelope schema")
}
eventID = try container.decode(UUID.self, forKey: .eventID)
deviceID = try container.decode(String.self, forKey: .deviceID)
sequence = try container.decode(UInt64.self, forKey: .sequence)
sentAt = try container.decode(Date.self, forKey: .sentAt)
kind = try container.decode(WatchSyncKind.self, forKey: .kind)
payload = try container.decode(WatchJSONValue.self, forKey: .payload).data()
}
}
private enum WatchJSONValue: Codable, Equatable {
case object([String: WatchJSONValue])
case array([WatchJSONValue])
case string(String)
case number(Double)
case bool(Bool)
case null
init(data: Data) throws {
self = try WatchCoding.decoder.decode(Self.self, from: data)
}
func data() throws -> Data { try WatchCoding.encoder.encode(self) }
init(from decoder: Decoder) throws {
let container = try decoder.singleValueContainer()
if container.decodeNil() { self = .null }
else if let value = try? container.decode(Bool.self) { self = .bool(value) }
else if let value = try? container.decode(Double.self) { self = .number(value) }
else if let value = try? container.decode(String.self) { self = .string(value) }
else if let value = try? container.decode([WatchJSONValue].self) { self = .array(value) }
else { self = .object(try container.decode([String: WatchJSONValue].self)) }
}
func encode(to encoder: Encoder) throws {
var container = encoder.singleValueContainer()
switch self {
case let .object(value): try container.encode(value)
case let .array(value): try container.encode(value)
case let .string(value): try container.encode(value)
case let .number(value): try container.encode(value)
case let .bool(value): try container.encode(value)
case .null: try container.encodeNil()
}
}
}
public struct WatchAcknowledgementV1: Codable, Equatable, Sendable {
public let eventIDs: [UUID]
public let receivedAt: Date
public init(eventIDs: [UUID], receivedAt: Date = Date()) {
self.eventIDs = eventIDs
self.receivedAt = receivedAt
}
}
public struct WatchLiveHeartRateV1: Codable, Equatable, Sendable {
public let bpm: Double
public let sampledAt: Date
public let measurementID: UUID?
public init(bpm: Double, sampledAt: Date, measurementID: UUID? = nil) {
self.bpm = bpm
self.sampledAt = sampledAt
self.measurementID = measurementID
}
}
public enum WatchCoding {
public static var encoder: JSONEncoder {
let encoder = JSONEncoder()
encoder.dateEncodingStrategy = .iso8601
encoder.outputFormatting = [.sortedKeys]
return encoder
}
public static var decoder: JSONDecoder {
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
return decoder
}
}
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.developer.healthkit</key>
<true/>
</dict>
</plist>
+36
View File
@@ -0,0 +1,36 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>Haptika</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>$(MARKETING_VERSION)</string>
<key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>NSHealthShareUsageDescription</key>
<string>Haptika lit votre fréquence cardiaque pendant une mesure ou une séance guidée afin d’afficher votre évolution en direct.</string>
<key>NSHealthUpdateUsageDescription</key>
<string>Haptika peut enregistrer dans Santé les séances guidées Corps et esprit que vous terminez, si vous activez cette option.</string>
<key>WKApplication</key>
<true/>
<key>WKBackgroundModes</key>
<array>
<string>workout-processing</string>
</array>
<key>WKCompanionAppBundleIdentifier</key>
<string>com.sachagaulin.haptika.r537r56465.dev</string>
</dict>
</plist>
@@ -0,0 +1,129 @@
import Combine
import Foundation
import WatchConnectivity
@MainActor
public final class WatchConnectivityService: NSObject, ObservableObject {
@Published public private(set) var activationState: WCSessionActivationState = .notActivated
@Published public private(set) var isReachable = false
@Published public private(set) var lastSynchronizedAt: Date?
@Published public private(set) var lastError: String?
public var onEnvelope: ((WatchSyncEnvelopeV1) -> Void)?
public var onAcknowledgement: ((Set<UUID>) -> Void)?
private let session: WCSession?
private let encoder = WatchCoding.encoder
private let decoder = WatchCoding.decoder
public override init() {
session = WCSession.isSupported() ? .default : nil
super.init()
}
public func activate() {
guard let session else { return }
session.delegate = self
session.activate()
activationState = session.activationState
isReachable = session.isReachable
}
public func sendLive(_ envelope: WatchSyncEnvelopeV1) {
guard let session, session.isReachable else { return }
do {
let data = try encoder.encode(envelope)
session.sendMessageData(data, replyHandler: nil) { [weak self] error in
Task { @MainActor in self?.lastError = error.localizedDescription }
}
} catch {
lastError = error.localizedDescription
}
}
/// Guaranteed delivery for durable mutations. WatchConnectivity persists these while offline.
public func transfer(_ envelope: WatchSyncEnvelopeV1) {
guard let session else { return }
do {
session.transferUserInfo(["envelope": try encoder.encode(envelope)])
} catch {
lastError = error.localizedDescription
}
}
public func flush(outbox: [WatchSyncEnvelopeV1]) {
for envelope in outbox { transfer(envelope) }
}
public func updateApplicationContext(preferences: WatchPreferencesV1) {
guard let session else { return }
do {
try session.updateApplicationContext(["preferences": try encoder.encode(preferences)])
} catch {
lastError = error.localizedDescription
}
}
private func receive(_ data: Data) {
do {
let envelope = try decoder.decode(WatchSyncEnvelopeV1.self, from: data)
lastSynchronizedAt = Date()
if envelope.kind == .acknowledgement,
let acknowledgement = try? envelope.decodePayload(WatchAcknowledgementV1.self) {
onAcknowledgement?(Set(acknowledgement.eventIDs))
} else {
onEnvelope?(envelope)
}
} catch {
lastError = error.localizedDescription
}
}
}
extension WatchConnectivityService: WCSessionDelegate {
nonisolated public func session(
_ session: WCSession,
activationDidCompleteWith activationState: WCSessionActivationState,
error: Error?
) {
Task { @MainActor [weak self] in
self?.activationState = activationState
self?.isReachable = session.isReachable
self?.lastError = error?.localizedDescription
}
}
nonisolated public func sessionReachabilityDidChange(_ session: WCSession) {
Task { @MainActor [weak self] in self?.isReachable = session.isReachable }
}
nonisolated public func session(_ session: WCSession, didReceiveMessageData messageData: Data) {
Task { @MainActor [weak self] in self?.receive(messageData) }
}
nonisolated public func session(_ session: WCSession, didReceiveUserInfo userInfo: [String: Any] = [:]) {
guard let data = userInfo["envelope"] as? Data else { return }
Task { @MainActor [weak self] in self?.receive(data) }
}
nonisolated public func session(
_ session: WCSession,
didReceiveApplicationContext applicationContext: [String: Any]
) {
guard let data = applicationContext["preferences"] as? Data else { return }
Task { @MainActor [weak self] in
guard let self,
let preferences = try? self.decoder.decode(WatchPreferencesV1.self, from: data) else { return }
let payload = try? self.encoder.encode(preferences)
if let payload {
self.onEnvelope?(.init(
deviceID: "iphone",
sequence: 0,
kind: .preferences,
payload: payload
))
}
self.lastSynchronizedAt = Date()
}
}
}
@@ -0,0 +1,127 @@
import Foundation
import WatchKit
@MainActor
public final class WatchHapticService: ObservableObject {
@Published public private(set) var isRunning = false
@Published public private(set) var elapsedSeconds: TimeInterval = 0
private let arbiter: WatchResourceArbiter
private var task: Task<Void, Never>?
private var activeRunID: UUID?
public init(arbiter: WatchResourceArbiter = .shared) {
self.arbiter = arbiter
}
public func start(protocol regulation: WatchRegulationProtocol) async throws {
task?.cancel()
task = nil
activeRunID = nil
await arbiter.forceRelease()
try await arbiter.acquire(.hapticRegulation)
let runID = UUID()
activeRunID = runID
isRunning = true
elapsedSeconds = 0
WKInterfaceDevice.current().play(.start)
task = Task { @MainActor [weak self] in
guard let self else { return }
defer {
Task { @MainActor in await self.finishRun(runID) }
}
if regulation.id == "sosStress" {
for _ in 0..<3 {
guard !Task.isCancelled else { return }
WKInterfaceDevice.current().play(.click)
try? await Task.sleep(for: .milliseconds(600))
}
}
var previousPhaseIndex = -1
var previousCycleIndex = -1
let startedAt = Date()
while !Task.isCancelled {
let elapsed = Date().timeIntervalSince(startedAt)
self.elapsedSeconds = min(regulation.durationSeconds, elapsed)
if elapsed >= regulation.durationSeconds {
WKInterfaceDevice.current().play(.success)
return
}
if regulation.id == "sosStress" {
let second = Int(elapsed.rounded(.down))
if second > 0 && second.isMultiple(of: 10) {
WKInterfaceDevice.current().play(.click)
try? await Task.sleep(for: .seconds(1))
continue
}
} else if let position = RegulationPhaseEngine.position(in: regulation, elapsedSeconds: elapsed),
position.phaseIndex != previousPhaseIndex || position.cycleIndex != previousCycleIndex {
if previousPhaseIndex >= 0 { WKInterfaceDevice.current().play(.click) }
previousPhaseIndex = position.phaseIndex
previousCycleIndex = position.cycleIndex
}
try? await Task.sleep(for: .milliseconds(100))
}
}
}
public func startCardiacMirror(
initialBPM: Double,
durationSeconds: TimeInterval = 120,
decelerationBPMPerMinute: Double = 1.5
) async throws {
task?.cancel()
task = nil
activeRunID = nil
await arbiter.forceRelease()
try await arbiter.acquire(.hapticRegulation)
let runID = UUID()
activeRunID = runID
isRunning = true
elapsedSeconds = 0
WKInterfaceDevice.current().play(.start)
task = Task { @MainActor [weak self] in
guard let self else { return }
defer {
Task { @MainActor in await self.finishRun(runID) }
}
let startedAt = Date()
while !Task.isCancelled {
let elapsed = Date().timeIntervalSince(startedAt)
self.elapsedSeconds = min(durationSeconds, elapsed)
guard elapsed < durationSeconds else {
WKInterfaceDevice.current().play(.success)
return
}
let targetBPM = max(35, initialBPM - decelerationBPMPerMinute * elapsed / 60)
WKInterfaceDevice.current().play(.click)
try? await Task.sleep(for: .seconds(60 / targetBPM))
}
}
}
public func cancel(playStop: Bool = true) {
guard task != nil || isRunning else { return }
task?.cancel()
task = nil
activeRunID = nil
isRunning = false
if playStop { WKInterfaceDevice.current().play(.stop) }
Task { @MainActor [weak self] in
guard let self, self.activeRunID == nil else { return }
await self.arbiter.forceRelease()
}
}
private func finishRun(_ runID: UUID) async {
guard activeRunID == runID else { return }
activeRunID = nil
task = nil
isRunning = false
try? await arbiter.release(.hapticRegulation)
}
}
@@ -0,0 +1,267 @@
import Combine
import Foundation
import HealthKit
import WatchKit
public enum WatchCollectionMode: Equatable, Sendable {
case technicalMeasurement
case guidedMindAndBody(saveToHealth: Bool)
}
public enum WatchMeasurementState: Equatable, Sendable {
case idle
case authorizing
case waitingForFirstSample(secondsRemaining: Int)
case collecting(progress: Double)
case finishing
case completed(WatchMeasurementSummaryV1)
case failed(String)
}
@MainActor
public final class WatchHealthMeasurementService: NSObject, ObservableObject {
@Published public private(set) var state: WatchMeasurementState = .idle
@Published public private(set) var currentBPM: Double?
@Published public private(set) var samples: [WatchHeartRateSampleV1] = []
@Published public private(set) var lastWorkoutUUID: UUID?
public var onLiveHeartRate: ((WatchHeartRateSampleV1) -> Void)?
public var onCompleted: ((WatchMeasurementSummaryV1) -> Void)?
private let healthStore: HKHealthStore
private let arbiter: WatchResourceArbiter
private var workoutSession: HKWorkoutSession?
private var builder: HKLiveWorkoutBuilder?
private var collectionMode: WatchCollectionMode = .technicalMeasurement
private var startedAt: Date?
private var validCollectionStartedAt: Date?
private var timeoutTask: Task<Void, Never>?
private var finishTask: Task<Void, Never>?
private var collectionDurationSeconds: TimeInterval = 60
public init(
healthStore: HKHealthStore = HKHealthStore(),
arbiter: WatchResourceArbiter = .shared
) {
self.healthStore = healthStore
self.arbiter = arbiter
super.init()
}
public func requestAuthorization() async throws {
guard HKHealthStore.isHealthDataAvailable(),
let heartRate = HKObjectType.quantityType(forIdentifier: .heartRate) else {
throw NSError(domain: "HaptikaWatch.Health", code: 1, userInfo: [NSLocalizedDescriptionKey: "Santé n’est pas disponible sur cette Watch."])
}
state = .authorizing
try await healthStore.requestAuthorization(
toShare: [HKObjectType.workoutType()],
read: [heartRate]
)
state = .idle
}
public func start(
mode: WatchCollectionMode = .technicalMeasurement,
collectionDurationSeconds: TimeInterval = 60
) async throws {
guard case .idle = state else { return }
try await arbiter.acquire(.heartRateMeasurement)
do {
let configuration = HKWorkoutConfiguration()
configuration.activityType = .mindAndBody
configuration.locationType = .unknown
let session = try HKWorkoutSession(healthStore: healthStore, configuration: configuration)
let builder = session.associatedWorkoutBuilder()
builder.dataSource = HKLiveWorkoutDataSource(healthStore: healthStore, workoutConfiguration: configuration)
session.delegate = self
builder.delegate = self
workoutSession = session
self.builder = builder
collectionMode = mode
self.collectionDurationSeconds = max(60, collectionDurationSeconds)
samples = []
currentBPM = nil
lastWorkoutUUID = nil
let now = Date()
startedAt = now
validCollectionStartedAt = nil
state = .waitingForFirstSample(secondsRemaining: 30)
session.startActivity(with: now)
try await builder.beginCollection(at: now)
scheduleFirstSampleTimeout()
} catch {
await arbiter.forceRelease()
state = .failed(error.localizedDescription)
throw error
}
}
public func cancel() {
timeoutTask?.cancel()
finishTask?.cancel()
workoutSession?.end()
let builder = builder
Task { @MainActor [weak self] in
if let builder {
try? await builder.endCollection(at: Date())
builder.discardWorkout()
}
await self?.arbiter.forceRelease()
self?.resetResources()
self?.state = .idle
}
}
public func finish(interrupted: Bool = false) {
guard builder != nil else { return }
state = .finishing
timeoutTask?.cancel()
finishTask?.cancel()
workoutSession?.end()
let endDate = Date()
let startDate = startedAt ?? endDate
let validation = WatchMeasurementValidator.validate(samples: samples, interrupted: interrupted)
let summary = WatchMeasurementSummaryV1(
startedAt: startDate,
endedAt: endDate,
device: WatchDeviceIdentityV1(
id: WKInterfaceDevice.current().identifierForVendor?.uuidString ?? "unknown-watch",
name: WKInterfaceDevice.current().name,
model: WKInterfaceDevice.current().model,
systemVersion: WKInterfaceDevice.current().systemVersion
),
currentBPM: validation.currentBPM,
averageBPM: validation.averageBPM,
minimumBPM: validation.minimumBPM,
maximumBPM: validation.maximumBPM,
samples: validation.validSamples,
coverageSeconds: validation.coverageSeconds,
quality: validation.quality,
isValid: validation.isValid,
invalidReasons: validation.invalidReasons
)
let mode = collectionMode
let builder = self.builder
finishTask = Task { @MainActor [weak self] in
guard let self, let builder else { return }
do {
try await builder.endCollection(at: endDate)
switch mode {
case .technicalMeasurement:
builder.discardWorkout()
case let .guidedMindAndBody(saveToHealth):
if saveToHealth {
self.lastWorkoutUUID = try await builder.finishWorkout()?.uuid
} else {
builder.discardWorkout()
}
}
await self.arbiter.forceRelease()
self.resetResources(keepSamples: true)
self.state = .completed(summary)
self.onCompleted?(summary)
} catch {
builder.discardWorkout()
await self.arbiter.forceRelease()
self.resetResources(keepSamples: true)
self.state = .failed(error.localizedDescription)
}
}
}
public func resetAfterResult() {
guard workoutSession == nil else { return }
state = .idle
samples = []
currentBPM = nil
lastWorkoutUUID = nil
}
private func scheduleFirstSampleTimeout() {
timeoutTask?.cancel()
timeoutTask = Task { @MainActor [weak self] in
for remaining in stride(from: 30, through: 1, by: -1) {
guard let self, self.validCollectionStartedAt == nil, !Task.isCancelled else { return }
self.state = .waitingForFirstSample(secondsRemaining: remaining)
try? await Task.sleep(for: .seconds(1))
}
guard let self, self.validCollectionStartedAt == nil, !Task.isCancelled else { return }
self.finish()
}
}
private func scheduleCollectionFinish(from firstSampleDate: Date) {
timeoutTask?.cancel()
finishTask?.cancel()
finishTask = Task { @MainActor [weak self] in
while let self, !Task.isCancelled {
let elapsed = Date().timeIntervalSince(firstSampleDate)
self.state = .collecting(progress: min(1, elapsed / self.collectionDurationSeconds))
if elapsed >= self.collectionDurationSeconds {
self.finish()
return
}
try? await Task.sleep(for: .milliseconds(250))
}
}
}
private func acceptHeartRate(_ bpm: Double, at date: Date) {
guard (WatchMeasurementValidator.minimumBPM ... WatchMeasurementValidator.maximumBPM).contains(bpm) else { return }
let sample = WatchHeartRateSampleV1(date: date, bpm: bpm)
samples.append(sample)
currentBPM = bpm
onLiveHeartRate?(sample)
if validCollectionStartedAt == nil {
validCollectionStartedAt = date
scheduleCollectionFinish(from: date)
}
}
private func resetResources(keepSamples: Bool = false) {
workoutSession = nil
builder = nil
startedAt = nil
validCollectionStartedAt = nil
timeoutTask = nil
finishTask = nil
if !keepSamples { samples = [] }
}
}
extension WatchHealthMeasurementService: HKWorkoutSessionDelegate {
nonisolated public func workoutSession(
_ workoutSession: HKWorkoutSession,
didChangeTo toState: HKWorkoutSessionState,
from fromState: HKWorkoutSessionState,
date: Date
) {}
nonisolated public func workoutSession(_ workoutSession: HKWorkoutSession, didFailWithError error: Error) {
Task { @MainActor [weak self] in
self?.finish(interrupted: true)
}
}
}
extension WatchHealthMeasurementService: HKLiveWorkoutBuilderDelegate {
nonisolated public func workoutBuilderDidCollectEvent(_ workoutBuilder: HKLiveWorkoutBuilder) {}
nonisolated public func workoutBuilder(
_ workoutBuilder: HKLiveWorkoutBuilder,
didCollectDataOf collectedTypes: Set<HKSampleType>
) {
guard let heartRateType = HKObjectType.quantityType(forIdentifier: .heartRate),
collectedTypes.contains(heartRateType),
let statistics = workoutBuilder.statistics(for: heartRateType),
let quantity = statistics.mostRecentQuantity() else { return }
let unit = HKUnit.count().unitDivided(by: .minute())
let bpm = quantity.doubleValue(for: unit)
Task { @MainActor [weak self] in
self?.acceptHeartRate(bpm, at: Date())
}
}
}
@@ -0,0 +1,178 @@
import SwiftUI
struct WatchBeforeAfterFlowView: View {
enum Stage { case intro, before, beforeFeeling, exercise, after, afterFeeling, result }
@EnvironmentObject private var model: WatchAppModel
let regulation: WatchRegulationProtocol
@State private var stage: Stage = .intro
@State private var before: WatchMeasurementSummaryV1?
@State private var after: WatchMeasurementSummaryV1?
@State private var exerciseStartedAt: Date?
@State private var flowError: String?
@State private var selectedFeeling = 3
@State private var feelingBefore: Int?
@State private var feelingAfter: Int?
var body: some View {
ScrollView {
VStack(spacing: 10) {
switch stage {
case .intro:
Image(systemName: "arrow.left.arrow.right.heart.fill").font(.title).foregroundStyle(WatchTheme.accent)
Text("Avant · Exercice · Après").font(.headline).multilineTextAlignment(.center)
Text("Deux mesures identiques encadrent l’exercice pour comparer le BPM et la qualité, sans score de stress.")
.font(.caption2).foregroundStyle(WatchTheme.secondary).multilineTextAlignment(.center)
Button("Mesurer avant") { startMeasurement(stage: .before) }.buttonStyle(.borderedProminent)
case .before, .after:
measurementContent
case .beforeFeeling:
feelingContent(title: "Comment vous sentez-vous avant ?") {
feelingBefore = selectedFeeling
if var measurement = before {
measurement.feeling = selectedFeeling
measurement.feelingRecordedAt = Date()
measurement.updatedAt = Date()
before = measurement
model.enqueueMeasurement(measurement)
}
stage = .exercise
exerciseStartedAt = Date()
Task {
do { try await model.haptics.start(protocol: regulation) }
catch { flowError = error.localizedDescription }
}
}
case .exercise:
if model.haptics.isRunning, let exerciseStartedAt {
BreathOrb(regulation: regulation, startedAt: exerciseStartedAt)
ProgressView(value: model.haptics.elapsedSeconds, total: regulation.durationSeconds)
Button("Arrêter", role: .cancel) { model.haptics.cancel() }
} else {
Text("Exercice terminé").font(.headline)
Button("Mesurer après") { startMeasurement(stage: .after) }.buttonStyle(.borderedProminent)
}
case .afterFeeling:
feelingContent(title: "Comment vous sentez-vous après ?") {
feelingAfter = selectedFeeling
if var measurement = after {
measurement.feeling = selectedFeeling
measurement.feelingRecordedAt = Date()
measurement.updatedAt = Date()
after = measurement
model.enqueueMeasurement(measurement)
}
saveCompletedSession()
stage = .result
}
case .result:
resultContent
}
if let flowError { Text(flowError).font(.caption2).foregroundStyle(.orange) }
}
.padding(.horizontal, 4)
}
.navigationTitle("Comparaison")
.haptikaScreen()
.onChange(of: model.measurement.state) { _, newState in handleMeasurementState(newState) }
}
@ViewBuilder
private var measurementContent: some View {
switch model.measurement.state {
case let .waitingForFirstSample(seconds):
ProgressView()
Text("Recherche du signal · \(seconds) s").font(.caption2)
case let .collecting(progress):
Text(model.measurement.currentBPM.map { "\(Int($0.rounded())) BPM" } ?? "— BPM").font(.title2.bold())
ProgressView(value: progress)
Text(stage == .before ? "Mesure avant" : "Mesure après").font(.caption2)
case .finishing:
ProgressView("Analyse…")
default:
ProgressView()
}
Button("Annuler", role: .cancel) { model.measurement.cancel(); stage = .intro }
}
private var resultContent: some View {
VStack(spacing: 9) {
Text("Votre comparaison").font(.headline)
WatchCard {
VStack(alignment: .leading, spacing: 5) {
metricRow("Avant", before?.averageBPM)
metricRow("Après", after?.averageBPM)
if let beforeBPM = before?.averageBPM, let afterBPM = after?.averageBPM {
Divider()
Text(String(format: "Écart %+.0f BPM", afterBPM - beforeBPM)).foregroundStyle(WatchTheme.accent)
}
Text("Qualité : \(Int(((before?.quality ?? 0) * 100).rounded())) % → \(Int(((after?.quality ?? 0) * 100).rounded())) %")
if let feelingBefore, let feelingAfter {
Text("Ressenti : \(feelingBefore)/5 → \(feelingAfter)/5")
}
}
.font(.caption)
}
Text("Une variation ponctuelle n’est pas un diagnostic.").font(.caption2).foregroundStyle(WatchTheme.secondary)
}
}
private func metricRow(_ label: String, _ bpm: Double?) -> some View {
HStack {
Text(label)
Spacer()
Text(bpm.map { "\(Int($0.rounded())) BPM" } ?? "—")
}
}
private func startMeasurement(stage: Stage) {
self.stage = stage
model.measurement.resetAfterResult()
Task {
do {
try await model.measurement.requestAuthorization()
try await model.measurement.start()
} catch { flowError = error.localizedDescription }
}
}
private func feelingContent(title: String, onConfirm: @escaping () -> Void) -> some View {
VStack(spacing: 10) {
Text(title).font(.headline).multilineTextAlignment(.center)
Picker("Ressenti", selection: $selectedFeeling) {
ForEach(1...5, id: \.self) { score in Text("\(score) / 5").tag(score) }
}
Button("Continuer", action: onConfirm).buttonStyle(.borderedProminent)
}
}
private func saveCompletedSession() {
let endedAt = Date()
model.enqueueSession(.init(
protocolID: regulation.id,
startedAt: exerciseStartedAt ?? endedAt,
endedAt: endedAt,
durationSeconds: regulation.durationSeconds,
state: .completed,
beforeMeasurementID: before?.id,
afterMeasurementID: after?.id,
feelingBefore: feelingBefore,
feelingAfter: feelingAfter
))
}
private func handleMeasurementState(_ state: WatchMeasurementState) {
guard case let .completed(summary) = state else { return }
if stage == .before {
before = summary
model.measurement.resetAfterResult()
selectedFeeling = 3
stage = .beforeFeeling
} else if stage == .after {
after = summary
model.measurement.resetAfterResult()
selectedFeeling = 3
stage = .afterFeeling
}
}
}
@@ -0,0 +1,112 @@
import SwiftUI
struct WatchCardiacMirrorView: View {
enum Stage { case intro, measuring, ready, mirroring, completed }
@EnvironmentObject private var model: WatchAppModel
@State private var stage: Stage = .intro
@State private var initialBPM: Double?
@State private var errorMessage: String?
@State private var mirrorStartedAt: Date?
var body: some View {
ScrollView {
VStack(spacing: 10) {
switch stage {
case .intro:
Image(systemName: "heart.circle.fill").font(.system(size: 40)).foregroundStyle(.pink)
Text("Miroir cardiaque").font(.headline)
Text(model.connectivity.isReachable ? "Mode connecté : la Watch mesure, l’iPhone peut reconstruire le rythme visuel et haptique." : "Mode Estimation Watch : mesure initiale, arrêt du capteur, puis rythme reconstruit et ralenti.")
.font(.caption2).foregroundStyle(WatchTheme.secondary).multilineTextAlignment(.center)
Button("Mesurer le rythme") { startMeasurement() }.buttonStyle(.borderedProminent)
case .measuring:
Text(model.measurement.currentBPM.map { "\(Int($0.rounded())) BPM" } ?? "— BPM").font(.title2.bold())
measurementProgress
Text("Aucune vibration pendant la mesure").font(.caption2).foregroundStyle(.orange)
Button("Annuler", role: .cancel) { model.measurement.cancel(); stage = .intro }
case .ready:
Text(initialBPM.map { "Repère initial \(Int($0.rounded())) BPM" } ?? "Repère indisponible").font(.headline)
Text("Le rythme suivant est une estimation reconstruite, pas chaque battement biologique.")
.font(.caption2).foregroundStyle(WatchTheme.secondary).multilineTextAlignment(.center)
if model.connectivity.isReachable {
Label("iPhone connecté", systemImage: "iphone.and.arrow.forward").font(.caption).foregroundStyle(WatchTheme.mint)
Button("Terminer") { stage = .completed }
} else {
Button("Lancer le miroir") { startMirror() }.buttonStyle(.borderedProminent)
}
case .mirroring:
estimatedPulse
ProgressView(value: model.haptics.elapsedSeconds, total: 120)
Text("Ralentissement progressif · −1,5 BPM/min").font(.caption2).foregroundStyle(WatchTheme.secondary)
Button("Arrêter", role: .cancel) { model.haptics.cancel(); stage = .completed }
case .completed:
Image(systemName: "checkmark.circle.fill").font(.title).foregroundStyle(WatchTheme.mint)
Text("Expérience terminée").font(.headline)
Button("Recommencer") { stage = .intro; model.measurement.resetAfterResult() }
}
if let errorMessage { Text(errorMessage).font(.caption2).foregroundStyle(.orange) }
}
.padding(.horizontal, 4)
}
.navigationTitle("Miroir")
.haptikaScreen()
.onChange(of: model.measurement.state) { _, state in
if case let .completed(summary) = state {
initialBPM = summary.averageBPM
stage = .ready
}
}
.onChange(of: model.haptics.isRunning) { wasRunning, isRunning in
if stage == .mirroring, wasRunning, !isRunning { stage = .completed }
}
.onDisappear {
if stage == .measuring { model.measurement.cancel() }
if stage == .mirroring { model.haptics.cancel() }
}
}
@ViewBuilder
private var measurementProgress: some View {
switch model.measurement.state {
case let .waitingForFirstSample(seconds):
ProgressView(); Text("Signal · \(seconds) s").font(.caption2)
case let .collecting(progress): ProgressView(value: progress)
case .finishing: ProgressView("Analyse…")
default: EmptyView()
}
}
private var estimatedPulse: some View {
TimelineView(.animation(minimumInterval: 1 / 20)) { context in
let elapsed = mirrorStartedAt.map { context.date.timeIntervalSince($0) } ?? 0
let bpm = max(35, (initialBPM ?? 60) - 1.5 * elapsed / 60)
let phase = (elapsed * bpm / 60).truncatingRemainder(dividingBy: 1)
let scale = 0.78 + 0.22 * max(0, 1 - phase * 4)
VStack {
Image(systemName: "heart.fill").font(.system(size: 62)).foregroundStyle(.pink).scaleEffect(scale)
Text("≈ \(Int(bpm.rounded())) BPM").font(.headline.monospacedDigit())
}
}
}
private func startMeasurement() {
stage = .measuring
model.measurement.resetAfterResult()
Task {
do {
try await model.measurement.requestAuthorization()
try await model.measurement.start()
} catch { errorMessage = error.localizedDescription }
}
}
private func startMirror() {
guard let initialBPM else { return }
mirrorStartedAt = Date()
stage = .mirroring
Task {
do { try await model.haptics.startCardiacMirror(initialBPM: initialBPM) }
catch { errorMessage = error.localizedDescription; stage = .ready }
}
}
}
@@ -0,0 +1,138 @@
import Charts
import SwiftUI
struct WatchCoherenceView: View {
@EnvironmentObject private var model: WatchAppModel
@State private var selectedDuration = 180
@State private var startedAt: Date?
@State private var savedCompletion = false
private let regulation = WatchProtocolCatalog.protocolWithID("myCoherence")!
var body: some View {
ScrollView {
VStack(spacing: 10) {
switch model.measurement.state {
case .idle, .authorizing:
Text("Respirez 5 s / 5 s").font(.headline)
Text("Le guide reste visuel : aucune vibration ne peut interrompre la mesure cardiaque.")
.font(.caption2).foregroundStyle(WatchTheme.secondary).multilineTextAlignment(.center)
Picker("Durée", selection: $selectedDuration) {
Text("3 min").tag(180)
Text("5 min").tag(300)
}
Button("Démarrer") { start() }.buttonStyle(.borderedProminent)
case let .waitingForFirstSample(seconds):
BreathOrb(regulation: regulation, startedAt: startedAt ?? Date())
Text("Signal cardiaque… \(seconds) s").font(.caption2)
cancelButton
case let .collecting(progress):
BreathOrb(regulation: regulation, startedAt: startedAt ?? Date())
Text(model.measurement.currentBPM.map { "\(Int($0.rounded())) BPM" } ?? "— BPM").font(.title3.bold())
heartRateTrend(samples: Array(model.measurement.samples.suffix(30)))
ProgressView(value: progress)
Text("Couverture \(Int(model.measurement.samples.last?.date.timeIntervalSince(model.measurement.samples.first?.date ?? Date()) ?? 0)) s")
.font(.caption2).foregroundStyle(WatchTheme.secondary)
cancelButton
case .finishing:
ProgressView("Sauvegarde…")
case let .completed(summary):
Image(systemName: "checkmark.circle.fill").font(.title).foregroundStyle(WatchTheme.mint)
Text("Séance terminée").font(.headline)
Text(summary.averageBPM.map { "Pouls moyen \(Int($0.rounded())) BPM" } ?? "Pouls non disponible")
.font(.caption)
heartRateTrend(samples: summary.samples)
Text("Couverture \(Int(summary.coverageSeconds)) s · tendance \(trendLabel(summary.samples))")
.font(.caption2).foregroundStyle(WatchTheme.secondary)
Text("Aucun score de cohérence ou de PRV n’est inventé.").font(.caption2).foregroundStyle(WatchTheme.secondary)
Button("Terminer") { model.measurement.resetAfterResult() }.buttonStyle(.borderedProminent)
case let .failed(message):
Text("Séance interrompue").font(.headline)
Text(message).font(.caption2).foregroundStyle(WatchTheme.secondary)
Button("Retour") { model.measurement.resetAfterResult() }
}
}
.padding(.horizontal, 4)
}
.navigationTitle("Ma Cohérence")
.haptikaScreen()
.onChange(of: model.measurement.state) { _, newState in
guard case let .completed(summary) = newState, !savedCompletion, let startedAt else { return }
savedCompletion = true
model.enqueueSession(.init(
protocolID: regulation.id,
startedAt: startedAt,
endedAt: summary.endedAt,
durationSeconds: summary.endedAt.timeIntervalSince(startedAt),
state: .completed,
afterMeasurementID: summary.id,
healthKitWorkoutID: model.measurement.lastWorkoutUUID
))
}
}
private func start() {
startedAt = Date()
savedCompletion = false
Task {
do {
try await model.measurement.requestAuthorization()
try await model.measurement.start(
mode: .guidedMindAndBody(saveToHealth: model.store.preferences.saveMindAndBodyWorkouts),
collectionDurationSeconds: TimeInterval(selectedDuration)
)
} catch {}
}
}
private var cancelButton: some View {
Button("Arrêter", role: .cancel) { model.measurement.cancel() }.buttonStyle(.bordered)
}
private func heartRateTrend(samples: [WatchHeartRateSampleV1]) -> some View {
Chart(samples) { sample in
LineMark(x: .value("Temps", sample.date), y: .value("BPM", sample.bpm))
.foregroundStyle(WatchTheme.mint)
.interpolationMethod(.catmullRom)
}
.chartXAxis(.hidden)
.chartYAxis(.hidden)
.frame(height: 44)
}
private func trendLabel(_ samples: [WatchHeartRateSampleV1]) -> String {
guard samples.count >= 4 else { return "en construction" }
let midpoint = samples.count / 2
let first = samples.prefix(midpoint).map(\.bpm)
let last = samples.suffix(from: midpoint).map(\.bpm)
let delta = (last.reduce(0, +) / Double(last.count)) - (first.reduce(0, +) / Double(first.count))
if delta < -2 { return "en baisse" }
if delta > 2 { return "en hausse" }
return "stable"
}
}
struct BreathOrb: View {
let regulation: WatchRegulationProtocol
let startedAt: Date
var body: some View {
TimelineView(.animation(minimumInterval: 1 / 20)) { context in
let elapsed = context.date.timeIntervalSince(startedAt)
let position = RegulationPhaseEngine.position(in: regulation, elapsedSeconds: elapsed)
let isExpanding = position?.phase.kind == .inhale
let fraction = position?.progress ?? 0
let scale = isExpanding ? 0.6 + fraction * 0.4 : 1 - fraction * 0.4
VStack(spacing: 6) {
ZStack {
Circle().fill(WatchTheme.accent.opacity(0.12)).frame(width: 108, height: 108)
Circle()
.fill(RadialGradient(colors: [WatchTheme.mint, WatchTheme.accent.opacity(0.25)], center: .center, startRadius: 2, endRadius: 52))
.frame(width: 88, height: 88)
.scaleEffect(scale)
}
Text(position?.phase.label ?? "Respirez").font(.headline)
}
}
}
}
@@ -0,0 +1,102 @@
import SwiftUI
struct WatchExerciseListView: View {
private var hapticProtocols: [WatchRegulationProtocol] {
WatchProtocolCatalog.protocols.filter { $0.mode == .haptic }
}
var body: some View {
List(hapticProtocols) { regulation in
NavigationLink {
WatchExercisePlayerView(regulation: regulation)
} label: {
VStack(alignment: .leading, spacing: 3) {
Text(regulation.title).font(.subheadline.bold())
Text(formatDuration(regulation.durationSeconds)).font(.caption2).foregroundStyle(WatchTheme.secondary)
}
}
}
.navigationTitle("Exercices")
.haptikaScreen()
}
}
struct WatchExercisePlayerView: View {
@EnvironmentObject private var model: WatchAppModel
@Environment(\.dismiss) private var dismiss
let regulation: WatchRegulationProtocol
@State private var startedAt: Date?
@State private var errorMessage: String?
@State private var sessionSaved = false
var body: some View {
ScrollView {
VStack(spacing: 10) {
if model.haptics.isRunning, let startedAt {
BreathOrb(regulation: regulation, startedAt: startedAt)
ProgressView(value: model.haptics.elapsedSeconds, total: regulation.durationSeconds)
Text("\(Int(max(0, regulation.durationSeconds - model.haptics.elapsedSeconds))) s")
.font(.caption.monospacedDigit()).foregroundStyle(WatchTheme.secondary)
Button("Arrêter", role: .cancel) {
saveSession(state: .cancelled)
model.haptics.cancel()
}
} else if startedAt != nil {
Image(systemName: "checkmark.circle.fill").font(.title).foregroundStyle(WatchTheme.mint)
Text("Exercice terminé").font(.headline)
Button("Terminer") { dismiss() }.buttonStyle(.borderedProminent)
.onAppear { saveSession(state: .completed) }
} else {
Text(regulation.eyebrow).font(.caption2.bold()).foregroundStyle(WatchTheme.accent)
Text(regulation.title).font(.headline).multilineTextAlignment(.center)
Text(regulation.summary).font(.caption2).foregroundStyle(WatchTheme.secondary).multilineTextAlignment(.center)
Text("Aucune mesure cardiaque pendant les vibrations.").font(.caption2.bold()).foregroundStyle(.orange)
Button("Commencer") { start() }.buttonStyle(.borderedProminent)
NavigationLink("Parcours avant / après") {
WatchBeforeAfterFlowView(regulation: regulation)
}
.font(.caption)
}
if let errorMessage { Text(errorMessage).font(.caption2).foregroundStyle(.orange) }
}
.padding(.horizontal, 4)
}
.navigationTitle(regulation.title)
.haptikaScreen()
.onDisappear {
if model.haptics.isRunning {
saveSession(state: .cancelled)
model.haptics.cancel()
}
}
}
private func start() {
let now = Date()
Task {
do {
try await model.haptics.start(protocol: regulation)
startedAt = now
} catch {
errorMessage = error.localizedDescription
}
}
}
private func saveSession(state: WatchRegulationSessionState) {
guard !sessionSaved, let startedAt else { return }
sessionSaved = true
model.enqueueSession(.init(
protocolID: regulation.id,
startedAt: startedAt,
endedAt: Date(),
durationSeconds: model.haptics.elapsedSeconds,
state: state
))
}
}
private func formatDuration(_ duration: TimeInterval) -> String {
let seconds = Int(duration)
return seconds < 60 ? "\(seconds) s" : "\(seconds / 60) min \(seconds % 60 == 0 ? "" : "\(seconds % 60) s")"
}
@@ -0,0 +1,111 @@
import Charts
import SwiftUI
struct WatchHistoryView: View {
@EnvironmentObject private var model: WatchAppModel
private var recentSevenDays: [WatchMeasurementSummaryV1] {
let cutoff = Date().addingTimeInterval(-7 * 86_400)
return model.store.measurements.filter { $0.startedAt >= cutoff && $0.isValid }
}
var body: some View {
List {
if !recentSevenDays.isEmpty {
Section("Tendance 7 jours") {
Chart(recentSevenDays) { measurement in
LineMark(
x: .value("Date", measurement.startedAt),
y: .value("BPM", measurement.averageBPM ?? 0)
)
.foregroundStyle(WatchTheme.accent)
PointMark(
x: .value("Date", measurement.startedAt),
y: .value("BPM", measurement.averageBPM ?? 0)
)
.foregroundStyle(WatchTheme.mint)
}
.chartYAxis(.hidden)
.frame(height: 90)
}
}
Section("Mesures") {
if model.store.measurements.isEmpty {
Text("Aucune mesure synchronisée").font(.caption).foregroundStyle(WatchTheme.secondary)
}
ForEach(model.store.measurements) { measurement in
NavigationLink {
WatchMeasurementDetailView(measurement: measurement)
} label: {
HStack {
VStack(alignment: .leading, spacing: 2) {
Text(measurement.averageBPM.map { "\(Int($0.rounded())) BPM" } ?? "Incomplète").font(.subheadline.bold())
Text(measurement.startedAt, style: .date).font(.caption2).foregroundStyle(WatchTheme.secondary)
}
Spacer()
Text("\(Int((measurement.quality * 100).rounded())) %").font(.caption2).foregroundStyle(measurement.isValid ? WatchTheme.mint : .orange)
}
}
}
}
Section("Séances") {
if model.store.sessions.isEmpty {
Text("Aucune séance").font(.caption).foregroundStyle(WatchTheme.secondary)
}
ForEach(model.store.sessions) { session in
VStack(alignment: .leading, spacing: 2) {
Text(WatchProtocolCatalog.protocolWithID(session.protocolID)?.title ?? session.protocolID).font(.subheadline.bold())
Text(session.startedAt, style: .relative).font(.caption2).foregroundStyle(WatchTheme.secondary)
}
}
}
}
.navigationTitle("Historique")
.haptikaScreen()
}
}
struct WatchMeasurementDetailView: View {
@EnvironmentObject private var model: WatchAppModel
@Environment(\.dismiss) private var dismiss
let measurement: WatchMeasurementSummaryV1
@State private var confirmDelete = false
var body: some View {
List {
Section("Mesure Watch") {
metric("Moyenne", measurement.averageBPM.map { "\(Int($0.rounded())) BPM" } ?? "—")
metric("Min / max", "\(Int(measurement.minimumBPM ?? 0)) / \(Int(measurement.maximumBPM ?? 0))")
metric("Qualité", "\(Int((measurement.quality * 100).rounded())) %")
metric("Couverture", "\(Int(measurement.coverageSeconds)) s")
metric("Appareil", measurement.device.name)
if let feeling = measurement.feeling { metric("Ressenti", "\(feeling) / 5") }
}
Section {
Text("Pouls optique Apple Watch. Pas de PRV ni de diagnostic médical.")
.font(.caption2).foregroundStyle(WatchTheme.secondary)
}
Button("Supprimer", role: .destructive) { confirmDelete = true }
}
.navigationTitle(measurement.startedAt.formatted(date: .abbreviated, time: .shortened))
.haptikaScreen()
.confirmationDialog("Supprimer cette mesure ?", isPresented: $confirmDelete) {
Button("Supprimer", role: .destructive) {
model.deleteMeasurement(measurement)
dismiss()
}
Button("Annuler", role: .cancel) {}
}
}
private func metric(_ label: String, _ value: String) -> some View {
HStack {
Text(label).foregroundStyle(WatchTheme.secondary)
Spacer()
Text(value).multilineTextAlignment(.trailing)
}
.font(.caption)
}
}
@@ -0,0 +1,115 @@
import SwiftUI
struct WatchMeasurementView: View {
@EnvironmentObject private var model: WatchAppModel
var body: some View {
ScrollView {
VStack(spacing: 10) {
switch model.measurement.state {
case .idle, .authorizing:
preparation
case let .waitingForFirstSample(secondsRemaining):
liveHeader(bpm: nil, caption: "Recherche du signal · \(secondsRemaining) s")
ProgressView()
cancelButton
case let .collecting(progress):
liveHeader(bpm: model.measurement.currentBPM, caption: "Mesure en cours")
ProgressView(value: progress)
Text("Restez immobile, bras détendu").font(.caption2).foregroundStyle(WatchTheme.secondary)
cancelButton
case .finishing:
ProgressView("Analyse…")
case let .completed(summary):
WatchMeasurementResultView(summary: summary)
case let .failed(message):
Image(systemName: "exclamationmark.triangle.fill").foregroundStyle(.orange)
Text("Mesure interrompue").font(.headline)
Text(message).font(.caption2).foregroundStyle(WatchTheme.secondary)
Button("Recommencer") { model.measurement.resetAfterResult() }
}
}
.padding(.horizontal, 4)
}
.navigationTitle("Mesurer")
.haptikaScreen()
}
private var preparation: some View {
VStack(spacing: 10) {
Image(systemName: "applewatch.radiowaves.left.and.right").font(.system(size: 38)).foregroundStyle(WatchTheme.mint)
Text("60 secondes au repos").font(.headline)
Text("Serrez le bracelet sans gêne et posez l’avant-bras. Haptika attendra jusqu’à 30 s le premier BPM.")
.font(.caption2)
.foregroundStyle(WatchTheme.secondary)
.multilineTextAlignment(.center)
Button("Commencer") {
Task {
do {
try await model.measurement.requestAuthorization()
try await model.measurement.start()
} catch {}
}
}
.buttonStyle(.borderedProminent)
}
}
private func liveHeader(bpm: Double?, caption: String) -> some View {
VStack(spacing: 3) {
Image(systemName: "heart.fill").foregroundStyle(.pink).symbolEffect(.pulse, options: .repeating)
Text(bpm.map { "\(Int($0.rounded()))" } ?? "—")
.font(.system(size: 42, weight: .bold, design: .rounded))
Text(bpm == nil ? caption : "BPM · \(caption)").font(.caption2).foregroundStyle(WatchTheme.secondary)
}
}
private var cancelButton: some View {
Button("Arrêter", role: .cancel) { model.measurement.cancel() }
.buttonStyle(.bordered)
}
}
struct WatchMeasurementResultView: View {
@EnvironmentObject private var model: WatchAppModel
let summary: WatchMeasurementSummaryV1
private var comparison: WatchBaselineComparison {
model.store.baseline.compare(bpm: summary.averageBPM)
}
var body: some View {
VStack(spacing: 9) {
Image(systemName: summary.isValid ? "checkmark.circle.fill" : "exclamationmark.triangle.fill")
.font(.title)
.foregroundStyle(summary.isValid ? WatchTheme.mint : .orange)
Text(summary.averageBPM.map { "\(Int($0.rounded())) BPM" } ?? "Signal incomplet")
.font(.title3.bold())
WatchCard {
VStack(alignment: .leading, spacing: 5) {
Label("Qualité \(Int((summary.quality * 100).rounded())) %", systemImage: "checkmark.seal")
Text("\(Int(summary.minimumBPM ?? 0))–\(Int(summary.maximumBPM ?? 0)) BPM")
Text("Couverture \(Int(summary.coverageSeconds)) s")
Divider()
Text(comparison.frenchLabel).foregroundStyle(WatchTheme.accent)
}
.font(.caption)
}
Text("Mesure optique de bien-être, non médicale. Aucun indice PRV n’est calculé sur la Watch.")
.font(.caption2)
.foregroundStyle(WatchTheme.secondary)
if summary.isValid {
Text("Ressenti").font(.caption2).foregroundStyle(WatchTheme.secondary)
HStack(spacing: 4) {
ForEach(1...5, id: \.self) { value in
Button("\(value)") { model.saveFeeling(value, for: summary) }
.buttonStyle(.borderless)
.foregroundStyle(summary.feeling == value ? WatchTheme.mint : .white)
}
}
}
Button("Terminer") { model.measurement.resetAfterResult() }
.buttonStyle(.borderedProminent)
}
}
}
+114
View File
@@ -0,0 +1,114 @@
import SwiftUI
struct WatchRootView: View {
@EnvironmentObject private var model: WatchAppModel
var body: some View {
Group {
if model.hasCompletedOnboarding {
WatchHomeView()
} else {
WatchOnboardingView()
}
}
.preferredColorScheme(.dark)
}
}
struct WatchOnboardingView: View {
@EnvironmentObject private var model: WatchAppModel
@State private var page = 0
@State private var authorizationError: String?
var body: some View {
VStack(spacing: 8) {
TabView(selection: $page) {
onboardingPage(icon: "hand.raised.fill", title: "Vos données", text: "Les mesures restent sur vos appareils et se synchronisent avec votre iPhone appairé.").tag(0)
onboardingPage(icon: "heart.text.square.fill", title: "Bien-être, pas diagnostic", text: "Haptika n’est pas un dispositif médical. Consultez un professionnel en cas de symptôme.").tag(1)
onboardingPage(icon: "waveform.path.ecg", title: "Mesurer sans vibrer", text: "La Watch mesure le pouls optique. Les vibrations sont toujours arrêtées pendant cette phase.").tag(2)
}
.tabViewStyle(.verticalPage)
if let authorizationError {
Text(authorizationError).font(.caption2).foregroundStyle(.orange)
}
Button(page < 2 ? "Continuer" : "Autoriser Santé") {
if page < 2 {
withAnimation { page += 1 }
} else {
Task {
do {
try await model.measurement.requestAuthorization()
model.hasCompletedOnboarding = true
} catch {
authorizationError = error.localizedDescription
}
}
}
}
.buttonStyle(.borderedProminent)
}
.padding(.horizontal, 6)
.background(WatchTheme.background.ignoresSafeArea())
}
private func onboardingPage(icon: String, title: String, text: String) -> some View {
VStack(spacing: 10) {
Image(systemName: icon).font(.system(size: 34)).foregroundStyle(WatchTheme.accent)
Text(title).font(.headline).multilineTextAlignment(.center)
Text(text).font(.caption).foregroundStyle(WatchTheme.secondary).multilineTextAlignment(.center)
}
}
}
struct WatchHomeView: View {
@EnvironmentObject private var model: WatchAppModel
var body: some View {
NavigationStack {
List {
Section {
NavigationLink(value: WatchHomeDestination.measure) { homeRow("Mesurer", "waveform.path.ecg", WatchTheme.mint) }
NavigationLink(value: WatchHomeDestination.coherence) { homeRow("Ma Cohérence", "circle.dotted", WatchTheme.accent) }
NavigationLink(value: WatchHomeDestination.exercises) { homeRow("Exercices", "wind", .cyan) }
NavigationLink(value: WatchHomeDestination.cardiacMirror) { homeRow("Miroir cardiaque", "heart.circle", .pink) }
NavigationLink(value: WatchHomeDestination.history) { homeRow("Historique", "chart.xyaxis.line", .indigo) }
} header: {
VStack(alignment: .leading, spacing: 2) {
Text("HAPTIKA").font(.caption2).foregroundStyle(WatchTheme.accent)
Text("Prendre un temps").font(.headline).foregroundStyle(.white)
}
.textCase(nil)
}
Section {
HStack {
Circle().fill(model.connectivity.isReachable ? WatchTheme.mint : .gray).frame(width: 7, height: 7)
Text(model.connectivity.isReachable ? "iPhone joignable" : "Mode autonome")
}
.font(.caption2)
.foregroundStyle(WatchTheme.secondary)
}
}
.navigationDestination(for: WatchHomeDestination.self) { destination in
switch destination {
case .measure: WatchMeasurementView()
case .coherence: WatchCoherenceView()
case .exercises: WatchExerciseListView()
case .cardiacMirror: WatchCardiacMirrorView()
case .history: WatchHistoryView()
}
}
.haptikaScreen()
}
}
private func homeRow(_ title: String, _ icon: String, _ color: Color) -> some View {
Label {
Text(title).font(.subheadline.weight(.semibold))
} icon: {
Image(systemName: icon).foregroundStyle(color)
}
}
}
+29
View File
@@ -0,0 +1,29 @@
import SwiftUI
enum WatchTheme {
static let background = Color(red: 0.015, green: 0.04, blue: 0.10)
static let card = Color(red: 0.035, green: 0.12, blue: 0.22)
static let accent = Color(red: 0.37, green: 0.79, blue: 0.96)
static let mint = Color(red: 0.38, green: 0.88, blue: 0.78)
static let secondary = Color(red: 0.66, green: 0.73, blue: 0.84)
}
struct WatchCard<Content: View>: View {
@ViewBuilder var content: Content
var body: some View {
content
.padding(10)
.frame(maxWidth: .infinity, alignment: .leading)
.background(WatchTheme.card.opacity(0.82), in: RoundedRectangle(cornerRadius: 18))
.overlay(RoundedRectangle(cornerRadius: 18).stroke(WatchTheme.accent.opacity(0.22)))
}
}
extension View {
func haptikaScreen() -> some View {
scrollContentBackground(.hidden)
.background(WatchTheme.background.ignoresSafeArea())
.tint(WatchTheme.accent)
}
}
+126
View File
@@ -0,0 +1,126 @@
import XCTest
@testable import HaptikaWatch
final class WatchCoreTests: XCTestCase {
private let device = WatchDeviceIdentityV1(
id: "watch-series-10-test",
name: "Apple Watch de test",
model: "Watch7,8",
systemVersion: "11.0"
)
func testValidationAcceptsEightSamplesCoveringFortyFiveSeconds() {
let start = Date(timeIntervalSince1970: 1_700_000_000)
let samples = (0..<8).map { index in
WatchHeartRateSampleV1(date: start.addingTimeInterval(Double(index) * 7), bpm: 70 + Double(index % 3))
}
let result = WatchMeasurementValidator.validate(samples: samples)
XCTAssertTrue(result.isValid)
XCTAssertEqual(result.validSamples.count, 8)
XCTAssertEqual(result.coverageSeconds, 49, accuracy: 0.001)
}
func testValidationRejectsOutOfRangeAndLongSilence() {
let start = Date(timeIntervalSince1970: 1_700_000_000)
let offsets: [TimeInterval] = [0, 5, 10, 15, 20, 25, 45, 50, 55]
var samples = offsets.map { WatchHeartRateSampleV1(date: start.addingTimeInterval($0), bpm: 72) }
samples.append(.init(date: start.addingTimeInterval(56), bpm: 221))
let result = WatchMeasurementValidator.validate(samples: samples)
XCTAssertFalse(result.isValid)
XCTAssertTrue(result.invalidReasons.contains(.staleData))
XCTAssertEqual(result.validSamples.count, 9)
}
func testBaselineMaturesAtFiveAndTenComparableMeasurements() {
let five = (0..<5).map { measurement(index: $0, bpm: 70 + Double($0 % 2)) }
XCTAssertEqual(WatchBaselineSnapshot(measurements: five).maturity, .provisional)
let ten = (0..<10).map { measurement(index: $0, bpm: 70 + Double($0 % 2)) }
XCTAssertEqual(WatchBaselineSnapshot(measurements: ten).maturity, .personalized)
XCTAssertEqual(WatchBaselineSnapshot(measurements: ten).compare(bpm: 71), .usual)
XCTAssertEqual(WatchBaselineSnapshot(measurements: ten).compare(bpm: 90), .clearlyDifferent)
}
func testBaselineExcludesLowQualityAndNonRestMeasurements() {
var lowQuality = measurement(index: 0, bpm: 70)
lowQuality.quality = 0.64
var afterExercise = measurement(index: 1, bpm: 71)
afterExercise.context.recentExercise = true
XCTAssertEqual(WatchBaselineSnapshot(measurements: [lowQuality, afterExercise]).comparableMeasurements, 0)
}
func testPhaseEngineMovesThroughBoxBreathing() throws {
let protocolValue = try XCTUnwrap(WatchProtocolCatalog.protocolWithID("box4444"))
XCTAssertEqual(RegulationPhaseEngine.position(in: protocolValue, elapsedSeconds: 1)?.phase.kind, .inhale)
XCTAssertEqual(RegulationPhaseEngine.position(in: protocolValue, elapsedSeconds: 5)?.phase.kind, .hold)
XCTAssertEqual(RegulationPhaseEngine.position(in: protocolValue, elapsedSeconds: 9)?.phase.kind, .exhale)
XCTAssertEqual(RegulationPhaseEngine.position(in: protocolValue, elapsedSeconds: 13)?.phase.kind, .hold)
}
func testEnvelopeEncodesPayloadAsJSONObject() throws {
let summary = measurement(index: 0, bpm: 72)
let envelope = try WatchSyncEnvelopeV1(
eventID: UUID(uuidString: "11111111-1111-4111-8111-111111111111")!,
deviceID: device.id,
sequence: 42,
kind: .measurementUpsert,
payload: summary
)
let data = try WatchCoding.encoder.encode(envelope)
let object = try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [String: Any])
XCTAssertEqual(object["schemaVersion"] as? Int, 1)
XCTAssertNotNil(object["payload"] as? [String: Any])
XCTAssertNil(object["payload"] as? String, "Payload must not be a base64 Data string")
let decoded = try WatchCoding.decoder.decode(WatchSyncEnvelopeV1.self, from: data)
let decodedSummary = try decoded.decodePayload(WatchMeasurementSummaryV1.self)
XCTAssertEqual(decodedSummary.id, summary.id)
}
func testResourceArbiterExcludesHapticsDuringMeasurement() async throws {
let arbiter = WatchResourceArbiter()
try await arbiter.acquire(.heartRateMeasurement)
do {
try await arbiter.acquire(.hapticRegulation)
XCTFail("Second resource must be rejected")
} catch let error as WatchResourceError {
XCTAssertEqual(error, .busy(.heartRateMeasurement))
}
try await arbiter.release(.heartRateMeasurement)
try await arbiter.acquire(.hapticRegulation)
}
@MainActor
func testStoreCapsCanonicalHistoryButKeepsOutbox() throws {
let suite = "HaptikaWatchTests.\(UUID().uuidString)"
let defaults = try XCTUnwrap(UserDefaults(suiteName: suite))
defer { defaults.removePersistentDomain(forName: suite) }
let store = WatchStore(defaults: defaults)
for index in 0..<35 {
let value = measurement(index: index, bpm: 70)
let envelope = try WatchSyncEnvelopeV1(deviceID: device.id, sequence: UInt64(index), kind: .measurementUpsert, payload: value)
store.upsert(value, enqueue: envelope)
}
XCTAssertEqual(store.measurements.count, 30)
XCTAssertEqual(store.outbox.count, 35)
}
private func measurement(index: Int, bpm: Double) -> WatchMeasurementSummaryV1 {
let start = Date(timeIntervalSince1970: 1_700_000_000 + Double(index * 100))
let samples = (0..<8).map { sampleIndex in
WatchHeartRateSampleV1(date: start.addingTimeInterval(Double(sampleIndex) * 7), bpm: bpm)
}
return .init(
startedAt: start,
endedAt: start.addingTimeInterval(60),
device: device,
currentBPM: bpm,
averageBPM: bpm,
minimumBPM: bpm,
maximumBPM: bpm,
samples: samples,
coverageSeconds: 49,
quality: 0.82,
isValid: true,
context: .init(posture: .seated)
)
}
}
+1 -1
View File
@@ -5,7 +5,7 @@ require Pod::Executable.execute_command('node', ['-p',
{paths: [process.argv[1]]},
)', __dir__]).strip
platform :ios, min_ios_version_supported
platform :ios, '16.0'
prepare_react_native_project!
linkage = ENV['USE_FRAMEWORKS']
+2455
View File
File diff suppressed because it is too large Load Diff