feat: watch design from figma
@@ -25,7 +25,9 @@
|
||||
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 */; };
|
||||
87D814A03C8826DC5243F782 /* Inter.ttf in Resources */ = {isa = PBXBuildFile; fileRef = B4AE4816A2AE68CA9E8E1CB0 /* Inter.ttf */; };
|
||||
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 */; };
|
||||
A03E880ED4913D3E67FA728C /* WatchHapticService.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA7A2A9E8A782CA3D7F79124 /* WatchHapticService.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; };
|
||||
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>"; };
|
||||
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; };
|
||||
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>"; };
|
||||
@@ -98,6 +101,7 @@
|
||||
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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
@@ -204,6 +208,8 @@
|
||||
A378E27FE3D44AD2EEF64FC9 /* HaptikaShared */,
|
||||
47085F82A7AB6010AA4FB195 /* HaptikaWatchTests */,
|
||||
E900FA2623A3C868FA97EFEE /* HaptikaWatch */,
|
||||
B4AE4816A2AE68CA9E8E1CB0 /* Inter.ttf */,
|
||||
769A9666C8F131FA0A875FA1 /* OFL.txt */,
|
||||
);
|
||||
indentWidth = 2;
|
||||
sourceTree = "<group>";
|
||||
@@ -418,6 +424,8 @@
|
||||
files = (
|
||||
2754CCE8D2EB3703FA37D055 /* Assets.xcassets in Resources */,
|
||||
114BDE84D8AA9241891F063A /* regulation-protocols.v1.json in Resources */,
|
||||
87D814A03C8826DC5243F782 /* Inter.ttf in Resources */,
|
||||
9125FED2B6DD2C0D898E181F /* OFL.txt in Resources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
@@ -645,6 +653,7 @@
|
||||
DEVELOPMENT_TEAM = R537R56465;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.sachagaulin.haptika.r537r56465.dev.watchtests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = watchos;
|
||||
SUPPORTED_PLATFORMS = "watchos watchsimulator";
|
||||
SWIFT_VERSION = 5.0;
|
||||
@@ -849,6 +858,7 @@
|
||||
DEVELOPMENT_TEAM = R537R56465;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.sachagaulin.haptika.r537r56465.dev.watchtests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = watchos;
|
||||
SUPPORTED_PLATFORMS = "watchos watchsimulator";
|
||||
SWIFT_VERSION = 5.0;
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "EA92A2DC9365C1414126C0B1"
|
||||
BuildableName = ".xctest"
|
||||
BuildableName = "HaptikaWatchTests.xctest"
|
||||
BlueprintName = "HaptikaWatchTests"
|
||||
ReferencedContainer = "container:HaptikaApp.xcodeproj">
|
||||
</BuildableReference>
|
||||
|
||||
@@ -4,11 +4,13 @@ import SwiftUI
|
||||
struct HaptikaWatchApp: App {
|
||||
@StateObject private var model = WatchAppModel()
|
||||
|
||||
init() { WatchTheme.registerFonts() }
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
WatchRootView()
|
||||
.environmentObject(model)
|
||||
.task { model.start() }
|
||||
.task { if !WatchDesignMode.enabled { model.start() } }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,8 @@ public final class WatchAppModel: ObservableObject {
|
||||
public let haptics: WatchHapticService
|
||||
public let connectivity: WatchConnectivityService
|
||||
|
||||
private var serviceSubscriptions = Set<AnyCancellable>()
|
||||
|
||||
private let defaults: UserDefaults
|
||||
private let onboardingKey = "haptika.watch.onboarding.completed.v1"
|
||||
private let deviceIDKey = "haptika.watch.device.id.v1"
|
||||
@@ -82,6 +84,11 @@ public final class WatchAppModel: ObservableObject {
|
||||
}
|
||||
|
||||
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.onLiveHeartRate = { [weak self] sample in
|
||||
guard let self,
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
{
|
||||
"colors" : [
|
||||
"colors": [
|
||||
{
|
||||
"color" : {
|
||||
"color-space" : "srgb",
|
||||
"components" : {
|
||||
"alpha" : "1.000",
|
||||
"blue" : "0.922",
|
||||
"green" : "0.741",
|
||||
"red" : "0.314"
|
||||
"idiom": "universal",
|
||||
"color": {
|
||||
"color-space": "srgb",
|
||||
"components": {
|
||||
"red": "0.290196",
|
||||
"green": "0.721569",
|
||||
"blue": "0.909804",
|
||||
"alpha": "1.000"
|
||||
}
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
"info": {
|
||||
"author": "xcode",
|
||||
"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 |
@@ -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.
|
||||
@@ -115,24 +115,29 @@ struct WatchCoherenceView: View {
|
||||
struct BreathOrb: View {
|
||||
let regulation: WatchRegulationProtocol
|
||||
let startedAt: Date
|
||||
var showsLabel = true
|
||||
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
||||
|
||||
var body: some View {
|
||||
TimelineView(.animation(minimumInterval: 1 / 20)) { context in
|
||||
let elapsed = context.date.timeIntervalSince(startedAt)
|
||||
TimelineView(.animation(minimumInterval: reduceMotion ? 1 : 1 / 20)) { context in
|
||||
let elapsed = WatchDesignMode.frozen ? 0 : 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) {
|
||||
let scale = position?.phase.kind == .inhale ? 0.65 + fraction * 0.35 : position?.phase.kind == .exhale ? 1 - fraction * 0.35 : 1
|
||||
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 {
|
||||
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)
|
||||
Circle().fill(WatchTheme.accent.opacity(0.2)).blur(radius: 6)
|
||||
Circle().fill(WatchTheme.accent.opacity(0.8))
|
||||
.overlay(Circle().stroke(WatchTheme.accent.opacity(0.3)))
|
||||
Circle().fill(.white.opacity(0.8)).frame(width: 8, height: 8)
|
||||
}
|
||||
.frame(width: 68, height: 68).scaleEffect(reduceMotion || WatchDesignMode.frozen ? 1 : scale)
|
||||
.frame(height: 68).accessibilityHidden(true)
|
||||
}.frame(maxWidth: .infinity).background { WatchGlow() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,102 +1,257 @@
|
||||
import SwiftUI
|
||||
|
||||
struct WatchExerciseListView: View {
|
||||
private var hapticProtocols: [WatchRegulationProtocol] {
|
||||
WatchProtocolCatalog.protocols.filter { $0.mode == .haptic }
|
||||
enum WatchRoutineCategory: String, CaseIterable, Identifiable {
|
||||
case breathe, anchor, meditate
|
||||
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 asset: String { switch self { case .breathe: "WatchBreath"; case .anchor: "WatchAnchor"; case .meditate: "WatchMeditation" } }
|
||||
var routines: [WatchRoutine] {
|
||||
switch self {
|
||||
case .breathe:
|
||||
return [WatchRoutine(title: "Cohérence", protocolID: "coherence55", category: self),
|
||||
WatchRoutine(title: "Calme express", protocolID: "relax46", category: self),
|
||||
WatchRoutine(title: "Équilibre", protocolID: "box4444", category: self),
|
||||
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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
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)
|
||||
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)
|
||||
}
|
||||
}
|
||||
.navigationTitle("Exercices")
|
||||
.haptikaScreen()
|
||||
}.padding(.horizontal, 16).padding(.top, 36).padding(.bottom, 12)
|
||||
}.scrollIndicators(.hidden).contentMargins(.vertical, 0, for: .scrollContent)
|
||||
.ignoresSafeArea(.container, edges: .top) }
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
let regulation: WatchRegulationProtocol
|
||||
let routine: WatchRoutine
|
||||
var initiallyRunning = false
|
||||
@State private var startedAt: Date?
|
||||
@State private var errorMessage: String?
|
||||
@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 {
|
||||
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) }
|
||||
WatchFigmaCanvas(background: routine.isSleep ? WatchTheme.night : WatchTheme.background) {
|
||||
ZStack {
|
||||
if routine.category == .meditate {
|
||||
meditation
|
||||
} else if routine.category == .anchor || startedAt != nil {
|
||||
session
|
||||
} 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)
|
||||
preparation
|
||||
}
|
||||
.font(.caption)
|
||||
if screenHidden {
|
||||
Color.black.ignoresSafeArea()
|
||||
Button { screenHidden = false } label: {
|
||||
Color.black.frame(maxWidth: .infinity, maxHeight: .infinity).contentShape(Rectangle())
|
||||
}.buttonStyle(.plain).accessibilityLabel("Afficher les commandes de la séance")
|
||||
}
|
||||
if let errorMessage { Text(errorMessage).font(.caption2).foregroundStyle(.orange) }
|
||||
}
|
||||
.padding(.horizontal, 4)
|
||||
}
|
||||
.navigationTitle(regulation.title)
|
||||
.haptikaScreen()
|
||||
.ignoresSafeArea(.container, edges: routine.isSleep ? .bottom : [])
|
||||
.task {
|
||||
if initiallyRunning || routine.category == .anchor { await start() }
|
||||
}
|
||||
.alert(errorMessage ?? "", isPresented: Binding(get: { errorMessage != nil }, set: { if !$0 { errorMessage = nil } })) {
|
||||
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 {
|
||||
if model.haptics.isRunning {
|
||||
guard startedAt != nil, !finished, !WatchDesignMode.enabled else { return }
|
||||
saveSession(state: .cancelled)
|
||||
model.haptics.cancel()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func start() {
|
||||
let now = Date()
|
||||
Task {
|
||||
private var preparation: some View {
|
||||
VStack(spacing: 0) {
|
||||
WatchEyebrow(title: routine.title == "Cohérence" ? "Cohérence Cardiaque" : routine.title)
|
||||
.tracking(-0.6)
|
||||
Spacer(minLength: 14)
|
||||
VStack(spacing: 5) {
|
||||
Image("WatchPrepare").resizable().scaledToFit().frame(width: 17, height: 14)
|
||||
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 = now
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
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) {
|
||||
guard !sessionSaved, let startedAt else { return }
|
||||
guard !WatchDesignMode.enabled, !sessionSaved, let startedAt, let regulation = routine.regulation else { return }
|
||||
sessionSaved = true
|
||||
model.enqueueSession(.init(
|
||||
protocolID: regulation.id,
|
||||
startedAt: startedAt,
|
||||
endedAt: Date(),
|
||||
durationSeconds: model.haptics.elapsedSeconds,
|
||||
state: state
|
||||
))
|
||||
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")"
|
||||
struct WatchCompactToggleStyle: ToggleStyle {
|
||||
func makeBody(configuration: Configuration) -> some View {
|
||||
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é")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,114 +2,124 @@ import SwiftUI
|
||||
|
||||
struct WatchMeasurementView: View {
|
||||
@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 {
|
||||
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):
|
||||
WatchFigmaCanvas {
|
||||
Group {
|
||||
if previewCompleted {
|
||||
WatchMeasurementResultView()
|
||||
} else if case let .completed(summary) = model.measurement.state, !WatchDesignMode.enabled {
|
||||
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() }
|
||||
} else {
|
||||
TimelineView(.periodic(from: .now, by: 1)) { context in
|
||||
let seconds = remainingSeconds(at: context.date)
|
||||
VStack(spacing: 0) {
|
||||
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, 4)
|
||||
}
|
||||
.navigationTitle("Mesurer")
|
||||
.haptikaScreen()
|
||||
}.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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
private func remainingSeconds(at date: Date) -> Int {
|
||||
if WatchDesignMode.enabled {
|
||||
guard !WatchDesignMode.frozen, let previewStartedAt else { return 60 }
|
||||
return max(0, 60 - Int(date.timeIntervalSince(previewStartedAt)))
|
||||
}
|
||||
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 func startMeasurement() async {
|
||||
if WatchDesignMode.enabled { previewStartedAt = Date(); return }
|
||||
do {
|
||||
model.measurement.resetAfterResult()
|
||||
try await model.measurement.requestAuthorization()
|
||||
guard !Task.isCancelled else { return }
|
||||
try await model.measurement.start()
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
}
|
||||
} catch { errorMessage = error.localizedDescription }
|
||||
}
|
||||
|
||||
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 func close() {
|
||||
if !WatchDesignMode.enabled { model.measurement.cancel(); model.measurement.resetAfterResult() }
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
private var cancelButton: some View {
|
||||
Button("Arrêter", role: .cancel) { model.measurement.cancel() }
|
||||
.buttonStyle(.bordered)
|
||||
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 {
|
||||
@EnvironmentObject private var model: WatchAppModel
|
||||
let summary: WatchMeasurementSummaryV1
|
||||
|
||||
private var comparison: WatchBaselineComparison {
|
||||
model.store.baseline.compare(bpm: summary.averageBPM)
|
||||
}
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
var summary: WatchMeasurementSummaryV1? = nil
|
||||
|
||||
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)
|
||||
VStack(spacing: 0) {
|
||||
WatchEyebrow(title: "RÉSULTAT")
|
||||
Spacer(minLength: 12)
|
||||
HStack(alignment: .firstTextBaseline, spacing: 0) {
|
||||
Text(WatchDesignMode.enabled ? "72" : "—")
|
||||
.font(WatchTheme.font(64, weight: .bold)).tracking(-3.2)
|
||||
.foregroundStyle(LinearGradient(colors: [WatchTheme.mint, WatchTheme.accent], startPoint: .topLeading, endPoint: .bottomTrailing))
|
||||
Text("%").font(WatchTheme.font(14, weight: .medium)).foregroundStyle(WatchTheme.mint.opacity(0.8))
|
||||
}.foregroundStyle(WatchTheme.accent).shadow(color: WatchTheme.accent.opacity(0.5), radius: 5)
|
||||
WatchBalanceAxis(position: WatchDesignMode.enabled ? 0.77 : nil).padding(.top, 14)
|
||||
Text(WatchDesignMode.enabled ? "Équilibre favorable" : "")
|
||||
.font(WatchTheme.font(10)).foregroundStyle(WatchTheme.secondary).padding(.top, 14)
|
||||
Spacer(minLength: 12)
|
||||
Button("Fermer") {
|
||||
if !WatchDesignMode.enabled { model.measurement.resetAfterResult() }
|
||||
dismiss()
|
||||
}.buttonStyle(WatchPillStyle())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ struct WatchRootView: View {
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if model.hasCompletedOnboarding {
|
||||
if WatchDesignMode.enabled || model.hasCompletedOnboarding {
|
||||
WatchHomeView()
|
||||
} else {
|
||||
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)
|
||||
.background(WatchTheme.background.ignoresSafeArea())
|
||||
}
|
||||
|
||||
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)
|
||||
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 {
|
||||
@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 {
|
||||
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()
|
||||
}
|
||||
NavigationStack(path: $path) {
|
||||
TabView(selection: $page) {
|
||||
dashboard.tag(0)
|
||||
WatchRoutineList(category: .breathe).tag(1)
|
||||
WatchRoutineList(category: .anchor).tag(2)
|
||||
WatchRoutineList(category: .meditate).tag(3)
|
||||
WatchWakeView(onStop: { page = 0 }).tag(4)
|
||||
WatchFacePreview().tag(5)
|
||||
}
|
||||
.tabViewStyle(.verticalPage)
|
||||
.navigationDestination(for: WatchFigmaScreen.self) { WatchFigmaDestination(screen: $0) }
|
||||
.haptikaScreen()
|
||||
.toolbar(.hidden, for: .navigationBar)
|
||||
.persistentSystemOverlays(.hidden)
|
||||
}
|
||||
}
|
||||
|
||||
private func homeRow(_ title: String, _ icon: String, _ color: Color) -> some View {
|
||||
Label {
|
||||
Text(title).font(.subheadline.weight(.semibold))
|
||||
} icon: {
|
||||
Image(systemName: icon).foregroundStyle(color)
|
||||
private var dashboard: some View {
|
||||
WatchFigmaCanvas {
|
||||
VStack(spacing: 9) {
|
||||
WatchEyebrow(title: "mon état actuel")
|
||||
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() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,29 +1,160 @@
|
||||
import SwiftUI
|
||||
import CoreText
|
||||
|
||||
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)
|
||||
static func font(_ size: CGFloat, weight: Font.Weight = .regular) -> Font {
|
||||
.custom("Inter-Regular", size: size).weight(weight)
|
||||
}
|
||||
|
||||
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 {
|
||||
@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)))
|
||||
content.padding(12).frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(WatchTheme.card, in: RoundedRectangle(cornerRadius: 12))
|
||||
.overlay(RoundedRectangle(cornerRadius: 12).stroke(.white.opacity(0.08)))
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
func haptikaScreen() -> some View {
|
||||
scrollContentBackground(.hidden)
|
||||
.background(WatchTheme.background.ignoresSafeArea())
|
||||
.foregroundStyle(WatchTheme.text)
|
||||
.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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
After Width: | Height: | Size: 63 KiB |
|
After Width: | Height: | Size: 360 KiB |
|
After Width: | Height: | Size: 120 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 121 KiB |
|
After Width: | Height: | Size: 86 KiB |
|
After Width: | Height: | Size: 106 KiB |
@@ -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"
|
||||
]
|
||||
}
|
||||
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 121 KiB |
|
After Width: | Height: | Size: 96 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 52 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 84 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 113 KiB |