feat: watch design from figma

This commit is contained in:
leon
2026-09-07 16:02:36 +02:00
parent b9930b7378
commit dcecd07d56
48 changed files with 2927 additions and 243 deletions
+1884
View File
File diff suppressed because it is too large Load Diff
+10
View File
@@ -25,7 +25,9 @@
7EE01244D179E8FF19B34AB1 /* WatchRootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D442ED95A5832B6D76BE3836 /* WatchRootView.swift */; }; 7EE01244D179E8FF19B34AB1 /* WatchRootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D442ED95A5832B6D76BE3836 /* WatchRootView.swift */; };
81AB9BB82411601600AC10FF /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 81AB9BB72411601600AC10FF /* LaunchScreen.storyboard */; }; 81AB9BB82411601600AC10FF /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 81AB9BB72411601600AC10FF /* LaunchScreen.storyboard */; };
85CD8DEF4D32F6F143395961 /* WatchCoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = ED5549BB07ECF7888A132AE9 /* WatchCoreTests.swift */; }; 85CD8DEF4D32F6F143395961 /* WatchCoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = ED5549BB07ECF7888A132AE9 /* WatchCoreTests.swift */; };
87D814A03C8826DC5243F782 /* Inter.ttf in Resources */ = {isa = PBXBuildFile; fileRef = B4AE4816A2AE68CA9E8E1CB0 /* Inter.ttf */; };
8B283CAD2473B5F13BFB92FD /* WatchBaseline.swift in Sources */ = {isa = PBXBuildFile; fileRef = E742A36D6693717080FD19DB /* WatchBaseline.swift */; }; 8B283CAD2473B5F13BFB92FD /* WatchBaseline.swift in Sources */ = {isa = PBXBuildFile; fileRef = E742A36D6693717080FD19DB /* WatchBaseline.swift */; };
9125FED2B6DD2C0D898E181F /* OFL.txt in Resources */ = {isa = PBXBuildFile; fileRef = 769A9666C8F131FA0A875FA1 /* OFL.txt */; };
93FBBB939E9FBC3D0DECCC37 /* WatchExercisesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5C7116D54CE5D0470079330 /* WatchExercisesView.swift */; }; 93FBBB939E9FBC3D0DECCC37 /* WatchExercisesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5C7116D54CE5D0470079330 /* WatchExercisesView.swift */; };
A03E880ED4913D3E67FA728C /* WatchHapticService.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA7A2A9E8A782CA3D7F79124 /* WatchHapticService.swift */; }; A03E880ED4913D3E67FA728C /* WatchHapticService.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA7A2A9E8A782CA3D7F79124 /* WatchHapticService.swift */; };
A5EF56A31E4A899ADF00A55C /* WatchConnectivityService.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDE53A2DF65CFBE48E21F8AF /* WatchConnectivityService.swift */; }; A5EF56A31E4A899ADF00A55C /* WatchConnectivityService.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDE53A2DF65CFBE48E21F8AF /* WatchConnectivityService.swift */; };
@@ -88,6 +90,7 @@
5DCACB8F33CDC322A6C60F78 /* libPods-HaptikaApp.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-HaptikaApp.a"; sourceTree = BUILT_PRODUCTS_DIR; }; 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>"; }; 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>"; }; 761780EC2CA45674006654EE /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = AppDelegate.swift; path = HaptikaApp/AppDelegate.swift; sourceTree = "<group>"; };
769A9666C8F131FA0A875FA1 /* OFL.txt */ = {isa = PBXFileReference; includeInIndex = 1; name = OFL.txt; path = HaptikaWatch/Fonts/OFL.txt; 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; }; 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>"; }; 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>"; }; 816CF2099D6657DF684CC745 /* WatchBeforeAfterFlowView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WatchBeforeAfterFlowView.swift; sourceTree = "<group>"; };
@@ -98,6 +101,7 @@
A845A00D463125569C206227 /* WatchTheme.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WatchTheme.swift; 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; }; 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>"; }; AF06B58CBAC27F9902D1B4A5 /* Assets.xcassets */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
B4AE4816A2AE68CA9E8E1CB0 /* Inter.ttf */ = {isa = PBXFileReference; includeInIndex = 1; name = Inter.ttf; path = HaptikaWatch/Fonts/Inter.ttf; sourceTree = "<group>"; };
BF8BE67FB283769179D55328 /* HaptikaWatch.entitlements */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.entitlements; path = HaptikaWatch.entitlements; 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>"; }; 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>"; }; CE56816B3C44D7F4EDE469C4 /* WatchStore.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WatchStore.swift; sourceTree = "<group>"; };
@@ -204,6 +208,8 @@
A378E27FE3D44AD2EEF64FC9 /* HaptikaShared */, A378E27FE3D44AD2EEF64FC9 /* HaptikaShared */,
47085F82A7AB6010AA4FB195 /* HaptikaWatchTests */, 47085F82A7AB6010AA4FB195 /* HaptikaWatchTests */,
E900FA2623A3C868FA97EFEE /* HaptikaWatch */, E900FA2623A3C868FA97EFEE /* HaptikaWatch */,
B4AE4816A2AE68CA9E8E1CB0 /* Inter.ttf */,
769A9666C8F131FA0A875FA1 /* OFL.txt */,
); );
indentWidth = 2; indentWidth = 2;
sourceTree = "<group>"; sourceTree = "<group>";
@@ -418,6 +424,8 @@
files = ( files = (
2754CCE8D2EB3703FA37D055 /* Assets.xcassets in Resources */, 2754CCE8D2EB3703FA37D055 /* Assets.xcassets in Resources */,
114BDE84D8AA9241891F063A /* regulation-protocols.v1.json in Resources */, 114BDE84D8AA9241891F063A /* regulation-protocols.v1.json in Resources */,
87D814A03C8826DC5243F782 /* Inter.ttf in Resources */,
9125FED2B6DD2C0D898E181F /* OFL.txt in Resources */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
@@ -645,6 +653,7 @@
DEVELOPMENT_TEAM = R537R56465; DEVELOPMENT_TEAM = R537R56465;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
PRODUCT_BUNDLE_IDENTIFIER = com.sachagaulin.haptika.r537r56465.dev.watchtests; PRODUCT_BUNDLE_IDENTIFIER = com.sachagaulin.haptika.r537r56465.dev.watchtests;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = watchos; SDKROOT = watchos;
SUPPORTED_PLATFORMS = "watchos watchsimulator"; SUPPORTED_PLATFORMS = "watchos watchsimulator";
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
@@ -849,6 +858,7 @@
DEVELOPMENT_TEAM = R537R56465; DEVELOPMENT_TEAM = R537R56465;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
PRODUCT_BUNDLE_IDENTIFIER = com.sachagaulin.haptika.r537r56465.dev.watchtests; PRODUCT_BUNDLE_IDENTIFIER = com.sachagaulin.haptika.r537r56465.dev.watchtests;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = watchos; SDKROOT = watchos;
SUPPORTED_PLATFORMS = "watchos watchsimulator"; SUPPORTED_PLATFORMS = "watchos watchsimulator";
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
@@ -36,7 +36,7 @@
<BuildableReference <BuildableReference
BuildableIdentifier = "primary" BuildableIdentifier = "primary"
BlueprintIdentifier = "EA92A2DC9365C1414126C0B1" BlueprintIdentifier = "EA92A2DC9365C1414126C0B1"
BuildableName = ".xctest" BuildableName = "HaptikaWatchTests.xctest"
BlueprintName = "HaptikaWatchTests" BlueprintName = "HaptikaWatchTests"
ReferencedContainer = "container:HaptikaApp.xcodeproj"> ReferencedContainer = "container:HaptikaApp.xcodeproj">
</BuildableReference> </BuildableReference>
+3 -1
View File
@@ -4,11 +4,13 @@ import SwiftUI
struct HaptikaWatchApp: App { struct HaptikaWatchApp: App {
@StateObject private var model = WatchAppModel() @StateObject private var model = WatchAppModel()
init() { WatchTheme.registerFonts() }
var body: some Scene { var body: some Scene {
WindowGroup { WindowGroup {
WatchRootView() WatchRootView()
.environmentObject(model) .environmentObject(model)
.task { model.start() } .task { if !WatchDesignMode.enabled { model.start() } }
} }
} }
} }
+7
View File
@@ -14,6 +14,8 @@ public final class WatchAppModel: ObservableObject {
public let haptics: WatchHapticService public let haptics: WatchHapticService
public let connectivity: WatchConnectivityService public let connectivity: WatchConnectivityService
private var serviceSubscriptions = Set<AnyCancellable>()
private let defaults: UserDefaults private let defaults: UserDefaults
private let onboardingKey = "haptika.watch.onboarding.completed.v1" private let onboardingKey = "haptika.watch.onboarding.completed.v1"
private let deviceIDKey = "haptika.watch.device.id.v1" private let deviceIDKey = "haptika.watch.device.id.v1"
@@ -82,6 +84,11 @@ public final class WatchAppModel: ObservableObject {
} }
private func wireServices() { private func wireServices() {
// Relay nested service changes so all Watch screens receive live updates.
for publisher in [store.objectWillChange, measurement.objectWillChange, haptics.objectWillChange, connectivity.objectWillChange] {
publisher.sink { [weak self] _ in self?.objectWillChange.send() }
.store(in: &serviceSubscriptions)
}
measurement.onCompleted = { [weak self] summary in self?.enqueueMeasurement(summary) } measurement.onCompleted = { [weak self] summary in self?.enqueueMeasurement(summary) }
measurement.onLiveHeartRate = { [weak self] sample in measurement.onLiveHeartRate = { [weak self] sample in
guard let self, guard let self,
@@ -1,20 +1,20 @@
{ {
"colors" : [ "colors": [
{ {
"color" : { "idiom": "universal",
"color-space" : "srgb", "color": {
"components" : { "color-space": "srgb",
"alpha" : "1.000", "components": {
"blue" : "0.922", "red": "0.290196",
"green" : "0.741", "green": "0.721569",
"red" : "0.314" "blue": "0.909804",
"alpha": "1.000"
} }
}, }
"idiom" : "universal"
} }
], ],
"info" : { "info": {
"author" : "xcode", "author": "xcode",
"version" : 1 "version": 1
} }
} }
@@ -0,0 +1,15 @@
{
"images": [
{
"filename": "WatchAnchor.svg",
"idiom": "universal"
}
],
"info": {
"author": "xcode",
"version": 1
},
"properties": {
"preserves-vector-representation": true
}
}
@@ -0,0 +1,3 @@
<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="24" height="18" viewBox="0 0 24 18" fill="none" xmlns="http://www.w3.org/2000/svg">
<path id="Icon" d="M8 18C7.45 18 6.97917 17.8042 6.5875 17.4125C6.19583 17.0208 6 16.55 6 16V2C6 1.45 6.19583 0.979167 6.5875 0.5875C6.97917 0.195833 7.45 0 8 0H16C16.55 0 17.0208 0.195833 17.4125 0.5875C17.8042 0.979167 18 1.45 18 2V16C18 16.55 17.8042 17.0208 17.4125 17.4125C17.0208 17.8042 16.55 18 16 18H8V18M16 16V2V2V2H8V2V2V16V16V16H16V16V16V16M12 5C12.2833 5 12.5208 4.90417 12.7125 4.7125C12.9042 4.52083 13 4.28333 13 4C13 3.71667 12.9042 3.47917 12.7125 3.2875C12.5208 3.09583 12.2833 3 12 3C11.7167 3 11.4792 3.09583 11.2875 3.2875C11.0958 3.47917 11 3.71667 11 4C11 4.28333 11.0958 4.52083 11.2875 4.7125C11.4792 4.90417 11.7167 5 12 5V5M0 12V6H2V12H0V12M3 14V4H5V14H3V14M22 12V6H24V12H22V12M19 14V4H21V14H19V14M8 16V16V16V16V16V16V2V2V2V2V2V2V16V16" fill="#A78BFA"/>
</svg>

After

Width:  |  Height:  |  Size: 955 B

@@ -0,0 +1,15 @@
{
"images": [
{
"filename": "WatchBreath.svg",
"idiom": "universal"
}
],
"info": {
"author": "xcode",
"version": 1
},
"properties": {
"preserves-vector-representation": true
}
}
@@ -0,0 +1,5 @@
<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="16.6667" height="14.1667" viewBox="0 0 16.6667 14.1667" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="Margin">
<path id="Icon" d="M7.91667 14.1667C7.22222 14.1667 6.63194 13.9236 6.14583 13.4375C5.65972 12.9514 5.41667 12.3611 5.41667 11.6667H7.08333C7.08333 11.9028 7.16319 12.1007 7.32292 12.2604C7.48264 12.4201 7.68056 12.5 7.91667 12.5C8.15278 12.5 8.35069 12.4201 8.51042 12.2604C8.67014 12.1007 8.75 11.9028 8.75 11.6667C8.75 11.4306 8.67014 11.2326 8.51042 11.0729C8.35069 10.9132 8.15278 10.8333 7.91667 10.8333H0V9.16667H7.91667C8.61111 9.16667 9.20139 9.40972 9.6875 9.89583C10.1736 10.3819 10.4167 10.9722 10.4167 11.6667C10.4167 12.3611 10.1736 12.9514 9.6875 13.4375C9.20139 13.9236 8.61111 14.1667 7.91667 14.1667V14.1667M0 5.83333V4.16667H11.25C11.6111 4.16667 11.9097 4.04861 12.1458 3.8125C12.3819 3.57639 12.5 3.27778 12.5 2.91667C12.5 2.55556 12.3819 2.25694 12.1458 2.02083C11.9097 1.78472 11.6111 1.66667 11.25 1.66667C10.8889 1.66667 10.5903 1.78472 10.3542 2.02083C10.1181 2.25694 10 2.55556 10 2.91667H8.33333C8.33333 2.09722 8.61458 1.40625 9.17708 0.84375C9.73958 0.28125 10.4306 0 11.25 0C12.0694 0 12.7604 0.28125 13.3229 0.84375C13.8854 1.40625 14.1667 2.09722 14.1667 2.91667C14.1667 3.73611 13.8854 4.42708 13.3229 4.98958C12.7604 5.55208 12.0694 5.83333 11.25 5.83333H0V5.83333M13.75 12.5V10.8333C14.1111 10.8333 14.4097 10.7153 14.6458 10.4792C14.8819 10.2431 15 9.94444 15 9.58333C15 9.22222 14.8819 8.92361 14.6458 8.6875C14.4097 8.45139 14.1111 8.33333 13.75 8.33333H0V6.66667H13.75C14.5694 6.66667 15.2604 6.94792 15.8229 7.51042C16.3854 8.07292 16.6667 8.76389 16.6667 9.58333C16.6667 10.4028 16.3854 11.0938 15.8229 11.6562C15.2604 12.2188 14.5694 12.5 13.75 12.5V12.5" fill="#4AB8E8"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

@@ -0,0 +1,15 @@
{
"images": [
{
"filename": "WatchChevron.svg",
"idiom": "universal"
}
],
"info": {
"author": "xcode",
"version": 1
},
"properties": {
"preserves-vector-representation": true
}
}
@@ -0,0 +1,5 @@
<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="4.93333" height="8" viewBox="0 0 4.93333 8" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="Container">
<path id="Icon" d="M3.06667 4L0 0.933333L0.933333 0L4.93333 4L0.933333 8L0 7.06667L3.06667 4V4" fill="#3E484E"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 318 B

@@ -0,0 +1,15 @@
{
"images": [
{
"filename": "WatchHeart.svg",
"idiom": "universal"
}
],
"info": {
"author": "xcode",
"version": 1
},
"properties": {
"preserves-vector-representation": true
}
}
@@ -0,0 +1,5 @@
<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="16.6667" height="15.2917" viewBox="0 0 16.6667 15.2917" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="Container">
<path id="Icon" d="M8.33333 15.2917L7.125 14.2083C5.72222 12.9444 4.5625 11.8542 3.64583 10.9375C2.72917 10.0208 2 9.19792 1.45833 8.46875C0.916667 7.73958 0.538194 7.06944 0.322917 6.45833C0.107639 5.84722 0 5.22222 0 4.58333C0 3.27778 0.4375 2.1875 1.3125 1.3125C2.1875 0.4375 3.27778 0 4.58333 0C5.30556 0 5.99306 0.152778 6.64583 0.458333C7.29861 0.763889 7.86111 1.19444 8.33333 1.75C8.80556 1.19444 9.36806 0.763889 10.0208 0.458333C10.6736 0.152778 11.3611 0 12.0833 0C13.3889 0 14.4792 0.4375 15.3542 1.3125C16.2292 2.1875 16.6667 3.27778 16.6667 4.58333C16.6667 5.22222 16.559 5.84722 16.3438 6.45833C16.1285 7.06944 15.75 7.73958 15.2083 8.46875C14.6667 9.19792 13.9375 10.0208 13.0208 10.9375C12.1042 11.8542 10.9444 12.9444 9.54167 14.2083L8.33333 15.2917V15.2917" fill="#4AB8E8"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1011 B

@@ -0,0 +1,15 @@
{
"images": [
{
"filename": "WatchMeditation.svg",
"idiom": "universal"
}
],
"info": {
"author": "xcode",
"version": 1
},
"properties": {
"preserves-vector-representation": true
}
}
@@ -0,0 +1,5 @@
<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="19" height="17" viewBox="0 0 19 17" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="Margin">
<path id="Icon" d="M4.01111 17C3.48333 17 3.03472 16.8141 2.66528 16.4422C2.29583 16.0703 2.11111 15.6187 2.11111 15.0875C2.11111 14.7156 2.21667 14.3659 2.42778 14.0383C2.63889 13.7107 2.92037 13.476 3.27222 13.3344L7.38889 11.6875V9.29688C6.43889 10.4125 5.33495 11.2669 4.07708 11.8602C2.81921 12.4534 1.46019 12.75 0 12.75V10.625C1.1963 10.625 2.28264 10.3771 3.25903 9.88125C4.23542 9.38542 5.11944 8.67708 5.91111 7.75625L7.33611 6.05625C7.54722 5.80833 7.79352 5.6224 8.075 5.49844C8.35648 5.37448 8.65556 5.3125 8.97222 5.3125H10.0278C10.3444 5.3125 10.6435 5.37448 10.925 5.49844C11.2065 5.6224 11.4528 5.80833 11.6639 6.05625L13.0889 7.75625C13.8806 8.67708 14.7646 9.38542 15.741 9.88125C16.7174 10.3771 17.8037 10.625 19 10.625V12.75C17.5398 12.75 16.1808 12.4534 14.9229 11.8602C13.665 11.2669 12.5611 10.4125 11.6111 9.29688V11.6875L15.7278 13.3344C16.0796 13.476 16.3611 13.7107 16.5722 14.0383C16.7833 14.3659 16.8889 14.7156 16.8889 15.0875C16.8889 15.6187 16.7042 16.0703 16.3347 16.4422C15.9653 16.8141 15.5167 17 14.9889 17H7.38889V16.4687C7.38889 16.0083 7.53843 15.6276 7.8375 15.3266C8.13657 15.0255 8.51481 14.875 8.97222 14.875H12.1389C12.2972 14.875 12.4248 14.8263 12.5215 14.7289C12.6183 14.6315 12.6667 14.5031 12.6667 14.3438C12.6667 14.1844 12.6183 14.056 12.5215 13.9586C12.4248 13.8612 12.2972 13.8125 12.1389 13.8125H8.97222C8.23333 13.8125 7.6088 14.0693 7.09861 14.5828C6.58843 15.0964 6.33333 15.725 6.33333 16.4687V17H4.01111V17M9.5 4.25C8.91944 4.25 8.42245 4.04193 8.00903 3.62578C7.5956 3.20964 7.38889 2.70938 7.38889 2.125C7.38889 1.54063 7.5956 1.04036 8.00903 0.624219C8.42245 0.208073 8.91944 0 9.5 0C10.0806 0 10.5775 0.208073 10.991 0.624219C11.4044 1.04036 11.6111 1.54063 11.6111 2.125C11.6111 2.70938 11.4044 3.20964 10.991 3.62578C10.5775 4.04193 10.0806 4.25 9.5 4.25V4.25" fill="#E94697"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.0 KiB

@@ -0,0 +1,15 @@
{
"images": [
{
"filename": "WatchPrepare.svg",
"idiom": "universal"
}
],
"info": {
"author": "xcode",
"version": 1
},
"properties": {
"preserves-vector-representation": true
}
}
@@ -0,0 +1,5 @@
<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="17" height="14" viewBox="0 0 17 14" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="Container">
<path id="Icon" d="M1.7 14C1.2325 14 0.832292 13.8286 0.499375 13.4859C0.166458 13.1432 0 12.7313 0 12.25V7.875H4.56875L6.035 10.8938C6.10583 11.0542 6.21208 11.1745 6.35375 11.2547C6.49542 11.3349 6.64417 11.375 6.8 11.375C6.95583 11.375 7.10458 11.3349 7.24625 11.2547C7.38792 11.1745 7.49417 11.0542 7.565 10.8938L10.2 5.46875L11.135 7.39375C11.2058 7.55417 11.3121 7.67448 11.4537 7.75469C11.5954 7.8349 11.7442 7.875 11.9 7.875H17V12.25C17 12.7313 16.8335 13.1432 16.5006 13.4859C16.1677 13.8286 15.7675 14 15.3 14H1.7V14M0 6.125V1.75C0 1.26875 0.166458 0.856771 0.499375 0.514063C0.832292 0.171354 1.2325 0 1.7 0H15.3C15.7675 0 16.1677 0.171354 16.5006 0.514063C16.8335 0.856771 17 1.26875 17 1.75V6.125H12.4312L10.965 3.10625C10.8942 2.94583 10.7879 2.83281 10.6462 2.76719C10.5046 2.70156 10.3558 2.66875 10.2 2.66875C10.0442 2.66875 9.89542 2.70156 9.75375 2.76719C9.61208 2.83281 9.50583 2.94583 9.435 3.10625L6.8 8.53125L5.865 6.60625C5.79417 6.44583 5.68792 6.32552 5.54625 6.24531C5.40458 6.1651 5.25583 6.125 5.1 6.125H0V6.125" fill="#73D1FF"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

@@ -0,0 +1,15 @@
{
"images": [
{
"filename": "WatchSOS.svg",
"idiom": "universal"
}
],
"info": {
"author": "xcode",
"version": 1
},
"properties": {
"preserves-vector-representation": true
}
}
@@ -0,0 +1,5 @@
<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="20.1833" height="21" viewBox="0 0 20.1833 21" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="Container">
<path id="Icon" d="M8.05 21V14.0583L2.04167 17.5292L0 14L6.00833 10.5L0 7.02917L2.04167 3.5L8.05 6.97083V0H12.1333V6.97083L18.1417 3.5L20.1833 7.02917L14.175 10.5L20.1833 14L18.1417 17.5292L12.1333 14.0583V21H8.05V21" fill="#F43F5E"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 442 B

@@ -0,0 +1,15 @@
{
"images": [
{
"filename": "WatchStop.svg",
"idiom": "universal"
}
],
"info": {
"author": "xcode",
"version": 1
},
"properties": {
"preserves-vector-representation": true
}
}
@@ -0,0 +1,5 @@
<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="Container">
<path id="Icon" d="M6 14H14V6H6V14V14M10 20C8.61667 20 7.31667 19.7375 6.1 19.2125C4.88333 18.6875 3.825 17.975 2.925 17.075C2.025 16.175 1.3125 15.1167 0.7875 13.9C0.2625 12.6833 0 11.3833 0 10C0 8.61667 0.2625 7.31667 0.7875 6.1C1.3125 4.88333 2.025 3.825 2.925 2.925C3.825 2.025 4.88333 1.3125 6.1 0.7875C7.31667 0.2625 8.61667 0 10 0C11.3833 0 12.6833 0.2625 13.9 0.7875C15.1167 1.3125 16.175 2.025 17.075 2.925C17.975 3.825 18.6875 4.88333 19.2125 6.1C19.7375 7.31667 20 8.61667 20 10C20 11.3833 19.7375 12.6833 19.2125 13.9C18.6875 15.1167 17.975 16.175 17.075 17.075C16.175 17.975 15.1167 18.6875 13.9 19.2125C12.6833 19.7375 11.3833 20 10 20V20" fill="#FFB4AB" fill-opacity="0.8"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 887 B

Binary file not shown.
+93
View File
@@ -0,0 +1,93 @@
Copyright 2020 The Inter Project Authors (https://github.com/rsms/inter)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
+17 -12
View File
@@ -115,24 +115,29 @@ struct WatchCoherenceView: View {
struct BreathOrb: View { struct BreathOrb: View {
let regulation: WatchRegulationProtocol let regulation: WatchRegulationProtocol
let startedAt: Date let startedAt: Date
var showsLabel = true
@Environment(\.accessibilityReduceMotion) private var reduceMotion
var body: some View { var body: some View {
TimelineView(.animation(minimumInterval: 1 / 20)) { context in TimelineView(.animation(minimumInterval: reduceMotion ? 1 : 1 / 20)) { context in
let elapsed = context.date.timeIntervalSince(startedAt) let elapsed = WatchDesignMode.frozen ? 0 : context.date.timeIntervalSince(startedAt)
let position = RegulationPhaseEngine.position(in: regulation, elapsedSeconds: elapsed) let position = RegulationPhaseEngine.position(in: regulation, elapsedSeconds: elapsed)
let isExpanding = position?.phase.kind == .inhale
let fraction = position?.progress ?? 0 let fraction = position?.progress ?? 0
let scale = isExpanding ? 0.6 + fraction * 0.4 : 1 - fraction * 0.4 let scale = position?.phase.kind == .inhale ? 0.65 + fraction * 0.35 : position?.phase.kind == .exhale ? 1 - fraction * 0.35 : 1
VStack(spacing: 6) { VStack(spacing: 20) {
Text(position?.phase.kind == .exhale ? "Expirez" : position?.phase.kind == .inhale ? "Inspirez" : position?.phase.label ?? "Inspirez")
.opacity(showsLabel ? 1 : 0)
.font(WatchTheme.font(20, weight: .semibold)).multilineTextAlignment(.center)
.minimumScaleFactor(0.75).lineLimit(1).fixedSize(horizontal: false, vertical: true)
ZStack { ZStack {
Circle().fill(WatchTheme.accent.opacity(0.12)).frame(width: 108, height: 108) Circle().fill(WatchTheme.accent.opacity(0.2)).blur(radius: 6)
Circle() Circle().fill(WatchTheme.accent.opacity(0.8))
.fill(RadialGradient(colors: [WatchTheme.mint, WatchTheme.accent.opacity(0.25)], center: .center, startRadius: 2, endRadius: 52)) .overlay(Circle().stroke(WatchTheme.accent.opacity(0.3)))
.frame(width: 88, height: 88) Circle().fill(.white.opacity(0.8)).frame(width: 8, height: 8)
.scaleEffect(scale)
} }
Text(position?.phase.label ?? "Respirez").font(.headline) .frame(width: 68, height: 68).scaleEffect(reduceMotion || WatchDesignMode.frozen ? 1 : scale)
} .frame(height: 68).accessibilityHidden(true)
}.frame(maxWidth: .infinity).background { WatchGlow() }
} }
} }
} }
+225 -70
View File
@@ -1,102 +1,257 @@
import SwiftUI import SwiftUI
struct WatchExerciseListView: View { enum WatchRoutineCategory: String, CaseIterable, Identifiable {
private var hapticProtocols: [WatchRegulationProtocol] { case breathe, anchor, meditate
WatchProtocolCatalog.protocols.filter { $0.mode == .haptic } var id: String { rawValue }
} var title: String { switch self { case .breathe: "Respirer"; case .anchor: "Ancrer"; case .meditate: "Méditation" } }
var color: Color { switch self { case .breathe: WatchTheme.accent; case .anchor: WatchTheme.purple; case .meditate: WatchTheme.pink } }
var body: some View { var asset: String { switch self { case .breathe: "WatchBreath"; case .anchor: "WatchAnchor"; case .meditate: "WatchMeditation" } }
List(hapticProtocols) { regulation in var routines: [WatchRoutine] {
NavigationLink { switch self {
WatchExercisePlayerView(regulation: regulation) case .breathe:
} label: { return [WatchRoutine(title: "Cohérence", protocolID: "coherence55", category: self),
VStack(alignment: .leading, spacing: 3) { WatchRoutine(title: "Calme express", protocolID: "relax46", category: self),
Text(regulation.title).font(.subheadline.bold()) WatchRoutine(title: "Équilibre", protocolID: "box4444", category: self),
Text(formatDuration(regulation.durationSeconds)).font(.caption2).foregroundStyle(WatchTheme.secondary) WatchRoutine(title: "4·7·8", protocolID: "breathing478", category: self)]
} case .anchor:
} return [WatchRoutine(title: "Micro-Ancrage", protocolID: "microAnchor", category: self),
WatchRoutine(title: "Sos Stress", protocolID: "sosStress", category: self),
WatchRoutine(title: "Apaiser", category: self), WatchRoutine(title: "Booster", category: self),
WatchRoutine(title: "Endormissement", category: self)]
case .meditate:
return ["Découverte", "Présence", "Sensation", "Atterrissage", "Libre"].map { WatchRoutine(title: $0, category: self) }
} }
.navigationTitle("Exercices") }
.haptikaScreen() }
struct WatchRoutine: Identifiable {
let title: String
var protocolID: String? = nil
let category: WatchRoutineCategory
var id: String { category.rawValue + title }
var regulation: WatchRegulationProtocol? { protocolID.flatMap(WatchProtocolCatalog.protocolWithID) }
var isSleep: Bool { title == "Endormissement" }
var summary: String {
title == "Découverte" ? "Cinq minutes de vibrations\nespacées. Ressenter,\nsans rien modifier." : ""
}
}
struct WatchExerciseListView: View {
var body: some View {
TabView {
ForEach(WatchRoutineCategory.allCases) { category in WatchRoutineList(category: category) }
}.tabViewStyle(.verticalPage).haptikaScreen()
}
}
struct WatchRoutineList: View {
let category: WatchRoutineCategory
var body: some View {
WatchFigmaCanvas { ScrollView {
VStack(spacing: 8) {
HStack(spacing: 8) {
Text(category.title).font(WatchTheme.font(20, weight: .semibold))
Image(category.asset).resizable().scaledToFit().frame(width: 19, height: 17).accessibilityHidden(true)
}.foregroundStyle(category.color).padding(.bottom, 6)
ForEach(category.routines) { routine in
NavigationLink { WatchRoutineView(routine: routine) } label: { WatchMenuRow(title: routine.title, color: category.color) }
.buttonStyle(.plain)
}
}.padding(.horizontal, 16).padding(.top, 36).padding(.bottom, 12)
}.scrollIndicators(.hidden).contentMargins(.vertical, 0, for: .scrollContent)
.ignoresSafeArea(.container, edges: .top) }
} }
} }
struct WatchExercisePlayerView: View { struct WatchExercisePlayerView: View {
let regulation: WatchRegulationProtocol
var body: some View {
WatchRoutineView(routine: .init(title: regulation.title, protocolID: regulation.id,
category: regulation.family == .breathe ? .breathe : .anchor))
}
}
struct WatchRoutineView: View {
@EnvironmentObject private var model: WatchAppModel @EnvironmentObject private var model: WatchAppModel
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
let regulation: WatchRegulationProtocol let routine: WatchRoutine
var initiallyRunning = false
@State private var startedAt: Date? @State private var startedAt: Date?
@State private var errorMessage: String? @State private var errorMessage: String?
@State private var sessionSaved = false @State private var sessionSaved = false
@State private var finished = false
@State private var blackScreen = true
@State private var screenHidden = false
@State private var focus = true
var body: some View { var body: some View {
ScrollView { WatchFigmaCanvas(background: routine.isSleep ? WatchTheme.night : WatchTheme.background) {
VStack(spacing: 10) { ZStack {
if model.haptics.isRunning, let startedAt { if routine.category == .meditate {
BreathOrb(regulation: regulation, startedAt: startedAt) meditation
ProgressView(value: model.haptics.elapsedSeconds, total: regulation.durationSeconds) } else if routine.category == .anchor || startedAt != nil {
Text("\(Int(max(0, regulation.durationSeconds - model.haptics.elapsedSeconds))) s") session
.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 { } else {
Text(regulation.eyebrow).font(.caption2.bold()).foregroundStyle(WatchTheme.accent) preparation
Text(regulation.title).font(.headline).multilineTextAlignment(.center) }
Text(regulation.summary).font(.caption2).foregroundStyle(WatchTheme.secondary).multilineTextAlignment(.center) if screenHidden {
Text("Aucune mesure cardiaque pendant les vibrations.").font(.caption2.bold()).foregroundStyle(.orange) Color.black.ignoresSafeArea()
Button("Commencer") { start() }.buttonStyle(.borderedProminent) Button { screenHidden = false } label: {
NavigationLink("Parcours avant / après") { Color.black.frame(maxWidth: .infinity, maxHeight: .infinity).contentShape(Rectangle())
WatchBeforeAfterFlowView(regulation: regulation) }.buttonStyle(.plain).accessibilityLabel("Afficher les commandes de la séance")
}
.font(.caption)
} }
if let errorMessage { Text(errorMessage).font(.caption2).foregroundStyle(.orange) }
} }
.padding(.horizontal, 4)
} }
.navigationTitle(regulation.title) .ignoresSafeArea(.container, edges: routine.isSleep ? .bottom : [])
.haptikaScreen() .task {
.onDisappear { if initiallyRunning || routine.category == .anchor { await start() }
if model.haptics.isRunning { }
saveSession(state: .cancelled) .alert(errorMessage ?? "", isPresented: Binding(get: { errorMessage != nil }, set: { if !$0 { errorMessage = nil } })) {
model.haptics.cancel() Button("Fermer") { dismiss() }
}
.onChange(of: model.haptics.isRunning) { wasRunning, running in
if wasRunning && !running && startedAt != nil && !finished && !WatchDesignMode.enabled {
saveSession(state: .completed)
finished = true
dismiss()
} }
} }
.onDisappear {
guard startedAt != nil, !finished, !WatchDesignMode.enabled else { return }
saveSession(state: .cancelled)
model.haptics.cancel()
}
} }
private func start() { private var preparation: some View {
let now = Date() VStack(spacing: 0) {
Task { WatchEyebrow(title: routine.title == "Cohérence" ? "Cohérence Cardiaque" : routine.title)
do { .tracking(-0.6)
try await model.haptics.start(protocol: regulation) Spacer(minLength: 14)
startedAt = now VStack(spacing: 5) {
} catch { Image("WatchPrepare").resizable().scaledToFit().frame(width: 17, height: 14)
errorMessage = error.localizedDescription Text("Prêt pour la séance ?").font(WatchTheme.font(7))
} }
.frame(width: 110, height: 86)
.overlay(RoundedRectangle(cornerRadius: 24).stroke(.white.opacity(0.05), lineWidth: 4))
Toggle(isOn: $blackScreen) { Text("Mode écran noir").font(WatchTheme.font(10, weight: .semibold)) }
.toggleStyle(WatchCompactToggleStyle())
.padding(.top, 10)
Spacer(minLength: 16)
Button("Commencer".uppercased()) { Task { await start() } }
.buttonStyle(WatchPillStyle(primary: true, height: 29, fontSize: 10))
}.padding(.horizontal, 24).padding(.vertical, 12)
}
private var meditation: some View {
VStack(spacing: 10) {
Text(routine.title).font(WatchTheme.font(24, weight: .medium)).tracking(-0.6)
if !routine.summary.isEmpty {
Text(routine.summary).font(WatchTheme.font(12))
.foregroundStyle(WatchTheme.secondary.opacity(0.8))
.multilineTextAlignment(.center).fixedSize(horizontal: false, vertical: true)
}
}.frame(maxWidth: .infinity, maxHeight: .infinity)
.padding(.horizontal, 20).background { WatchGlow() }
}
private var session: some View {
VStack(spacing: 0) {
if routine.isSleep {
Color.clear.frame(maxWidth: .infinity, maxHeight: .infinity)
} else if routine.category == .breathe, let regulation = routine.regulation {
Spacer(minLength: 0)
BreathOrb(regulation: regulation, startedAt: startedAt ?? Date(), showsLabel: focus)
HStack(spacing: 0) {
modeButton("FOCUS", selected: focus) { focus = true }
modeButton("LIBRE", selected: !focus) { focus = false }
}
.padding(4).frame(width: 132, height: 24)
.background(WatchTheme.card, in: Capsule())
.overlay(Capsule().stroke(.white.opacity(0.05)))
.padding(.top, 12)
Spacer(minLength: 16)
} else {
Spacer(minLength: 0)
VStack(spacing: 8) {
Text(startedAt != nil || WatchDesignMode.enabled ? "Session en cours" : "").font(.system(size: 14)).foregroundStyle(WatchTheme.secondary.opacity(0.6))
TimelineView(.periodic(from: .now, by: 1)) { context in
let duration = routine.regulation?.durationSeconds ?? 785
let remaining = max(0, duration - context.date.timeIntervalSince(startedAt ?? context.date))
Text(WatchDesignMode.frozen ? "13:05" : startedAt == nil ? "—" : watchTime(remaining))
.font(.system(size: 56)).monospacedDigit().foregroundStyle(WatchTheme.mint)
.shadow(color: WatchTheme.accent.opacity(0.15), radius: 6)
}
HStack(spacing: 4) {
ForEach(0..<3) { index in
Circle().fill(WatchTheme.accent.opacity(index == 0 ? 0.45 : 0.15)).frame(width: 6, height: 6)
}
}.accessibilityHidden(true)
}
Spacer(minLength: 28)
}
Button { stop() } label: {
HStack(spacing: 8) {
if routine.category != .breathe { Image("WatchStop").resizable().scaledToFit().frame(width: 20, height: 20) }
Text(routine.category == .breathe ? "Terminer" : "Arrêter")
}
}.buttonStyle(WatchPillStyle(destructive: routine.category != .breathe, height: routine.category == .breathe ? 36 : 48))
}.frame(maxWidth: .infinity, maxHeight: .infinity)
.padding(.horizontal, routine.category == .breathe ? 28 : 16).padding(.vertical, 12)
}
private func modeButton(_ label: String, selected: Bool, action: @escaping () -> Void) -> some View {
Button(action: action) { Text(label).font(WatchTheme.font(8, weight: .semibold)).tracking(0.6)
.foregroundStyle(selected ? Color(red: 0, green: 0.275, blue: 0.369) : WatchTheme.mint)
.frame(maxWidth: .infinity, minHeight: 16)
.background(selected ? WatchTheme.mint : .clear, in: Capsule())
}.buttonStyle(.plain).accessibilityAddTraits(selected ? .isSelected : [])
}
private func start() async {
if WatchDesignMode.enabled {
startedAt = Date()
screenHidden = blackScreen && routine.category == .breathe && !initiallyRunning
return
} }
guard let regulation = routine.regulation else { return }
do {
try await model.haptics.start(protocol: regulation)
startedAt = Date()
screenHidden = blackScreen && routine.category == .breathe && !initiallyRunning
} catch { errorMessage = error.localizedDescription }
}
private func stop() {
saveSession(state: .cancelled)
finished = true
if !WatchDesignMode.enabled { model.haptics.cancel() }
dismiss()
} }
private func saveSession(state: WatchRegulationSessionState) { private func saveSession(state: WatchRegulationSessionState) {
guard !sessionSaved, let startedAt else { return } guard !WatchDesignMode.enabled, !sessionSaved, let startedAt, let regulation = routine.regulation else { return }
sessionSaved = true sessionSaved = true
model.enqueueSession(.init( model.enqueueSession(.init(protocolID: regulation.id, startedAt: startedAt, endedAt: Date(),
protocolID: regulation.id, durationSeconds: model.haptics.elapsedSeconds, state: state))
startedAt: startedAt,
endedAt: Date(),
durationSeconds: model.haptics.elapsedSeconds,
state: state
))
} }
} }
private func formatDuration(_ duration: TimeInterval) -> String { struct WatchCompactToggleStyle: ToggleStyle {
let seconds = Int(duration) func makeBody(configuration: Configuration) -> some View {
return seconds < 60 ? "\(seconds) s" : "\(seconds / 60) min \(seconds % 60 == 0 ? "" : "\(seconds % 60) s")" Button { configuration.isOn.toggle() } label: {
HStack {
configuration.label
Spacer(minLength: 8)
Capsule().fill(configuration.isOn ? WatchTheme.mint : WatchTheme.secondary.opacity(0.3))
.frame(width: 29, height: 15)
.overlay(alignment: configuration.isOn ? .trailing : .leading) {
Circle().fill(configuration.isOn ? Color(red: 0, green: 0.208, blue: 0.282) : WatchTheme.text)
.frame(width: 11, height: 11).padding(2)
}
}.padding(.horizontal, 10).frame(height: 29)
.background(WatchTheme.card.opacity(0.6), in: Capsule())
.overlay(Capsule().stroke(.white.opacity(0.08)))
}.buttonStyle(.plain).accessibilityValue(configuration.isOn ? "Activé" : "Désactivé")
}
} }
+100 -90
View File
@@ -2,114 +2,124 @@ import SwiftUI
struct WatchMeasurementView: View { struct WatchMeasurementView: View {
@EnvironmentObject private var model: WatchAppModel @EnvironmentObject private var model: WatchAppModel
@Environment(\.dismiss) private var dismiss
@State private var errorMessage: String?
@State private var previewStartedAt: Date?
@State private var previewCompleted = false
var body: some View { var body: some View {
ScrollView { WatchFigmaCanvas {
VStack(spacing: 10) { Group {
switch model.measurement.state { if previewCompleted {
case .idle, .authorizing: WatchMeasurementResultView()
preparation } else if case let .completed(summary) = model.measurement.state, !WatchDesignMode.enabled {
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) WatchMeasurementResultView(summary: summary)
case let .failed(message): } else {
Image(systemName: "exclamationmark.triangle.fill").foregroundStyle(.orange) TimelineView(.periodic(from: .now, by: 1)) { context in
Text("Mesure interrompue").font(.headline) let seconds = remainingSeconds(at: context.date)
Text(message).font(.caption2).foregroundStyle(WatchTheme.secondary) VStack(spacing: 0) {
Button("Recommencer") { model.measurement.resetAfterResult() } WatchEyebrow(title: "Analyse")
Spacer(minLength: 10)
WatchSignalRing(progress: 1 - Double(seconds) / 60)
Text(watchTime(TimeInterval(seconds)))
.font(WatchTheme.font(15, weight: .bold)).monospacedDigit()
.padding(.top, 12)
Text("Restez immobile")
.font(WatchTheme.font(14)).foregroundStyle(WatchTheme.secondary.opacity(0.6))
.padding(.top, 6)
Spacer(minLength: 12)
Button("Fermer") { close() }.buttonStyle(WatchPillStyle())
}
.onChange(of: seconds) { _, value in
if WatchDesignMode.enabled && !WatchDesignMode.frozen && value == 0 { previewCompleted = true }
}
}
} }
}.padding(.horizontal, 20).padding(.vertical, 12)
}
.task { await startMeasurement() }
.alert(errorMessage ?? "", isPresented: Binding(get: { errorMessage != nil }, set: { if !$0 { errorMessage = nil } })) {
Button("Fermer") { close() }
}
.onChange(of: model.measurement.state) { _, state in
if !WatchDesignMode.enabled, case let .failed(message) = state { errorMessage = message }
}
.onDisappear {
guard !WatchDesignMode.enabled else { return }
switch model.measurement.state {
case .waitingForFirstSample, .collecting: model.measurement.cancel()
default: break
} }
.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 { private func remainingSeconds(at date: Date) -> Int {
VStack(spacing: 3) { if WatchDesignMode.enabled {
Image(systemName: "heart.fill").foregroundStyle(.pink).symbolEffect(.pulse, options: .repeating) guard !WatchDesignMode.frozen, let previewStartedAt else { return 60 }
Text(bpm.map { "\(Int($0.rounded()))" } ?? "—") return max(0, 60 - Int(date.timeIntervalSince(previewStartedAt)))
.font(.system(size: 42, weight: .bold, design: .rounded)) }
Text(bpm == nil ? caption : "BPM · \(caption)").font(.caption2).foregroundStyle(WatchTheme.secondary) switch model.measurement.state {
case let .collecting(progress): return max(0, Int((60 * (1 - progress)).rounded(.up)))
case .finishing: return 0
default: return 60
} }
} }
private var cancelButton: some View { private func startMeasurement() async {
Button("Arrêter", role: .cancel) { model.measurement.cancel() } if WatchDesignMode.enabled { previewStartedAt = Date(); return }
.buttonStyle(.bordered) do {
model.measurement.resetAfterResult()
try await model.measurement.requestAuthorization()
guard !Task.isCancelled else { return }
try await model.measurement.start()
} catch { errorMessage = error.localizedDescription }
}
private func close() {
if !WatchDesignMode.enabled { model.measurement.cancel(); model.measurement.resetAfterResult() }
dismiss()
}
}
struct WatchSignalRing: View {
let progress: Double
var body: some View {
ZStack {
Circle().stroke(Color(red: 25 / 255, green: 32 / 255, blue: 44 / 255).opacity(0.4), lineWidth: 6)
Circle().stroke(WatchTheme.accent.opacity(0.8), lineWidth: 6)
Circle().stroke(WatchTheme.accent.opacity(0.6), lineWidth: 2)
Circle().fill(WatchTheme.accent.opacity(0.2)).frame(width: 40, height: 40)
.overlay(Circle().stroke(WatchTheme.accent.opacity(0.3)))
.shadow(color: WatchTheme.accent.opacity(0.3), radius: 8)
Image("WatchHeart").resizable().scaledToFit().frame(width: 16.667, height: 15.292)
}.frame(width: 72, height: 72).padding(3)
.accessibilityLabel("Analyse").accessibilityValue("\(Int(progress * 100)) pour cent")
} }
} }
struct WatchMeasurementResultView: View { struct WatchMeasurementResultView: View {
@EnvironmentObject private var model: WatchAppModel @EnvironmentObject private var model: WatchAppModel
let summary: WatchMeasurementSummaryV1 @Environment(\.dismiss) private var dismiss
var summary: WatchMeasurementSummaryV1? = nil
private var comparison: WatchBaselineComparison {
model.store.baseline.compare(bpm: summary.averageBPM)
}
var body: some View { var body: some View {
VStack(spacing: 9) { VStack(spacing: 0) {
Image(systemName: summary.isValid ? "checkmark.circle.fill" : "exclamationmark.triangle.fill") WatchEyebrow(title: "RÉSULTAT")
.font(.title) Spacer(minLength: 12)
.foregroundStyle(summary.isValid ? WatchTheme.mint : .orange) HStack(alignment: .firstTextBaseline, spacing: 0) {
Text(summary.averageBPM.map { "\(Int($0.rounded())) BPM" } ?? "Signal incomplet") Text(WatchDesignMode.enabled ? "72" : "—")
.font(.title3.bold()) .font(WatchTheme.font(64, weight: .bold)).tracking(-3.2)
WatchCard { .foregroundStyle(LinearGradient(colors: [WatchTheme.mint, WatchTheme.accent], startPoint: .topLeading, endPoint: .bottomTrailing))
VStack(alignment: .leading, spacing: 5) { Text("%").font(WatchTheme.font(14, weight: .medium)).foregroundStyle(WatchTheme.mint.opacity(0.8))
Label("Qualité \(Int((summary.quality * 100).rounded())) %", systemImage: "checkmark.seal") }.foregroundStyle(WatchTheme.accent).shadow(color: WatchTheme.accent.opacity(0.5), radius: 5)
Text("\(Int(summary.minimumBPM ?? 0))–\(Int(summary.maximumBPM ?? 0)) BPM") WatchBalanceAxis(position: WatchDesignMode.enabled ? 0.77 : nil).padding(.top, 14)
Text("Couverture \(Int(summary.coverageSeconds)) s") Text(WatchDesignMode.enabled ? "Équilibre favorable" : "")
Divider() .font(WatchTheme.font(10)).foregroundStyle(WatchTheme.secondary).padding(.top, 14)
Text(comparison.frenchLabel).foregroundStyle(WatchTheme.accent) Spacer(minLength: 12)
} Button("Fermer") {
.font(.caption) if !WatchDesignMode.enabled { model.measurement.resetAfterResult() }
} dismiss()
Text("Mesure optique de bien-être, non médicale. Aucun indice PRV n’est calculé sur la Watch.") }.buttonStyle(WatchPillStyle())
.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)
} }
} }
} }
+136 -44
View File
@@ -5,7 +5,7 @@ struct WatchRootView: View {
var body: some View { var body: some View {
Group { Group {
if model.hasCompletedOnboarding { if WatchDesignMode.enabled || model.hasCompletedOnboarding {
WatchHomeView() WatchHomeView()
} else { } else {
WatchOnboardingView() WatchOnboardingView()
@@ -47,68 +47,160 @@ struct WatchOnboardingView: View {
} }
} }
} }
.buttonStyle(.borderedProminent) .buttonStyle(WatchPillStyle(primary: true))
Button("Plus tard") { model.hasCompletedOnboarding = true }
.font(.system(size: 11)).buttonStyle(.plain).foregroundStyle(WatchTheme.secondary)
} }
.padding(.horizontal, 6) .padding(.horizontal, 6)
.background(WatchTheme.background.ignoresSafeArea()) .background(WatchTheme.background.ignoresSafeArea())
} }
private func onboardingPage(icon: String, title: String, text: String) -> some View { private func onboardingPage(icon: String, title: String, text: String) -> some View {
VStack(spacing: 10) { ScrollView { VStack(spacing: 10) {
Image(systemName: icon).font(.system(size: 34)).foregroundStyle(WatchTheme.accent) Image(systemName: icon).font(.system(size: 34)).foregroundStyle(WatchTheme.accent)
Text(title).font(.headline).multilineTextAlignment(.center) Text(title).font(.headline).multilineTextAlignment(.center)
Text(text).font(.caption).foregroundStyle(WatchTheme.secondary).multilineTextAlignment(.center) Text(text).font(.system(size: 12)).foregroundStyle(WatchTheme.secondary).multilineTextAlignment(.center).fixedSize(horizontal: false, vertical: true)
} }.padding(.horizontal, 6) }
} }
} }
enum WatchFigmaScreen: String, Hashable, CaseIterable {
case home, measure, result, breathe, prepare, breathing, anchor, anchoring, meditate, meditation, sleep, wake, face
}
struct WatchHomeView: View { struct WatchHomeView: View {
@EnvironmentObject private var model: WatchAppModel @State private var page = 0
@State private var path: [WatchFigmaScreen] = WatchDesignMode.initialScreen.flatMap { $0 == .home ? nil : [$0] } ?? []
var body: some View { var body: some View {
NavigationStack { NavigationStack(path: $path) {
List { TabView(selection: $page) {
Section { dashboard.tag(0)
NavigationLink(value: WatchHomeDestination.measure) { homeRow("Mesurer", "waveform.path.ecg", WatchTheme.mint) } WatchRoutineList(category: .breathe).tag(1)
NavigationLink(value: WatchHomeDestination.coherence) { homeRow("Ma Cohérence", "circle.dotted", WatchTheme.accent) } WatchRoutineList(category: .anchor).tag(2)
NavigationLink(value: WatchHomeDestination.exercises) { homeRow("Exercices", "wind", .cyan) } WatchRoutineList(category: .meditate).tag(3)
NavigationLink(value: WatchHomeDestination.cardiacMirror) { homeRow("Miroir cardiaque", "heart.circle", .pink) } WatchWakeView(onStop: { page = 0 }).tag(4)
NavigationLink(value: WatchHomeDestination.history) { homeRow("Historique", "chart.xyaxis.line", .indigo) } WatchFacePreview().tag(5)
} 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()
}
} }
.tabViewStyle(.verticalPage)
.navigationDestination(for: WatchFigmaScreen.self) { WatchFigmaDestination(screen: $0) }
.haptikaScreen() .haptikaScreen()
.toolbar(.hidden, for: .navigationBar)
.persistentSystemOverlays(.hidden)
} }
} }
private func homeRow(_ title: String, _ icon: String, _ color: Color) -> some View { private var dashboard: some View {
Label { WatchFigmaCanvas {
Text(title).font(.subheadline.weight(.semibold)) VStack(spacing: 9) {
} icon: { WatchEyebrow(title: "mon état actuel")
Image(systemName: icon).foregroundStyle(color) ZStack {
Circle().stroke(.white.opacity(0.05), lineWidth: 4)
VStack(spacing: 8) {
HStack(alignment: .firstTextBaseline, spacing: 0) {
Text(WatchDesignMode.enabled ? "72" : "—")
.font(WatchTheme.font(40, weight: .bold)).tracking(-2.4)
Text("%").font(WatchTheme.font(10, weight: .bold))
}
WatchBalanceAxis(compact: true, position: WatchDesignMode.enabled ? 0.72 : nil)
}
}.frame(width: 99, height: 99).padding(.vertical, 2)
NavigationLink(value: WatchFigmaScreen.measure) {
Text("Mesurer la VCF".uppercased())
}.buttonStyle(WatchPillStyle(primary: true, height: 29, fontSize: 10))
NavigationLink(value: WatchFigmaScreen.measure) {
Text("Actualiser mon état".uppercased())
}.buttonStyle(WatchPillStyle(primary: true, height: 29, fontSize: 10))
}.padding(.horizontal, 24).padding(.top, 14).padding(.bottom, 2)
}
}
}
struct WatchFigmaDestination: View {
let screen: WatchFigmaScreen
var body: some View {
switch screen {
case .home: WatchHomeView()
case .measure: WatchMeasurementView()
case .result: WatchFigmaCanvas { WatchMeasurementResultView().padding(.horizontal, 20) }
case .breathe: WatchRoutineList(category: .breathe)
case .prepare: WatchRoutineView(routine: WatchRoutineCategory.breathe.routines[0])
case .breathing: WatchRoutineView(routine: WatchRoutineCategory.breathe.routines[0], initiallyRunning: true)
case .anchor: WatchRoutineList(category: .anchor)
case .anchoring: WatchRoutineView(routine: WatchRoutineCategory.anchor.routines[0])
case .meditate: WatchRoutineList(category: .meditate)
case .meditation: WatchRoutineView(routine: WatchRoutineCategory.meditate.routines[0])
case .sleep: WatchRoutineView(routine: WatchRoutineCategory.anchor.routines[4])
case .wake: WatchWakeView()
case .face: WatchFacePreview()
}
}
}
struct WatchWakeView: View {
@Environment(\.dismiss) private var dismiss
var onStop: (() -> Void)?
var body: some View {
WatchFigmaCanvas(background: WatchTheme.night) {
VStack(spacing: 12) {
Text("RÉVEIL").font(.system(size: 12, weight: .semibold)).tracking(1.2).foregroundStyle(WatchTheme.secondary.opacity(0.8))
Spacer(minLength: 0)
Group {
if WatchDesignMode.enabled {
Text("7:12")
} else {
TimelineView(.periodic(from: .now, by: 60)) { context in Text(context.date, style: .time) }
}
}.font(.system(size: 44, weight: .bold)).monospacedDigit()
.background { WatchGlow() }
Spacer(minLength: 0)
Button { if let onStop { onStop() } else { dismiss() } } label: {
HStack(spacing: 8) {
Image("WatchStop").resizable().scaledToFit().frame(width: 20, height: 20)
Text("Arrêter")
}
}.buttonStyle(WatchPillStyle(destructive: true, height: 48))
}.padding(.horizontal, 16).padding(.vertical, 10)
}.ignoresSafeArea(.container, edges: .bottom)
}
}
struct WatchFacePreview: View {
var body: some View {
WatchFigmaCanvas {
VStack(spacing: 0) {
Group {
if WatchDesignMode.enabled { Text("10:09") }
else { TimelineView(.periodic(from: .now, by: 60)) { context in Text(context.date, style: .time) } }
}.font(.system(size: 32, weight: .bold))
Text("HAPTIKA").font(.system(size: 10, weight: .light)).tracking(1).foregroundStyle(WatchTheme.accent)
Spacer(minLength: 36)
HStack {
NavigationLink(value: WatchFigmaScreen.measure) {
VStack(spacing: 4) {
Text(WatchDesignMode.enabled ? "68" : "—").font(WatchTheme.font(18, weight: .bold))
.frame(width: 52, height: 52)
.background(WatchTheme.background, in: Circle())
.overlay(Circle().stroke(WatchTheme.accent.opacity(0.12), lineWidth: 2).padding(4))
.shadow(color: WatchTheme.accent.opacity(0.2), radius: 4)
Text("VFC").font(WatchTheme.font(9)).tracking(0.9).foregroundStyle(WatchTheme.secondary)
}
}
Spacer()
NavigationLink {
WatchRoutineView(routine: .init(title: "Sos Stress", protocolID: "sosStress", category: .anchor))
} label: {
VStack(spacing: 4) {
Image("WatchSOS").resizable().scaledToFit().frame(width: 21, height: 21)
.frame(width: 52, height: 52)
.background(Color(red: 0.957, green: 0.247, blue: 0.369).opacity(0.1), in: Circle())
.overlay(Circle().stroke(Color(red: 0.957, green: 0.247, blue: 0.369), lineWidth: 2))
.shadow(color: Color(red: 0.957, green: 0.247, blue: 0.369).opacity(0.5), radius: 6)
Text("SOS").font(WatchTheme.font(9)).tracking(0.9).foregroundStyle(Color(red: 0.957, green: 0.247, blue: 0.369))
}
}
}.buttonStyle(.plain)
}.padding(.horizontal, 23).padding(.vertical, 16).background { WatchGlow() }
} }
} }
} }
+142 -11
View File
@@ -1,29 +1,160 @@
import SwiftUI import SwiftUI
import CoreText
enum WatchTheme { enum WatchTheme {
static let background = Color(red: 0.015, green: 0.04, blue: 0.10) static func font(_ size: CGFloat, weight: Font.Weight = .regular) -> Font {
static let card = Color(red: 0.035, green: 0.12, blue: 0.22) .custom("Inter-Regular", size: size).weight(weight)
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) static func registerFonts() {
guard let url = Bundle.main.url(forResource: "Inter", withExtension: "ttf") else { return }
CTFontManagerRegisterFontsForURL(url as CFURL, .process, nil)
}
static let background = Color(hex: 0x0C141F)
static let night = Color(hex: 0x0A1019)
static let card = Color(hex: 0x151C27)
static let accent = Color("AccentColor")
static let mint = Color(hex: 0x79D3FF)
static let secondary = Color(hex: 0xBEC8CF)
static let text = Color(hex: 0xDCE3F3)
static let purple = Color(hex: 0xA78BFA)
static let pink = Color(hex: 0xE94697)
static let stop = Color(hex: 0xFFB4AB)
}
private extension Color {
init(hex: UInt32) {
self.init(red: Double((hex >> 16) & 255) / 255, green: Double((hex >> 8) & 255) / 255, blue: Double(hex & 255) / 255)
}
} }
struct WatchCard<Content: View>: View { struct WatchCard<Content: View>: View {
@ViewBuilder var content: Content @ViewBuilder var content: Content
var body: some View { var body: some View {
content content.padding(12).frame(maxWidth: .infinity, alignment: .leading)
.padding(10) .background(WatchTheme.card, in: RoundedRectangle(cornerRadius: 12))
.frame(maxWidth: .infinity, alignment: .leading) .overlay(RoundedRectangle(cornerRadius: 12).stroke(.white.opacity(0.08)))
.background(WatchTheme.card.opacity(0.82), in: RoundedRectangle(cornerRadius: 18))
.overlay(RoundedRectangle(cornerRadius: 18).stroke(WatchTheme.accent.opacity(0.22)))
} }
} }
struct WatchPillStyle: ButtonStyle {
var primary = false
var destructive = false
var height: CGFloat = 36
var fontSize: CGFloat = 14
func makeBody(configuration: Configuration) -> some View {
configuration.label
.font(destructive ? .system(size: fontSize) : WatchTheme.font(fontSize, weight: .semibold))
.foregroundStyle(destructive ? WatchTheme.stop : primary ? Color(red: 0, green: 0.208, blue: 0.282) : WatchTheme.text)
.frame(maxWidth: .infinity).frame(height: height)
.background(primary ? WatchTheme.accent : destructive ? .clear : Color(red: 0.18, green: 0.208, blue: 0.255), in: Capsule())
.overlay(Capsule().stroke(destructive ? WatchTheme.stop.opacity(0.3) : .clear))
.opacity(configuration.isPressed ? 0.65 : 1)
.contentShape(Capsule())
}
}
struct WatchMenuRow: View {
let title: String
var color: Color = WatchTheme.accent
var body: some View {
HStack {
Text(title).font(WatchTheme.font(16, weight: .medium)).fixedSize(horizontal: false, vertical: true)
Spacer(minLength: 4)
Image("WatchChevron").resizable().scaledToFit().frame(width: 5, height: 8).accessibilityHidden(true)
}
.foregroundStyle(WatchTheme.text)
.padding(.horizontal, 15).frame(minHeight: 50)
.background(LinearGradient(colors: [color.opacity(0.1), WatchTheme.card], startPoint: .leading, endPoint: .trailing))
.overlay(alignment: .leading) { Rectangle().fill(color).frame(width: 4) }
.clipShape(RoundedRectangle(cornerRadius: 12))
}
}
struct WatchGlow: View {
var color: Color = WatchTheme.accent
var body: some View {
Circle()
.fill(RadialGradient(colors: [color.opacity(0.24), color.opacity(0)], center: .center, startRadius: 0, endRadius: 100))
.frame(width: 210, height: 210)
.allowsHitTesting(false).accessibilityHidden(true)
}
}
struct WatchEyebrow: View {
let title: String
var body: some View {
Text(title.uppercased()).font(WatchTheme.font(12, weight: .semibold))
.tracking(0.6).foregroundStyle(WatchTheme.secondary).multilineTextAlignment(.center)
.lineLimit(1).minimumScaleFactor(0.7).fixedSize(horizontal: false, vertical: true)
}
}
func watchTime(_ seconds: TimeInterval) -> String {
let value = max(0, Int(seconds.rounded(.up)))
return String(format: "%02d:%02d", value / 60, value % 60)
}
extension View { extension View {
func haptikaScreen() -> some View { func haptikaScreen() -> some View {
scrollContentBackground(.hidden) scrollContentBackground(.hidden)
.background(WatchTheme.background.ignoresSafeArea()) .background(WatchTheme.background.ignoresSafeArea())
.foregroundStyle(WatchTheme.text)
.tint(WatchTheme.accent) .tint(WatchTheme.accent)
} }
} }
// Explicit Debug-only presentation fixtures. Never saved or sent to HealthKit.
enum WatchDesignMode {
static var enabled: Bool {
#if DEBUG && targetEnvironment(simulator)
ProcessInfo.processInfo.arguments.contains("--figma-preview")
#else
false
#endif
}
static var initialScreen: WatchFigmaScreen? {
guard enabled,
let index = ProcessInfo.processInfo.arguments.firstIndex(of: "--figma-screen"),
ProcessInfo.processInfo.arguments.indices.contains(index + 1) else { return nil }
return WatchFigmaScreen(rawValue: ProcessInfo.processInfo.arguments[index + 1])
}
static var frozen: Bool { initialScreen != nil }
}
struct WatchFigmaCanvas<Content: View>: View {
var background: Color = WatchTheme.background
@Environment(\.dismiss) private var dismiss
@ViewBuilder var content: Content
var body: some View {
GeometryReader { geometry in
content.frame(width: geometry.size.width, height: geometry.size.height)
}
.scrollContentBackground(.hidden)
.foregroundStyle(WatchTheme.text)
.tint(WatchTheme.accent)
.background(background.ignoresSafeArea())
.toolbar(.hidden, for: .navigationBar)
.persistentSystemOverlays(.hidden)
.accessibilityAction(.escape) { dismiss() }
}
}
struct WatchBalanceAxis: View {
var compact = false
var position: Double?
var body: some View {
HStack(spacing: 4) {
if compact { Circle().fill(Color(red: 0.957, green: 0.247, blue: 0.369)).frame(width: 6, height: 6) }
GeometryReader { geometry in
Capsule().fill(LinearGradient(colors: [compact ? WatchTheme.accent.opacity(0) : Color(red: 0.957, green: 0.247, blue: 0.369), WatchTheme.accent], startPoint: .leading, endPoint: .trailing))
if !compact, let position {
Capsule().fill(WatchTheme.text).frame(width: 4, height: 14)
.position(x: geometry.size.width * min(1, max(0, position)), y: geometry.size.height / 2)
.shadow(color: .white.opacity(0.5), radius: 4)
}
}.frame(width: compact ? 32 : 120, height: compact ? 4 : 6)
if compact { Circle().fill(WatchTheme.accent).frame(width: 6, height: 6) }
}.accessibilityHidden(true)
}
}
+54
View File
@@ -0,0 +1,54 @@
# Haptika Watch — contrôle de fidélité Figma
Source : https://www.figma.com/design/2FOpWaxKTfJi0ogQBNizXi/haptika?node-id=566-10144
Les 13 écrans de la section WATCH ont été relus via `get_design_context`. Les textes extraits du MCP sont conservés dans `corrected/figma-copy.json`. Les captures finales sont dans `corrected/` ; `corrected/all-screens.png` les rassemble.
## Corrections de fidélité
- Mesure : suppression de la préparation ajoutée. L’écran présente « ANALYSE », un anneau circulaire de 78 points avec le cœur exporté de Figma, « 01:00 », « Restez immobile » et « Fermer ». Le temps de recherche du signal ne remplace plus le minuteur de mesure par 00:30.
- Accueil : « MON ÉTAT ACTUEL », « MESURER LA VCF » et « ACTUALISER MON ÉTAT », conformément à la maquette, y compris son libellé VCF. Jauge circulaire, pourcentage et petit axe coloré rétablis.
- Résultat : grand pourcentage, dégradé cyan et axe horizontal du Figma. Suppression des cartes et questionnaires ajoutés.
- Respiration : libellés exacts, préparation avec son rectangle arrondi de la maquette, petit interrupteur, orbe circulaire, FOCUS / LIBRE et Terminer. Plus de texte « À votre rythme » ni d’écran « Séance terminée » ajouté.
- Ancrage : casse « Sos Stress », liste violette, session avec minuteur SF Pro et bouton Arrêter.
- Méditation : introduction centrée, sans duplication du titre, sans interrupteur ni bouton ajouté. Texte et retours à la ligne du Figma conservés, y compris « Ressenter ».
- Endormissement : écran sombre et bouton Arrêter. Réveil : heure et Arrêter. Cadran : heure, HAPTIKA, deux raccourcis ronds, VFC / SOS et icône SOS originale.
- Les halos sont dessinés dans une forme circulaire avec une fin transparente ; ils ne sont plus coupés par le rectangle de leur conteneur.
- Police Inter embarquée sous licence OFL pour les textes Inter ; police système pour les éléments SF Pro. Tailles, graisses, couleurs et boutons ajustés depuis les propriétés des nœuds.
- Les catégories sont accessibles par pages verticales depuis l’accueil. Les vues complémentaires non présentes dans la section WATCH ne sont plus ajoutées à ce parcours.
## Écrans contrôlés
| Capture | Nœud |
| --- | --- |
| home | 460:1606 |
| measure | 504:19 |
| result | 470:31 |
| breathe | 479:410 |
| prepare | 640:9 |
| breathing | 479:238 |
| anchor | 479:329 |
| anchoring | 479:479 |
| meditate | 480:543 |
| meditation | 481:666 |
| sleep | 486:729 |
| wake | 487:784 |
| face | 487:818 |
## Mode maquette et données réelles
Le lancement de contrôle utilise `--figma-preview`. Ce mode est limité à une compilation Debug sur simulateur. Il reprend les valeurs illustratives du Figma (72 %, 68, 7:12, 10:09), sans mesure, vibration, sauvegarde ni synchronisation. Il ne transforme jamais un BPM en pourcentage VFC.
`--figma-screen measure` (ou un nom de capture du tableau) ouvre directement l’écran et fige ses données pour la comparaison. Sans `--figma-screen`, le minuteur du parcours de démonstration décompte les 60 secondes.
Sur une vraie montre, l’acquisition cardiaque demeure reliée au service existant. En l’absence de source de score VFC, le pourcentage reste « — » et aucun qualificatif d’équilibre n’est inventé. Les protocoles non configurés, le réveil et les complications système restent des interfaces sans moteur associé. Ces limites sont documentées ici, sans ajout de texte aux maquettes.
## Validation
- Compilation Watch Debug réussie.
- 8 tests WatchCoreTests réussis, aucun échec.
- Comparaison visuelle des 13 captures sur Apple Watch Series 10 46 mm, watchOS 11.5.
- L’heure système watchOS apparaît au-dessus de l’interface ; les maquettes Figma ne la dessinent pas. Les marges évitent son chevauchement avec les titres.
- Les écrans iPhone et le manifeste de protocoles partagé ne sont pas modifiés.
Les captures à la racine de ce dossier proviennent de la première intégration ; celles de `corrected/` font foi pour cette correction.
Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 360 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

+83
View File
@@ -0,0 +1,83 @@
{
"460:1606": [
"mon état actuel",
"72",
"%",
"Mesurer la VCF",
"Actualiser mon état"
],
"504:19": [
"Analyse",
"01:00",
"Restez immobile",
"Fermer"
],
"470:31": [
"RÉSULTAT",
"72",
"%",
"Équilibre favorable",
"Fermer"
],
"479:410": [
"Respirer",
"Cohérence",
"Calme express",
"Équilibre",
"4·7·8"
],
"640:9": [
"Cohérence Cardiaque",
"Prêt pour la séance ?",
"Mode écran noir",
"Commencer"
],
"479:238": [
"Inspirez",
"FOCUS",
"LIBRE",
"Terminer"
],
"479:329": [
"Ancrer",
"Micro-Ancrage",
"Sos Stress",
"Apaiser",
"Booster",
"Endormissement"
],
"479:479": [
"Session en cours",
"13:05",
"Arrêter"
],
"480:543": [
"Méditation",
"Découverte",
"Présence",
"Sensation",
"Atterrissage",
"Libre"
],
"481:666": [
"Découverte",
"Cinq minutes de vibrations",
"espacées. Ressenter,",
"sans rien modifier."
],
"486:729": [
"Arrêter"
],
"487:784": [
"RÉVEIL",
"7:12",
"Arrêter"
],
"487:818": [
"10:09",
"HAPTIKA",
"68",
"VFC",
"SOS"
]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 113 KiB