feat: implement Haptika PPG and Watch V1
This commit is contained in:
@@ -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
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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() }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user