Commit fb3eedec9456d26ba3b7e36b4092b342fea7ee7e

Authored by 权海
1 parent 1393e4bf

feat(ui):iOS原生项目接入runner

feat(ui):模拟器可以运行了

xx
Showing 100 changed files with 2816 additions and 17 deletions

Too many changes to show.

To preserve performance only 100 of 100+ files are displayed.

@@ -11,7 +11,7 @@ @@ -11,7 +11,7 @@
11 .svn/ 11 .svn/
12 .swiftpm/ 12 .swiftpm/
13 migrate_working_dir/ 13 migrate_working_dir/
14 -# SDK/ 14 +SDK/
15 15
16 # IntelliJ related 16 # IntelliJ related
17 *.iml 17 *.iml
@@ -5,10 +5,25 @@ @@ -5,10 +5,25 @@
5 "version": "0.2.0", 5 "version": "0.2.0",
6 "configurations": [ 6 "configurations": [
7 { 7 {
8 - "name": "Flutter", 8 + "name": "Flutter-release",
9 "type": "dart", 9 "type": "dart",
10 "request": "launch", 10 "request": "launch",
11 - "program": "lib/main.dart" 11 + "program": "lib/main.dart",
  12 + "flutterMode": "release"
12 }, 13 },
  14 + {
  15 + "name": "Flutter-profile",
  16 + "type": "dart",
  17 + "request": "launch",
  18 + "program": "lib/main.dart",
  19 + "flutterMode": "profile"
  20 + },
  21 + {
  22 + "name": "Flutter-debug",
  23 + "type": "dart",
  24 + "request": "launch",
  25 + "program": "lib/main.dart",
  26 + "flutterMode": "debug"
  27 + }
13 ] 28 ]
14 } 29 }
1 -platform :ios, '13.0' 1 +platform :ios, '17.0'
2 2
3 # CocoaPods analytics sends network stats synchronously affecting flutter build latency. 3 # CocoaPods analytics sends network stats synchronously affecting flutter build latency.
4 ENV['COCOAPODS_DISABLE_STATS'] = 'true' 4 ENV['COCOAPODS_DISABLE_STATS'] = 'true'
@@ -28,14 +28,24 @@ flutter_ios_podfile_setup @@ -28,14 +28,24 @@ flutter_ios_podfile_setup
28 28
29 target 'Runner' do 29 target 'Runner' do
30 use_frameworks! 30 use_frameworks!
31 - use_modular_headers!  
32 - 31 +
  32 + pod 'SDWebImage'
  33 + pod 'ThinkingSDK'
  34 +
33 flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__)) 35 flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__))
34 - target 'RunnerTests' do  
35 - inherit! :search_paths  
36 - end 36 +
37 end 37 end
38 38
  39 +target 'Runner Watch App' do
  40 + platform :watchos, '10.0'
  41 + use_frameworks!
  42 +
  43 + pod 'SDWebImage'
  44 + pod 'SDWebImageSwiftUI'
  45 + pod 'ThinkingDataAnalyticsExtension'
  46 +end
  47 +
  48 +
39 post_install do |installer| 49 post_install do |installer|
40 installer.pods_project.targets.each do |target| 50 installer.pods_project.targets.each do |target|
41 flutter_additional_ios_build_settings(target) 51 flutter_additional_ios_build_settings(target)
@@ -12,12 +12,36 @@ PODS: @@ -12,12 +12,36 @@ PODS:
12 - FlutterMacOS 12 - FlutterMacOS
13 - permission_handler_apple (9.3.0): 13 - permission_handler_apple (9.3.0):
14 - Flutter 14 - Flutter
  15 + - SDWebImage (5.21.7):
  16 + - SDWebImage/Core (= 5.21.7)
  17 + - SDWebImage/Core (5.21.7)
  18 + - SDWebImageSwiftUI (3.1.4):
  19 + - SDWebImage (~> 5.21.1)
15 - shared_preferences_foundation (0.0.1): 20 - shared_preferences_foundation (0.0.1):
16 - Flutter 21 - Flutter
17 - FlutterMacOS 22 - FlutterMacOS
18 - sqflite_darwin (0.0.4): 23 - sqflite_darwin (0.0.4):
19 - Flutter 24 - Flutter
20 - FlutterMacOS 25 - FlutterMacOS
  26 + - ThinkingDataAnalyticsExtension (1.1.3):
  27 + - ThinkingDataCore (= 1.3.3)
  28 + - ThinkingDataCore (1.3.3):
  29 + - ThinkingDataCore/Main (= 1.3.3)
  30 + - ThinkingDataCore/iOS (1.3.3)
  31 + - ThinkingDataCore/Main (1.3.3):
  32 + - ThinkingDataCore/iOS
  33 + - ThinkingDataCore/OSX
  34 + - ThinkingDataCore/tvOS
  35 + - ThinkingDataCore/versionOS
  36 + - ThinkingDataCore/watchOS
  37 + - ThinkingDataCore/watchOS (1.3.3)
  38 + - ThinkingSDK (3.4.0):
  39 + - ThinkingSDK/Main (= 3.4.0)
  40 + - ThinkingSDK/iOS (3.4.0):
  41 + - ThinkingDataCore (= 1.3.3)
  42 + - ThinkingSDK/Main (3.4.0):
  43 + - ThinkingSDK/iOS
  44 + - ThinkingSDK/OSX
21 - TOCropViewController (2.7.4) 45 - TOCropViewController (2.7.4)
22 - webview_flutter_wkwebview (0.0.1): 46 - webview_flutter_wkwebview (0.0.1):
23 - Flutter 47 - Flutter
@@ -30,12 +54,21 @@ DEPENDENCIES: @@ -30,12 +54,21 @@ DEPENDENCIES:
30 - image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`) 54 - image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`)
31 - path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`) 55 - path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`)
32 - permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`) 56 - permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`)
  57 + - SDWebImage
  58 + - SDWebImageSwiftUI
33 - shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`) 59 - shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`)
34 - sqflite_darwin (from `.symlinks/plugins/sqflite_darwin/darwin`) 60 - sqflite_darwin (from `.symlinks/plugins/sqflite_darwin/darwin`)
  61 + - ThinkingDataAnalyticsExtension
  62 + - ThinkingSDK
35 - webview_flutter_wkwebview (from `.symlinks/plugins/webview_flutter_wkwebview/darwin`) 63 - webview_flutter_wkwebview (from `.symlinks/plugins/webview_flutter_wkwebview/darwin`)
36 64
37 SPEC REPOS: 65 SPEC REPOS:
38 trunk: 66 trunk:
  67 + - SDWebImage
  68 + - SDWebImageSwiftUI
  69 + - ThinkingDataAnalyticsExtension
  70 + - ThinkingDataCore
  71 + - ThinkingSDK
39 - TOCropViewController 72 - TOCropViewController
40 73
41 EXTERNAL SOURCES: 74 EXTERNAL SOURCES:
@@ -65,11 +98,16 @@ SPEC CHECKSUMS: @@ -65,11 +98,16 @@ SPEC CHECKSUMS:
65 image_picker_ios: 7fe1ff8e34c1790d6fff70a32484959f563a928a 98 image_picker_ios: 7fe1ff8e34c1790d6fff70a32484959f563a928a
66 path_provider_foundation: 080d55be775b7414fd5a5ef3ac137b97b097e564 99 path_provider_foundation: 080d55be775b7414fd5a5ef3ac137b97b097e564
67 permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d 100 permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d
  101 + SDWebImage: e9fc87c1aab89a8ab1bbd74eba378c6f53be8abf
  102 + SDWebImageSwiftUI: 965cdad5a7210b7ec06aaa1a3d17180d0a152250
68 shared_preferences_foundation: 9e1978ff2562383bd5676f64ec4e9aa8fa06a6f7 103 shared_preferences_foundation: 9e1978ff2562383bd5676f64ec4e9aa8fa06a6f7
69 sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0 104 sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0
  105 + ThinkingDataAnalyticsExtension: 06a9b27447ba1fc7b38d336f0b1807c682349983
  106 + ThinkingDataCore: 69334fda774daf150396514cfd79b90cdb0c0d16
  107 + ThinkingSDK: e114c65ad28ccfc11b9de84e902abbd73c69fc32
70 TOCropViewController: 80b8985ad794298fb69d3341de183f33d1853654 108 TOCropViewController: 80b8985ad794298fb69d3341de183f33d1853654
71 webview_flutter_wkwebview: 1821ceac936eba6f7984d89a9f3bcb4dea99ebb2 109 webview_flutter_wkwebview: 1821ceac936eba6f7984d89a9f3bcb4dea99ebb2
72 110
73 -PODFILE CHECKSUM: ade96bceabe3919b69c16573938e4268fe3a6c9d 111 +PODFILE CHECKSUM: 11b37be67b2ce5b4cca7654ac969f9b9789ac4bf
74 112
75 COCOAPODS: 1.16.2 113 COCOAPODS: 1.16.2
  1 +//
  2 +// AnimationImageView.swift
  3 +// hippo-watch Watch App
  4 +//
  5 +// Created by shihao on 2025/7/7.
  6 +//
  7 +
  8 +import SwiftUI
  9 +import Foundation
  10 +import UIKit
  11 +import ImageIO
  12 +
  13 +struct AnimatedGIFView: View {
  14 + let gifName: String
  15 + @State private var currentFrame = 0
  16 + @State private var frames: [UIImage] = []
  17 + @State private var timer: Timer?
  18 +
  19 + var body: some View {
  20 + Group {
  21 + if !frames.isEmpty {
  22 + Image(uiImage: frames[currentFrame])
  23 + .resizable()
  24 + .aspectRatio(contentMode: .fit)
  25 + .frame(maxWidth: .infinity, maxHeight: .infinity)
  26 + } else {
  27 + Text("Loading...")
  28 + }
  29 + }
  30 + .onChange(of: gifName) { _, _ in
  31 + stopAnimation()
  32 + loadFrames()
  33 + }
  34 + .onAppear {
  35 + loadFrames()
  36 + }
  37 + .onDisappear {
  38 + stopAnimation()
  39 + }
  40 + }
  41 +
  42 + private func loadFrames() {
  43 + guard let gifPath = Bundle.main.path(forResource: gifName, ofType: "gif"),
  44 + let gifData = NSData(contentsOfFile: gifPath),
  45 + let source = CGImageSourceCreateWithData(gifData, nil) else { return }
  46 +
  47 + let frameCount = CGImageSourceGetCount(source)
  48 + var loadedFrames: [UIImage] = []
  49 +
  50 + for i in 0..<frameCount {
  51 + if let cgImage = CGImageSourceCreateImageAtIndex(source, i, nil) {
  52 + let image = UIImage(cgImage: cgImage)
  53 + loadedFrames.append(image)
  54 + }
  55 + }
  56 +
  57 + frames = loadedFrames
  58 + if !frames.isEmpty {
  59 + startAnimation()
  60 + }
  61 + }
  62 +
  63 + private func startAnimation() {
  64 + timer?.invalidate()
  65 + timer = Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) { _ in
  66 + if !frames.isEmpty {
  67 + currentFrame = (currentFrame + 1) % frames.count
  68 + }
  69 + }
  70 + }
  71 +
  72 + private func stopAnimation() {
  73 + timer?.invalidate()
  74 + timer = nil
  75 + }
  76 +}
  77 +
  1 +{
  2 + "colors" : [
  3 + {
  4 + "idiom" : "universal"
  5 + }
  6 + ],
  7 + "info" : {
  8 + "author" : "xcode",
  9 + "version" : 1
  10 + }
  11 +}
  1 +{
  2 + "images" : [
  3 + {
  4 + "filename" : "doublefeelAppIcon.png",
  5 + "idiom" : "universal",
  6 + "platform" : "watchos",
  7 + "size" : "1024x1024"
  8 + },
  9 + {
  10 + "filename" : "doublefeelAppIcon.png",
  11 + "idiom" : "universal",
  12 + "platform" : "ios",
  13 + "size" : "1024x1024"
  14 + }
  15 + ],
  16 + "info" : {
  17 + "author" : "xcode",
  18 + "version" : 1
  19 + }
  20 +}
  1 +{
  2 + "info" : {
  3 + "author" : "xcode",
  4 + "version" : 1
  5 + }
  6 +}
  1 +{
  2 + "data" : [
  3 + {
  4 + "filename" : "lovenote_push.p12",
  5 + "idiom" : "universal"
  6 + }
  7 + ],
  8 + "info" : {
  9 + "author" : "xcode",
  10 + "version" : 1
  11 + }
  12 +}
@@ -2,22 +2,20 @@ @@ -2,22 +2,20 @@
2 "images" : [ 2 "images" : [
3 { 3 {
4 "idiom" : "universal", 4 "idiom" : "universal",
5 - "filename" : "LaunchImage.png",  
6 "scale" : "1x" 5 "scale" : "1x"
7 }, 6 },
8 { 7 {
  8 + "filename" : "missButtonIcon@2x.png",
9 "idiom" : "universal", 9 "idiom" : "universal",
10 - "filename" : "LaunchImage@2x.png",  
11 "scale" : "2x" 10 "scale" : "2x"
12 }, 11 },
13 { 12 {
14 "idiom" : "universal", 13 "idiom" : "universal",
15 - "filename" : "LaunchImage@3x.png",  
16 "scale" : "3x" 14 "scale" : "3x"
17 } 15 }
18 ], 16 ],
19 "info" : { 17 "info" : {
20 - "version" : 1,  
21 - "author" : "xcode" 18 + "author" : "xcode",
  19 + "version" : 1
22 } 20 }
23 } 21 }
  1 +{
  2 + "images" : [
  3 + {
  4 + "idiom" : "universal",
  5 + "scale" : "1x"
  6 + },
  7 + {
  8 + "filename" : "pkPageCrown@2x.png",
  9 + "idiom" : "universal",
  10 + "scale" : "2x"
  11 + },
  12 + {
  13 + "idiom" : "universal",
  14 + "scale" : "3x"
  15 + }
  16 + ],
  17 + "info" : {
  18 + "author" : "xcode",
  19 + "version" : 1
  20 + }
  21 +}
  1 +{
  2 + "images" : [
  3 + {
  4 + "idiom" : "universal",
  5 + "scale" : "1x"
  6 + },
  7 + {
  8 + "filename" : "pkPageSleepIcon.png",
  9 + "idiom" : "universal",
  10 + "scale" : "2x"
  11 + },
  12 + {
  13 + "idiom" : "universal",
  14 + "scale" : "3x"
  15 + }
  16 + ],
  17 + "info" : {
  18 + "author" : "xcode",
  19 + "version" : 1
  20 + }
  21 +}
  1 +{
  2 + "images" : [
  3 + {
  4 + "idiom" : "universal",
  5 + "scale" : "1x"
  6 + },
  7 + {
  8 + "filename" : "pulseDetailBg@2x.png",
  9 + "idiom" : "universal",
  10 + "scale" : "2x"
  11 + },
  12 + {
  13 + "idiom" : "universal",
  14 + "scale" : "3x"
  15 + }
  16 + ],
  17 + "info" : {
  18 + "author" : "xcode",
  19 + "version" : 1
  20 + }
  21 +}
  1 +{
  2 + "images" : [
  3 + {
  4 + "idiom" : "universal",
  5 + "scale" : "1x"
  6 + },
  7 + {
  8 + "filename" : "pulseDetailDietIcon@2x.png",
  9 + "idiom" : "universal",
  10 + "scale" : "2x"
  11 + },
  12 + {
  13 + "idiom" : "universal",
  14 + "scale" : "3x"
  15 + }
  16 + ],
  17 + "info" : {
  18 + "author" : "xcode",
  19 + "version" : 1
  20 + }
  21 +}
  1 +{
  2 + "images" : [
  3 + {
  4 + "idiom" : "universal",
  5 + "scale" : "1x"
  6 + },
  7 + {
  8 + "filename" : "pulseDetailSportIcon@2x.png",
  9 + "idiom" : "universal",
  10 + "scale" : "2x"
  11 + },
  12 + {
  13 + "idiom" : "universal",
  14 + "scale" : "3x"
  15 + }
  16 + ],
  17 + "info" : {
  18 + "author" : "xcode",
  19 + "version" : 1
  20 + }
  21 +}
  1 +{
  2 + "images" : [
  3 + {
  4 + "idiom" : "universal",
  5 + "scale" : "1x"
  6 + },
  7 + {
  8 + "filename" : "pulseDetailTitleBg@2x.png",
  9 + "idiom" : "universal",
  10 + "scale" : "2x"
  11 + },
  12 + {
  13 + "idiom" : "universal",
  14 + "scale" : "3x"
  15 + }
  16 + ],
  17 + "info" : {
  18 + "author" : "xcode",
  19 + "version" : 1
  20 + }
  21 +}
  1 +{
  2 + "images" : [
  3 + {
  4 + "idiom" : "universal",
  5 + "scale" : "1x"
  6 + },
  7 + {
  8 + "filename" : "pulseDetailVipBannerIcon@2x.png",
  9 + "idiom" : "universal",
  10 + "scale" : "2x"
  11 + },
  12 + {
  13 + "idiom" : "universal",
  14 + "scale" : "3x"
  15 + }
  16 + ],
  17 + "info" : {
  18 + "author" : "xcode",
  19 + "version" : 1
  20 + }
  21 +}
  1 +{
  2 + "images" : [
  3 + {
  4 + "idiom" : "universal",
  5 + "scale" : "1x"
  6 + },
  7 + {
  8 + "filename" : "pulseDetailZhongyiIcon@2x.png",
  9 + "idiom" : "universal",
  10 + "scale" : "2x"
  11 + },
  12 + {
  13 + "idiom" : "universal",
  14 + "scale" : "3x"
  15 + }
  16 + ],
  17 + "info" : {
  18 + "author" : "xcode",
  19 + "version" : 1
  20 + }
  21 +}
  1 +{
  2 + "images" : [
  3 + {
  4 + "idiom" : "universal",
  5 + "scale" : "1x"
  6 + },
  7 + {
  8 + "filename" : "pulseWaveformChen@2x.png",
  9 + "idiom" : "universal",
  10 + "scale" : "2x"
  11 + },
  12 + {
  13 + "idiom" : "universal",
  14 + "scale" : "3x"
  15 + }
  16 + ],
  17 + "info" : {
  18 + "author" : "xcode",
  19 + "version" : 1
  20 + }
  21 +}
  1 +{
  2 + "images" : [
  3 + {
  4 + "idiom" : "universal",
  5 + "scale" : "1x"
  6 + },
  7 + {
  8 + "filename" : "pulseWaveformChi@2x.png",
  9 + "idiom" : "universal",
  10 + "scale" : "2x"
  11 + },
  12 + {
  13 + "idiom" : "universal",
  14 + "scale" : "3x"
  15 + }
  16 + ],
  17 + "info" : {
  18 + "author" : "xcode",
  19 + "version" : 1
  20 + }
  21 +}
  1 +{
  2 + "images" : [
  3 + {
  4 + "idiom" : "universal",
  5 + "scale" : "1x"
  6 + },
  7 + {
  8 + "filename" : "pulseWaveformEmpty@2x.png",
  9 + "idiom" : "universal",
  10 + "scale" : "2x"
  11 + },
  12 + {
  13 + "idiom" : "universal",
  14 + "scale" : "3x"
  15 + }
  16 + ],
  17 + "info" : {
  18 + "author" : "xcode",
  19 + "version" : 1
  20 + }
  21 +}
  1 +{
  2 + "images" : [
  3 + {
  4 + "idiom" : "universal",
  5 + "scale" : "1x"
  6 + },
  7 + {
  8 + "filename" : "pulseWaveformFu@2x.png",
  9 + "idiom" : "universal",
  10 + "scale" : "2x"
  11 + },
  12 + {
  13 + "idiom" : "universal",
  14 + "scale" : "3x"
  15 + }
  16 + ],
  17 + "info" : {
  18 + "author" : "xcode",
  19 + "version" : 1
  20 + }
  21 +}
  1 +{
  2 + "images" : [
  3 + {
  4 + "idiom" : "universal",
  5 + "scale" : "1x"
  6 + },
  7 + {
  8 + "filename" : "pulseWaveformHua@2x.png",
  9 + "idiom" : "universal",
  10 + "scale" : "2x"
  11 + },
  12 + {
  13 + "idiom" : "universal",
  14 + "scale" : "3x"
  15 + }
  16 + ],
  17 + "info" : {
  18 + "author" : "xcode",
  19 + "version" : 1
  20 + }
  21 +}
  1 +{
  2 + "images" : [
  3 + {
  4 + "idiom" : "universal",
  5 + "scale" : "1x"
  6 + },
  7 + {
  8 + "filename" : "pulseWaveformPin@2x.png",
  9 + "idiom" : "universal",
  10 + "scale" : "2x"
  11 + },
  12 + {
  13 + "idiom" : "universal",
  14 + "scale" : "3x"
  15 + }
  16 + ],
  17 + "info" : {
  18 + "author" : "xcode",
  19 + "version" : 1
  20 + }
  21 +}
  1 +{
  2 + "images" : [
  3 + {
  4 + "idiom" : "universal",
  5 + "scale" : "1x"
  6 + },
  7 + {
  8 + "filename" : "pulseWaveformSe@2x.png",
  9 + "idiom" : "universal",
  10 + "scale" : "2x"
  11 + },
  12 + {
  13 + "idiom" : "universal",
  14 + "scale" : "3x"
  15 + }
  16 + ],
  17 + "info" : {
  18 + "author" : "xcode",
  19 + "version" : 1
  20 + }
  21 +}
  1 +{
  2 + "images" : [
  3 + {
  4 + "idiom" : "universal",
  5 + "scale" : "1x"
  6 + },
  7 + {
  8 + "filename" : "pulseWaveformShu@2x.png",
  9 + "idiom" : "universal",
  10 + "scale" : "2x"
  11 + },
  12 + {
  13 + "idiom" : "universal",
  14 + "scale" : "3x"
  15 + }
  16 + ],
  17 + "info" : {
  18 + "author" : "xcode",
  19 + "version" : 1
  20 + }
  21 +}
  1 +{
  2 + "images" : [
  3 + {
  4 + "idiom" : "universal",
  5 + "scale" : "1x"
  6 + },
  7 + {
  8 + "filename" : "pulseWaveformXu@2x.png",
  9 + "idiom" : "universal",
  10 + "scale" : "2x"
  11 + },
  12 + {
  13 + "idiom" : "universal",
  14 + "scale" : "3x"
  15 + }
  16 + ],
  17 + "info" : {
  18 + "author" : "xcode",
  19 + "version" : 1
  20 + }
  21 +}
  1 +{
  2 + "images" : [
  3 + {
  4 + "idiom" : "universal",
  5 + "scale" : "1x"
  6 + },
  7 + {
  8 + "filename" : "punchButtonIcon@2x.png",
  9 + "idiom" : "universal",
  10 + "scale" : "2x"
  11 + },
  12 + {
  13 + "idiom" : "universal",
  14 + "scale" : "3x"
  15 + }
  16 + ],
  17 + "info" : {
  18 + "author" : "xcode",
  19 + "version" : 1
  20 + }
  21 +}
  1 +{
  2 + "images" : [
  3 + {
  4 + "idiom" : "universal",
  5 + "scale" : "1x"
  6 + },
  7 + {
  8 + "filename" : "stickButtonIcon@2x.png",
  9 + "idiom" : "universal",
  10 + "scale" : "2x"
  11 + },
  12 + {
  13 + "idiom" : "universal",
  14 + "scale" : "3x"
  15 + }
  16 + ],
  17 + "info" : {
  18 + "author" : "xcode",
  19 + "version" : 1
  20 + }
  21 +}
  1 +{
  2 + "images" : [
  3 + {
  4 + "idiom" : "universal",
  5 + "scale" : "1x"
  6 + },
  7 + {
  8 + "filename" : "takePulseBg@2x.png",
  9 + "idiom" : "universal",
  10 + "scale" : "2x"
  11 + },
  12 + {
  13 + "idiom" : "universal",
  14 + "scale" : "3x"
  15 + }
  16 + ],
  17 + "info" : {
  18 + "author" : "xcode",
  19 + "version" : 1
  20 + }
  21 +}
  1 +{
  2 + "images" : [
  3 + {
  4 + "idiom" : "universal",
  5 + "scale" : "1x"
  6 + },
  7 + {
  8 + "filename" : "takePulseHelp@2x.png",
  9 + "idiom" : "universal",
  10 + "scale" : "2x"
  11 + },
  12 + {
  13 + "idiom" : "universal",
  14 + "scale" : "3x"
  15 + }
  16 + ],
  17 + "info" : {
  18 + "author" : "xcode",
  19 + "version" : 1
  20 + }
  21 +}
  1 +{
  2 + "images" : [
  3 + {
  4 + "idiom" : "universal",
  5 + "scale" : "1x"
  6 + },
  7 + {
  8 + "filename" : "takePulseIcon@2x.png",
  9 + "idiom" : "universal",
  10 + "scale" : "2x"
  11 + },
  12 + {
  13 + "idiom" : "universal",
  14 + "scale" : "3x"
  15 + }
  16 + ],
  17 + "info" : {
  18 + "author" : "xcode",
  19 + "version" : 1
  20 + }
  21 +}
  1 +{
  2 + "images" : [
  3 + {
  4 + "idiom" : "universal",
  5 + "scale" : "1x"
  6 + },
  7 + {
  8 + "filename" : "todayDataActivity@2x.png",
  9 + "idiom" : "universal",
  10 + "scale" : "2x"
  11 + },
  12 + {
  13 + "idiom" : "universal",
  14 + "scale" : "3x"
  15 + }
  16 + ],
  17 + "info" : {
  18 + "author" : "xcode",
  19 + "version" : 1
  20 + }
  21 +}
  1 +{
  2 + "images" : [
  3 + {
  4 + "idiom" : "universal",
  5 + "scale" : "1x"
  6 + },
  7 + {
  8 + "filename" : "todayDataHR@2x.png",
  9 + "idiom" : "universal",
  10 + "scale" : "2x"
  11 + },
  12 + {
  13 + "idiom" : "universal",
  14 + "scale" : "3x"
  15 + }
  16 + ],
  17 + "info" : {
  18 + "author" : "xcode",
  19 + "version" : 1
  20 + }
  21 +}
  1 +{
  2 + "images" : [
  3 + {
  4 + "idiom" : "universal",
  5 + "scale" : "1x"
  6 + },
  7 + {
  8 + "filename" : "todayDataHRV@2x.png",
  9 + "idiom" : "universal",
  10 + "scale" : "2x"
  11 + },
  12 + {
  13 + "idiom" : "universal",
  14 + "scale" : "3x"
  15 + }
  16 + ],
  17 + "info" : {
  18 + "author" : "xcode",
  19 + "version" : 1
  20 + }
  21 +}
  1 +{
  2 + "images" : [
  3 + {
  4 + "idiom" : "universal",
  5 + "scale" : "1x"
  6 + },
  7 + {
  8 + "filename" : "todayDataStep@2x.png",
  9 + "idiom" : "universal",
  10 + "scale" : "2x"
  11 + },
  12 + {
  13 + "idiom" : "universal",
  14 + "scale" : "3x"
  15 + }
  16 + ],
  17 + "info" : {
  18 + "author" : "xcode",
  19 + "version" : 1
  20 + }
  21 +}
  1 +//
  2 +// Color+Extension.swift
  3 +// hippo-watch Watch App
  4 +//
  5 +// Created by shihao on 2025/7/10.
  6 +//
  7 +
  8 +import Foundation
  9 +import SwiftUI
  10 +
  11 +extension Color {
  12 + init(hex: String) {
  13 + let hex = hex.trimmingCharacters(in: .whitespacesAndNewlines)
  14 + .replacingOccurrences(of: "#", with: "")
  15 +
  16 + var int = UInt64()
  17 +
  18 + guard Scanner(string: hex).scanHexInt64(&int) else {
  19 + self.init(.sRGB, red: 0, green: 0, blue: 0, opacity: 1)
  20 + return
  21 + }
  22 +
  23 + let a, r, g, b: UInt64
  24 + switch hex.count {
  25 + case 8: // AARRGGBB
  26 + a = (int & 0xFF000000) >> 24
  27 + r = (int & 0x00FF0000) >> 16
  28 + g = (int & 0x0000FF00) >> 8
  29 + b = int & 0x000000FF
  30 + case 6: // RRGGBB
  31 + a = 255
  32 + r = (int & 0xFF0000) >> 16
  33 + g = (int & 0x00FF00) >> 8
  34 + b = int & 0x0000FF
  35 + case 3: // RGB (短格式)
  36 + a = 255
  37 + r = ((int & 0xF00) >> 8) * 17
  38 + g = ((int & 0x0F0) >> 4) * 17
  39 + b = (int & 0x00F) * 17
  40 + default:
  41 + a = 255; r = 0; g = 0; b = 0
  42 + }
  43 +
  44 + self.init(
  45 + .sRGB,
  46 + red: Double(r) / 255,
  47 + green: Double(g) / 255,
  48 + blue: Double(b) / 255,
  49 + opacity: Double(a) / 255
  50 + )
  51 + }
  52 +}
  53 +
  54 +struct RectCorner: OptionSet {
  55 + let rawValue: Int
  56 +
  57 + static let topLeft = RectCorner(rawValue: 1 << 0)
  58 + static let topRight = RectCorner(rawValue: 1 << 1)
  59 + static let bottomLeft = RectCorner(rawValue: 1 << 2)
  60 + static let bottomRight = RectCorner(rawValue: 1 << 3)
  61 +}
  62 +
  63 +extension View {
  64 + func cornerRadius(_ radius: CGFloat, corners: RectCorner) -> some View {
  65 + clipShape(RoundedRectangle(cornerRadius: radius))
  66 + }
  67 +}
  1 +//
  2 +// DesignSize.swift
  3 +// hippo-watch Watch App
  4 +//
  5 +// Created by shihao on 2025/7/7.
  6 +//
  7 +
  8 +import Foundation
  9 +import SwiftUI
  10 +#if canImport(WatchKit)
  11 +import WatchKit
  12 +#endif
  13 +
  14 +// MARK: - 设计稿尺寸配置
  15 +struct DesignSize {
  16 + static let width: CGFloat = 184.0 // 设计稿宽度
  17 + static let height: CGFloat = 224.0 // 设计稿高度
  18 +}
  19 +
  20 +// MARK: - 屏幕尺寸工具
  21 +struct ScreenSize {
  22 +#if canImport(WatchKit)
  23 + static let width = WKInterfaceDevice.current().screenBounds.width
  24 + static let height = WKInterfaceDevice.current().screenBounds.height
  25 +#else
  26 + static let width: CGFloat = 184.0
  27 + static let height: CGFloat = 224.0
  28 +#endif
  29 +
  30 + // 计算比例
  31 + static let widthRatio = width / DesignSize.width
  32 + static let heightRatio = height / DesignSize.height
  33 +}
  34 +
  35 +// MARK: - 数值扩展
  36 +extension Int {
  37 + /// 根据设计稿宽度比例适配
  38 + var x: CGFloat {
  39 + return CGFloat(self) * ScreenSize.widthRatio
  40 + }
  41 +
  42 + /// 根据设计稿高度比例适配
  43 + var y: CGFloat {
  44 + return CGFloat(self) * ScreenSize.heightRatio
  45 + }
  46 +}
  47 +
  48 +extension Double {
  49 + /// 根据设计稿宽度比例适配
  50 + var x: CGFloat {
  51 + return CGFloat(self) * ScreenSize.widthRatio
  52 + }
  53 +
  54 + /// 根据设计稿高度比例适配
  55 + var y: CGFloat {
  56 + return CGFloat(self) * ScreenSize.heightRatio
  57 + }
  58 +}
  59 +
  60 +extension CGFloat {
  61 + /// 根据设计稿宽度比例适配
  62 + var x: CGFloat {
  63 + return self * ScreenSize.widthRatio
  64 + }
  65 +
  66 + /// 根据设计稿高度比例适配
  67 + var y: CGFloat {
  68 + return self * ScreenSize.heightRatio
  69 + }
  70 +}
  1 +//
  2 +// MainPagingView.swift
  3 +// hippo-watch Watch App
  4 +//
  5 +// Created by shihao on 2025/6/6.
  6 +//
  7 +
  8 +import SwiftUI
  9 +
  10 +struct MainPagingView: View {
  11 + @State private var showPKView = false
  12 + @State private var currentPage = 0
  13 +
  14 + var body: some View {
  15 + NavigationView {
  16 + ZStack {
  17 + // 主要内容 - 垂直分页
  18 + TabView(selection: $currentPage) {
  19 + StatusComparisonView(showPKView: $showPKView)
  20 + .tag(0)
  21 +
  22 + TodayDataView()
  23 + .tag(1)
  24 +
  25 + TakePulseView()
  26 + .tag(2)
  27 + }
  28 +#if os(watchOS)
  29 + .tabViewStyle(.verticalPage)
  30 +#else
  31 + .tabViewStyle(.page)
  32 +#endif
  33 + }
  34 + .navigationBarHidden(true)
  35 + }
  36 + .fullScreenCover(isPresented: $showPKView, content: {
  37 + PKDetailView()
  38 + })
  39 + }
  40 +}
  1 +//
  2 +// EmptyResponse.swift
  3 +// hippo-watch Watch App
  4 +//
  5 +// Created by shihao on 2025/7/8.
  6 +//
  7 +
  8 +import Foundation
  9 +
  10 +
  11 +// 用于空响应的结构体
  12 +struct EmptyResponse: Codable {}
  1 +//
  2 +// InteractActionType.swift
  3 +// hippo-watch Watch App
  4 +//
  5 +// Created by shihao on 2025/7/8.
  6 +//
  7 +
  8 +import Foundation
  9 +import SwiftUI
  10 +
  11 +enum InteractionActionType: Int {
  12 + case stick = 0
  13 + case miss = 1
  14 + case punch = 2
  15 +
  16 + var title: String {
  17 + switch self {
  18 + case .stick:
  19 + return "戳一戳"
  20 + case .miss:
  21 + return "想Ta"
  22 + case .punch:
  23 + return "打一拳"
  24 + }
  25 + }
  26 +
  27 + var buttonImage: ImageResource {
  28 + switch self {
  29 + case .stick:
  30 + return .stickButtonIcon
  31 + case .miss:
  32 + return .missButtonIcon
  33 + case .punch:
  34 + return .punchButtonIcon
  35 + }
  36 + }
  37 +
  38 + var statusComparisonAnimationName: String {
  39 + switch self {
  40 + case .stick:
  41 + return "interactionStick"
  42 + case .miss:
  43 + return "interactionMiss"
  44 + case .punch:
  45 + return "interactionPunch"
  46 + }
  47 + }
  48 +}
  1 +//
  2 +// LatestHRV.swift
  3 +// hippo-watch Watch App
  4 +//
  5 +// Created by shihao on 2025/7/8.
  6 +//
  7 +
  8 +import Foundation
  9 +import SwiftUI
  10 +
  11 +struct LatestHRV: Codable {
  12 + var userHrv: Double?
  13 + var userHrvBaseline: Double?
  14 + var pairUserHrv: Double?
  15 + var pairHrvBaseline: Double?
  16 +}
  17 +
  18 +
  19 +extension HRVStatus {
  20 + var watchStatusColors: [Color] {
  21 + switch self {
  22 + case .fullOfEnergy:
  23 + return [.init(hex: "#FF6EA271"),
  24 + .black]
  25 + case .normal:
  26 + return [.init(hex: "#FF796EA2"),
  27 + .black]
  28 + case .overpressure:
  29 + return [.init(hex: "#FF924343"),
  30 + .black]
  31 + }
  32 + }
  33 +
  34 + var watchStatusTextColor: Color {
  35 + switch self {
  36 + case .fullOfEnergy:
  37 + return .init(hex: "#FF95FF9B")
  38 + case .normal:
  39 + return .init(hex: "#FFE2BBFF")
  40 + case .overpressure:
  41 + return .init(hex: "#FFFF8E8E")
  42 + }
  43 + }
  44 +}
  1 +//
  2 +// TodayHealthInfo.swift
  3 +// hippo-watch Watch App
  4 +//
  5 +// Created by shihao on 2025/7/8.
  6 +//
  7 +
  8 +import Foundation
  9 +
  10 +
  11 +struct TodayHealthInfo: Codable {
  12 + var recentData: RecentData?
  13 + var hrvDataList: [HRVData]?
  14 + var sleepDuration: Int
  15 +}
  16 +
  17 +struct RecentData: Codable {
  18 + var heartRate: Double?
  19 + var oxygenSaturation: Int?
  20 + var move: Int?
  21 + var exercise: Int?
  22 + var stand: Int?
  23 + var steps: Int?
  24 +}
  25 +
  26 +
  27 +
  1 +import Foundation
  2 +import SwiftUI
  3 +import HealthKit
  4 +
  5 +enum HRVStatus: String, Codable {
  6 + case fullOfEnergy = "活力满满"
  7 + case normal = "状态正常"
  8 + case overpressure = "压力过载"
  9 +}
  10 +
  11 +struct HRVData: Codable {
  12 + var value: Double?
  13 + var hrvBaseline: Double?
  14 + var date: Date?
  15 +
  16 + var status: HRVStatus? {
  17 + guard let value else { return nil }
  18 + guard let hrvBaseline, hrvBaseline > 0 else { return .normal }
  19 +
  20 + let ratio = value / hrvBaseline
  21 + if ratio >= 1.05 {
  22 + return .fullOfEnergy
  23 + }
  24 + if ratio < 0.9 {
  25 + return .overpressure
  26 + }
  27 + return .normal
  28 + }
  29 +}
  30 +
  31 +enum UserCharacter: Int, Codable {
  32 + case `default` = 0
  33 + case cat = 1
  34 + case dog = 2
  35 + case rabbit = 3
  36 + case elephant = 4
  37 +}
  38 +
  39 +struct AvatarResource: Codable {
  40 + var rawValue: String?
  41 +
  42 + var url: URL? {
  43 + guard let rawValue else { return nil }
  44 + return URL(string: rawValue)
  45 + }
  46 +
  47 + init(rawValue: String?) {
  48 + self.rawValue = rawValue
  49 + }
  50 +
  51 + init(from decoder: Decoder) throws {
  52 + if let value = try? decoder.singleValueContainer().decode(String.self) {
  53 + rawValue = value
  54 + return
  55 + }
  56 +
  57 + let container = try decoder.container(keyedBy: DynamicCodingKey.self)
  58 + rawValue = Self.decodeString(from: container, keys: ["url", "avatar", "path"])
  59 + }
  60 +
  61 + func encode(to encoder: Encoder) throws {
  62 + var container = encoder.singleValueContainer()
  63 + try container.encode(rawValue)
  64 + }
  65 +
  66 + private static func decodeString(from container: KeyedDecodingContainer<DynamicCodingKey>, keys: [String]) -> String? {
  67 + for key in keys {
  68 + if let codingKey = DynamicCodingKey(stringValue: key),
  69 + let value = try? container.decodeIfPresent(String.self, forKey: codingKey) {
  70 + return value
  71 + }
  72 + }
  73 + return nil
  74 + }
  75 +}
  76 +
  77 +struct UserInfo: Codable {
  78 + var id: Int?
  79 + var pairId: Int?
  80 + var nickname: String?
  81 + var avatar: AvatarResource?
  82 + var persona: UserCharacter?
  83 +
  84 + init(from decoder: Decoder) throws {
  85 + let container = try decoder.container(keyedBy: DynamicCodingKey.self)
  86 + id = Self.decodeInt(from: container, keys: ["id", "user_id", "userId"])
  87 + pairId = Self.decodeInt(from: container, keys: ["pair_id", "pairId"])
  88 + nickname = Self.decodeString(from: container, keys: ["nickname", "nick_name", "name"])
  89 + avatar = try Self.decodeAvatar(from: container)
  90 + persona = UserCharacter(rawValue: Self.decodeInt(from: container, keys: ["persona", "character", "user_character", "userCharacter"]) ?? 0)
  91 + }
  92 +
  93 + func toWatchMap() -> [String: Any] {
  94 + var map: [String: Any] = [:]
  95 + if let id { map["id"] = id }
  96 + if let pairId { map["pair_id"] = pairId }
  97 + if let nickname { map["nickname"] = nickname }
  98 + if let avatar = avatar?.rawValue { map["avatar"] = avatar }
  99 + if let persona { map["persona"] = persona.rawValue }
  100 + return map
  101 + }
  102 +
  103 + private static func decodeAvatar(from container: KeyedDecodingContainer<DynamicCodingKey>) throws -> AvatarResource? {
  104 + for key in ["avatar", "avatar_url", "avatarUrl"] {
  105 + guard let codingKey = DynamicCodingKey(stringValue: key) else { continue }
  106 + if let value = try? container.decodeIfPresent(String.self, forKey: codingKey) {
  107 + return AvatarResource(rawValue: value)
  108 + }
  109 + if let value = try? container.decodeIfPresent(AvatarResource.self, forKey: codingKey) {
  110 + return value
  111 + }
  112 + }
  113 + return nil
  114 + }
  115 +}
  116 +
  117 +struct WatchThemeModel: Codable {
  118 + var isDefaultTheme: Bool?
  119 + var positiveDescription: String?
  120 + var normalDescription: String?
  121 + var negativeDescription: String?
  122 + var positiveImageURL: String?
  123 + var normalImageURL: String?
  124 + var negativeImageURL: String?
  125 +
  126 + init(from decoder: Decoder) throws {
  127 + let container = try decoder.container(keyedBy: DynamicCodingKey.self)
  128 + isDefaultTheme = Self.decodeBool(from: container, keys: ["isDefaultTheme", "is_default_theme", "isDefault", "is_default"])
  129 + positiveDescription = Self.decodeString(from: container, keys: ["positiveDescription", "positive_description", "excellentDescription", "excellent_description"])
  130 + normalDescription = Self.decodeString(from: container, keys: ["normalDescription", "normal_description"])
  131 + negativeDescription = Self.decodeString(from: container, keys: ["negativeDescription", "negative_description", "overpressureDescription", "overpressure_description"])
  132 + positiveImageURL = Self.decodeString(from: container, keys: ["positiveImageURL", "positiveImageUrl", "positive_image_url", "positiveThumbnailURL", "positive_thumbnail_url", "positiveThumnailImageURL", "positive_thumnail_image_url"])
  133 + normalImageURL = Self.decodeString(from: container, keys: ["normalImageURL", "normalImageUrl", "normal_image_url", "normalThumbnailURL", "normal_thumbnail_url", "normalThumnailImageURL", "normal_thumnail_image_url"])
  134 + negativeImageURL = Self.decodeString(from: container, keys: ["negativeImageURL", "negativeImageUrl", "negative_image_url", "negativeThumbnailURL", "negative_thumbnail_url", "negativeThumnailImageURL", "negative_thumnail_image_url"])
  135 + }
  136 +
  137 + func hrvStatusDescription(hrvStatus: HRVStatus) -> String {
  138 + switch hrvStatus {
  139 + case .fullOfEnergy:
  140 + return positiveDescription ?? hrvStatus.rawValue
  141 + case .normal:
  142 + return normalDescription ?? hrvStatus.rawValue
  143 + case .overpressure:
  144 + return negativeDescription ?? hrvStatus.rawValue
  145 + }
  146 + }
  147 +
  148 + func hrvStatusThumnailImageURL(hrvStatus: HRVStatus) -> String? {
  149 + switch hrvStatus {
  150 + case .fullOfEnergy:
  151 + return positiveImageURL
  152 + case .normal:
  153 + return normalImageURL
  154 + case .overpressure:
  155 + return negativeImageURL
  156 + }
  157 + }
  158 +}
  159 +
  160 +extension String {
  161 + var url: URL? {
  162 + URL(string: self)
  163 + }
  164 +
  165 + var int: Int? {
  166 + Int(self)
  167 + }
  168 +}
  169 +
  170 +extension Int {
  171 + var string: String {
  172 + String(self)
  173 + }
  174 +
  175 + var double: Double {
  176 + Double(self)
  177 + }
  178 +}
  179 +
  180 +extension Double {
  181 + var int: Int {
  182 + Int(self)
  183 + }
  184 +}
  185 +
  186 +extension Array where Element == Double {
  187 + func average() -> Double {
  188 + guard !isEmpty else { return 0 }
  189 + return reduce(0, +) / Double(count)
  190 + }
  191 +}
  192 +
  193 +extension Text {
  194 + func wenyiheiFont(size: CGFloat) -> Text {
  195 + font(.custom("WenYue-XinQingNianTi-NC-W8", size: size))
  196 + }
  197 +}
  198 +
  199 +struct SleepStage {
  200 + let stage: HKCategoryValueSleepAnalysis
  201 + let startTime: Date
  202 + let endTime: Date
  203 + let duration: TimeInterval
  204 +}
  205 +
  206 +struct SleepData {
  207 + let startTime: Date
  208 + let endTime: Date
  209 + let duration: TimeInterval
  210 + let sleepStages: [SleepStage]
  211 + let totalSleepTime: TimeInterval
  212 + let sleepEfficiency: Double?
  213 +}
  214 +
  215 +enum HealthDataType: Int {
  216 + case hrv = 1
  217 + case heartRate = 2
  218 + case spo2 = 3
  219 + case move = 4
  220 + case exercise = 5
  221 + case stand = 6
  222 + case steps = 7
  223 + case walkingHeartRate = 8
  224 + case restingHeartRate = 9
  225 + case sleepingHeartRate = 10
  226 + case sleepingWristTemperature = 11
  227 + case respiratoryRate = 12
  228 + case irregularHeartRhythm = 13
  229 +}
  230 +
  231 +private struct DynamicCodingKey: CodingKey {
  232 + var stringValue: String
  233 + var intValue: Int?
  234 +
  235 + init?(stringValue: String) {
  236 + self.stringValue = stringValue
  237 + }
  238 +
  239 + init?(intValue: Int) {
  240 + self.stringValue = "\(intValue)"
  241 + self.intValue = intValue
  242 + }
  243 +}
  244 +
  245 +private extension UserInfo {
  246 + static func decodeString(from container: KeyedDecodingContainer<DynamicCodingKey>, keys: [String]) -> String? {
  247 + for key in keys {
  248 + if let codingKey = DynamicCodingKey(stringValue: key),
  249 + let value = try? container.decodeIfPresent(String.self, forKey: codingKey) {
  250 + return value
  251 + }
  252 + }
  253 + return nil
  254 + }
  255 +
  256 + static func decodeInt(from container: KeyedDecodingContainer<DynamicCodingKey>, keys: [String]) -> Int? {
  257 + for key in keys {
  258 + guard let codingKey = DynamicCodingKey(stringValue: key) else { continue }
  259 + if let value = try? container.decodeIfPresent(Int.self, forKey: codingKey) {
  260 + return value
  261 + }
  262 + if let value = try? container.decodeIfPresent(String.self, forKey: codingKey), let intValue = Int(value) {
  263 + return intValue
  264 + }
  265 + }
  266 + return nil
  267 + }
  268 +}
  269 +
  270 +extension UserInfo {
  271 + var isPaired: Bool {
  272 + pairId != nil
  273 + }
  274 +}
  275 +
  276 +private extension WatchThemeModel {
  277 + static func decodeString(from container: KeyedDecodingContainer<DynamicCodingKey>, keys: [String]) -> String? {
  278 + for key in keys {
  279 + if let codingKey = DynamicCodingKey(stringValue: key),
  280 + let value = try? container.decodeIfPresent(String.self, forKey: codingKey) {
  281 + return value
  282 + }
  283 + }
  284 + return nil
  285 + }
  286 +
  287 + static func decodeBool(from container: KeyedDecodingContainer<DynamicCodingKey>, keys: [String]) -> Bool? {
  288 + for key in keys {
  289 + guard let codingKey = DynamicCodingKey(stringValue: key) else { continue }
  290 + if let value = try? container.decodeIfPresent(Bool.self, forKey: codingKey) {
  291 + return value
  292 + }
  293 + if let value = try? container.decodeIfPresent(Int.self, forKey: codingKey) {
  294 + return value != 0
  295 + }
  296 + if let value = try? container.decodeIfPresent(String.self, forKey: codingKey) {
  297 + return ["1", "true", "yes"].contains(value.lowercased())
  298 + }
  299 + }
  300 + return nil
  301 + }
  302 +}
  1 +//
  2 +// NotificationType.swift
  3 +// hippo-watch Watch App
  4 +//
  5 +// Created by shihao on 2025/10/31.
  6 +//
  7 +
  8 +import Foundation
  9 +
  10 +struct NotificationType {
  11 + static let watchThemeChanged = NSNotification.Name("watchThemeChanged")
  12 +}
  1 +//
  2 +// PKDetailView.swift
  3 +// hippo-watch Watch App
  4 +//
  5 +// Created by shihao on 2025/7/7.
  6 +//
  7 +
  8 +import Foundation
  9 +import SwiftUI
  10 +import SDWebImageSwiftUI
  11 +
  12 +struct PKDetailView: View {
  13 + @Environment(\.presentationMode) var presentationMode
  14 + @StateObject private var vm = PKDetailViewModel()
  15 + @ObservedObject var userinfoManager = WatchUserinfoManager.share
  16 + @ObservedObject var dataManager = WatchDataManager.share
  17 +
  18 + var body: some View {
  19 + ScrollView {
  20 + VStack(spacing: 4.x) {
  21 + // 顶部用户头像
  22 + HStack(spacing: 0) {
  23 + Spacer()
  24 + ZStack {
  25 + WebImage(url: userinfoManager.myUserinfo?.avatar?.url)
  26 + .resizable()
  27 + .frame(width: 36.x, height: 36.x)
  28 + .background(.clear)
  29 + .clipShape(Circle())
  30 + .overlay(Circle().stroke(Color(hex: "#FF896CDC"), lineWidth: 1.x))
  31 +
  32 + if isMeWinner {
  33 + // 皇冠图标
  34 + Image(.pkPageCrown)
  35 + .offset(x: 11.x, y: -20.x)
  36 + }
  37 + }
  38 +
  39 + Spacer()
  40 +
  41 + ZStack {
  42 + WebImage(url: userinfoManager.otherUserinfo?.avatar?.url)
  43 + .resizable()
  44 + .frame(width: 36.x, height: 36.x)
  45 + .background(.clear)
  46 + .clipShape(Circle())
  47 + .overlay(Circle().stroke(Color(hex: "#FFFFF45B"), lineWidth: 1.x))
  48 +
  49 + if !isMeWinner {
  50 + // 皇冠图标
  51 + Image(.pkPageCrown)
  52 + .offset(x: 11.x, y: -20.x)
  53 + }
  54 + }
  55 + Spacer()
  56 + }
  57 +
  58 + Spacer()
  59 + .frame(height: 10.x)
  60 +
  61 + // 步数对比
  62 + HealthComparisonCard(
  63 + icon: .todayDataStep,
  64 + title: "今日步数",
  65 + leftValue: myStep?.string ?? "-",
  66 + rightValue: otherStep?.string ?? "-",
  67 + progress: stepProgerss,
  68 + isLeftWinning: true
  69 + )
  70 +
  71 + // 睡眠时间对比
  72 + HealthComparisonCard(
  73 + icon: .pkPageSleepIcon,
  74 + title: "睡眠时间",
  75 + leftValue: toSleepTimeDesc(mySleep),
  76 + rightValue: toSleepTimeDesc(otherSleep),
  77 + progress: sleepProgress,
  78 + isLeftWinning: false
  79 + )
  80 +
  81 + // 活动记录对比
  82 + HealthComparisonCard(
  83 + icon: .todayDataActivity,
  84 + title: "活动记录",
  85 + leftValue: "\(myActivity?.string ?? "-")千卡",
  86 + rightValue: "\(otherActivity?.string ?? "-")千卡",
  87 + progress: activityProgress,
  88 + isLeftWinning: true
  89 + )
  90 + }
  91 + .padding(.horizontal, 8.x)
  92 + .padding(.bottom, 20)
  93 + }
  94 + .background(Color.black)
  95 + .toolbar {
  96 + ToolbarItem(placement: .topBarTrailing) {
  97 + Text("PK")
  98 + .font(.system(size: 17))
  99 + .foregroundColor(.white)
  100 + }
  101 + }
  102 + .onAppear {
  103 + vm.refreshData()
  104 + ThinkSDKUtil.track(eventName: "enter_doublefeel_watch_page",
  105 + properties: ["page_type": "PK"])
  106 + }
  107 + }
  108 +
  109 + private var isMeWinner: Bool {
  110 + let step = stepProgerss > 0.5 ? 1 : 0
  111 + let sleep = sleepProgress > 0.5 ? 1 : 0
  112 + let activity = activityProgress > 0.5 ? 1 : 0
  113 +
  114 + let total = step + sleep + activity
  115 + return total > 1
  116 + }
  117 +
  118 + private var myStep: Int? {
  119 + return dataManager.myTodayStepCount
  120 + }
  121 +
  122 + private var otherStep: Int? {
  123 + return dataManager.otherTodayHealthInfo?.recentData?.steps
  124 + }
  125 +
  126 + private var stepProgerss: Double {
  127 + let mine = (myStep ?? 0).double
  128 + let other = (otherStep ?? 0).double
  129 + if mine == 0 && other == 0 {
  130 + return 0.5
  131 + }
  132 + return mine / (mine + other)
  133 + }
  134 +
  135 + private var mySleep: TimeInterval? {
  136 + return dataManager.myTodaySleepTime
  137 + }
  138 +
  139 + private var otherSleep: TimeInterval? {
  140 + return dataManager.otherTodayHealthInfo?.sleepDuration.double
  141 + }
  142 +
  143 + private var sleepProgress: Double {
  144 + let mine = mySleep ?? 0
  145 + let other = otherSleep ?? 0
  146 + if mine == 0 && other == 0 {
  147 + return 0.5
  148 + }
  149 + return mine / (mine + other)
  150 + }
  151 +
  152 + private var myActivity: Int? {
  153 + return dataManager.myTodayActiveEnergy?.int
  154 + }
  155 +
  156 + private var otherActivity: Int? {
  157 + return dataManager.otherTodayHealthInfo?.recentData?.move
  158 + }
  159 +
  160 + private var activityProgress: Double {
  161 + let mine = (myActivity ?? 0).double
  162 + let other = (otherActivity ?? 0).double
  163 + if mine == 0 && other == 0 {
  164 + return 0.5
  165 + }
  166 + return mine / (mine + other)
  167 + }
  168 +
  169 + private func toSleepTimeDesc(_ duration: Double?) -> String {
  170 + guard let duration else {
  171 + return "-"
  172 + }
  173 +
  174 + let totalMinutes = Int(duration / 60)
  175 + let hours = totalMinutes / 60
  176 + let minutes = totalMinutes % 60
  177 +
  178 + return "\(hours)小时\(minutes)分"
  179 + }
  180 +}
  181 +
  182 +struct HealthComparisonCard: View {
  183 + let icon: ImageResource
  184 + let title: String
  185 + let leftValue: String
  186 + let rightValue: String
  187 + let progress: Double
  188 + let isLeftWinning: Bool
  189 +
  190 + var body: some View {
  191 + VStack(alignment: .leading, spacing: 0) {
  192 + // 标题行
  193 + HStack {
  194 + Image(icon)
  195 + .resizable()
  196 + .frame(width: 16.x, height: 16.x)
  197 +
  198 + Text(title)
  199 + .foregroundColor(.white)
  200 + .font(.system(size: 14))
  201 + .fontWeight(.regular)
  202 + }
  203 +
  204 + // 数值对比
  205 + HStack {
  206 + Text(leftValue)
  207 + .foregroundColor(leftTextColor)
  208 + .font(.system(size: 12))
  209 + .fontWeight(.semibold)
  210 +
  211 + Spacer()
  212 +
  213 + Text(rightValue)
  214 + .foregroundColor(rightTextColor)
  215 + .font(.system(size: 12))
  216 + .fontWeight(.regular)
  217 + }
  218 + .padding(.top, 13.x)
  219 +
  220 + // 进度条
  221 + ZStack(alignment: .leading) {
  222 +
  223 + // 进度条
  224 + GeometryReader { geometry in
  225 + ZStack(alignment: .topLeading) {
  226 + HStack(spacing: 0) {
  227 + // 左侧进度(粉色)
  228 + Rectangle()
  229 + .fill(
  230 + Color(hex: "#FFFFD3E5")
  231 + )
  232 + .frame(width: geometry.size.width * progress,
  233 + height: 12.x)
  234 + .cornerRadius(6.x, corners: [.topLeft, .bottomLeft])
  235 +
  236 + // 右侧进度(蓝色)
  237 + Rectangle()
  238 + .fill(
  239 + Color(hex: "#FFC5DFFF")
  240 + )
  241 + .frame(width: geometry.size.width * (1 - progress),
  242 + height: 12.x)
  243 + .cornerRadius(6.x, corners: [.topRight, .bottomRight])
  244 + }
  245 +
  246 + // 中间分割点
  247 + Circle()
  248 + .fill(Color(hex: "#FFFFD2A1"))
  249 + .stroke(Color.black, lineWidth: 2.x)
  250 + .frame(width: 16.x, height: 16.x)
  251 + .offset(x: circleOffsetx(geometryWidth: geometry.size.width),
  252 + y: -2.x)
  253 + }
  254 + }
  255 +
  256 +
  257 + }
  258 + .padding(.top, 8.x)
  259 + .padding(.bottom, 7.x)
  260 + }
  261 + .padding(6.x)
  262 + .background(
  263 + RoundedRectangle(cornerRadius: 16.x)
  264 + .fill(Color.gray.opacity(0.31))
  265 + )
  266 + }
  267 +
  268 + private func circleOffsetx(geometryWidth: CGFloat) -> CGFloat {
  269 + if progress == 0 {
  270 + return -2.x
  271 + }
  272 + if progress == 1 {
  273 + return progress * geometryWidth - 10.x
  274 + }
  275 + return progress * geometryWidth - 8.x
  276 + }
  277 +
  278 + private var leftTextColor: Color {
  279 + return progress > 0.5 ? .init(hex: "#FFFFA51B") : .white.opacity(0.31)
  280 + }
  281 +
  282 + private var rightTextColor: Color {
  283 + return progress < 0.5 ? .init(hex: "#FFFFA51B") : .white.opacity(0.31)
  284 + }
  285 +}
  1 +//
  2 +// PKDetailViewModel.swift
  3 +// hippo-watch Watch App
  4 +//
  5 +// Created by shihao on 2025/7/8.
  6 +//
  7 +
  8 +import Foundation
  9 +import Combine
  10 +
  11 +
  12 +class PKDetailViewModel: ObservableObject {
  13 +
  14 + func refreshData() {
  15 + WatchDataManager.share.fetchMyTodayData()
  16 + WatchDataManager.share.fetchOtherTodayData()
  17 + }
  18 +}
  1 +//
  2 +// PulseDetailView.swift
  3 +// hippo-watch Watch App
  4 +//
  5 +// Created by shihao on 2025/10/21.
  6 +//
  7 +
  8 +import Foundation
  9 +import SwiftUI
  10 +
  11 +struct PulseDetailView: View {
  12 + @StateObject private var userManager = WatchUserinfoManager.share
  13 + @State var pulseType: PulseType
  14 +
  15 + var body: some View {
  16 + ZStack(alignment: .top) {
  17 + LinearGradient(colors: [.init(hex: "#EEE6FF"),
  18 + .init(hex: "#ECE3FF"),
  19 + .init(hex: "#EEE6FF"),
  20 + .init(hex: "#FFFFFF"),
  21 + ],
  22 + startPoint: .top,
  23 + endPoint: .bottom)
  24 + .ignoresSafeArea()
  25 +
  26 + Image(.pulseDetailBg)
  27 + .resizable()
  28 + .aspectRatio(contentMode: .fill)
  29 + .frame(width: ScreenSize.width)
  30 + .frame(height: 280.x)
  31 + .ignoresSafeArea()
  32 +
  33 + ScrollView {
  34 + VStack(alignment: .center, spacing: 12) {
  35 + pulseOverView
  36 + vipBanner
  37 + pulseCharacteristicView
  38 + traditionalChineseMedicineView
  39 + dietAdviceView
  40 + sportAdviceView
  41 + tipView
  42 + Spacer().frame(height: 40)
  43 + }
  44 + }
  45 + .ignoresSafeArea(.container, edges: .bottom)
  46 + }
  47 + }
  48 +
  49 + @ViewBuilder
  50 + private var pulseOverView: some View {
  51 + VStack(spacing: 0) {
  52 + PulseWaveformView(pulseType: pulseType,
  53 + lineColor: .white)
  54 + .frame(width: 152.x, height: 85.x, alignment: .center)
  55 + .padding(.top, 34)
  56 +
  57 + Text(pulseType.model.name)
  58 + .wenyiheiFont(size: 24)
  59 + .foregroundColor(.init(hex: "#2C2020"))
  60 + .padding(.top, 14)
  61 + Text(pulseType.model.characteristic)
  62 + .font(.custom("Songti SC Regular", size: 16))
  63 + .foregroundColor(.init(hex: "#896CDC"))
  64 + }
  65 + .padding(.top, 35)
  66 + .padding(.bottom, 20)
  67 + }
  68 +
  69 + @ViewBuilder
  70 + private var vipBanner: some View {
  71 + if isVip == false {
  72 + HStack(spacing: 6) {
  73 + Image(.pulseDetailVipBannerIcon)
  74 +
  75 + Text("前往手机APP开通会员\n即可查看脉象详情")
  76 + .font(.system(size: 14, weight: .semibold))
  77 + .foregroundColor(.init(hex: "#FFECBD"))
  78 + }
  79 + .padding(.horizontal, 10)
  80 + .frame(height: 52)
  81 + .background(
  82 + LinearGradient(colors: [.init(hex: "#4B2E78"),
  83 + .init(hex: "#794097")], startPoint: .leading, endPoint: .trailing)
  84 + )
  85 + .cornerRadius(12)
  86 + .padding(.bottom, 3)
  87 + }
  88 + }
  89 +
  90 + @ViewBuilder
  91 + private var pulseCharacteristicView: some View {
  92 + HStack {
  93 + VStack(spacing: 4) {
  94 + ForEach(Array(pulseType.model.name), id: \.self) { char in
  95 + Text(String(char))
  96 + .font(.custom("Songti SC Bold", size: 16))
  97 + .foregroundColor(.white)
  98 + }
  99 + }
  100 + .background {
  101 + Image(.pulseDetailTitleBg)
  102 + }
  103 + .padding(.trailing, 12)
  104 +
  105 + VStack(alignment: .leading, spacing: 7) {
  106 + Text("脉象特征")
  107 + .font(.custom("Songti SC Regular", size: 13))
  108 + .foregroundColor(.init(hex: "#896CDC"))
  109 +
  110 + Text(pulseType.model.traditionalChineseMedicine.summary)
  111 + .font(.custom("Songti SC Bold", size: 13))
  112 + .foregroundColor(.init(hex: "#2C2020"))
  113 + .blur(radius: isVip ? 0 : 5)
  114 +
  115 + }
  116 + }
  117 + .padding(.horizontal, 17)
  118 + .padding(.bottom, 3)
  119 + }
  120 +
  121 + @ViewBuilder
  122 + private var traditionalChineseMedicineView: some View {
  123 + VStack(alignment: .leading, spacing: 2) {
  124 + HStack(alignment: .center, spacing: 2) {
  125 + Image(.pulseDetailZhongyiIcon)
  126 + Text("中医理论")
  127 + .wenyiheiFont(size: 12)
  128 + .foregroundColor(.init(hex: "#2C2020"))
  129 + Spacer()
  130 + }
  131 +
  132 + Text(pulseType.model.traditionalChineseMedicine.bodyShape)
  133 + .font(.custom("Songti SC Bold", size: 13))
  134 + .foregroundColor(.init(hex: "#896CDC"))
  135 + .padding(.leading, 4)
  136 + .blur(radius: isVip ? 0 : 5)
  137 +
  138 + if let mainDisease = pulseType.model.traditionalChineseMedicine.mainDisease {
  139 + Text(mainDisease)
  140 + .font(.custom("Songti SC Bold", size: 13))
  141 + .foregroundColor(.init(hex: "#896CDC"))
  142 + .padding(.leading, 4)
  143 + .blur(radius: isVip ? 0 : 5)
  144 + .padding(.bottom, 8)
  145 + }
  146 +
  147 + }
  148 + .padding(.horizontal, 10)
  149 + .padding(.top, 12)
  150 + .padding(.bottom, 20)
  151 + .background(.white)
  152 + .cornerRadius(24)
  153 + .padding(.horizontal, 18)
  154 + }
  155 +
  156 + @ViewBuilder
  157 + private var dietAdviceView: some View {
  158 + VStack(alignment: .leading, spacing: 2) {
  159 + HStack(alignment: .center, spacing: 2) {
  160 + Image(.pulseDetailDietIcon)
  161 + Text("饮食建议")
  162 + .wenyiheiFont(size: 12)
  163 + .foregroundColor(.init(hex: "#2C2020"))
  164 + Spacer()
  165 + }
  166 +
  167 + Text(pulseType.model.dietAdvice)
  168 + .font(.custom("Songti SC Bold", size: 13))
  169 + .foregroundColor(.init(hex: "#896CDC"))
  170 + .padding(.leading, 4)
  171 + .blur(radius: isVip ? 0 : 5)
  172 + }
  173 + .padding(.horizontal, 10)
  174 + .padding(.top, 12)
  175 + .padding(.bottom, 17)
  176 + .background(.white)
  177 + .cornerRadius(24)
  178 + .padding(.horizontal, 18)
  179 + }
  180 +
  181 + @ViewBuilder
  182 + private var sportAdviceView: some View {
  183 + VStack(alignment: .leading, spacing: 2) {
  184 + HStack(alignment: .center, spacing: 2) {
  185 + Image(.pulseDetailSportIcon)
  186 +
  187 + Text("运动建议")
  188 + .wenyiheiFont(size: 12)
  189 + .foregroundColor(.init(hex: "#2C2020"))
  190 +
  191 + Spacer()
  192 + }
  193 +
  194 + Text(pulseType.model.sportAdvice)
  195 + .font(.custom("Songti SC Bold", size: 13))
  196 + .foregroundColor(.init(hex: "#896CDC"))
  197 + .padding(.leading, 4)
  198 + .blur(radius: isVip ? 0 : 5)
  199 +
  200 + }
  201 + .padding(.horizontal, 10)
  202 + .padding(.top, 12)
  203 + .padding(.bottom, 18)
  204 + .background(.white)
  205 + .cornerRadius(24)
  206 + .padding(.horizontal, 18)
  207 + }
  208 +
  209 + @ViewBuilder
  210 + private var tipView: some View {
  211 + Text(
  212 + """
  213 +特别提醒
  214 +本结论中提供的建议仅供参考
  215 +如您有任何健康疑虑或不适症状
  216 +请及时咨询正规医疗机构或执业医师
  217 +"""
  218 + )
  219 + .font(.custom("Songti SC Regular", size: 9))
  220 + .foregroundColor(.init(hex: "#908B91"))
  221 + .multilineTextAlignment(.center)
  222 + .lineSpacing(6)
  223 + .padding(.top, 17)
  224 + .padding(.bottom, 24)
  225 + }
  226 +
  227 + private var isVip: Bool {
  228 + return userManager.isMeVip ?? false
  229 + }
  230 +}
  1 +//
  2 +// PulseFailedReasonView.swift
  3 +// hippo-watch Watch App
  4 +//
  5 +// Created by shihao on 2025/10/22.
  6 +//
  7 +
  8 +import Foundation
  9 +import SwiftUI
  10 +
  11 +struct PulseFailedReasonView: View {
  12 + @Binding var isPresent: Bool
  13 + var body: some View {
  14 + ZStack {
  15 + LinearGradient(colors: [.init(hex: "#D1BDFF"),
  16 + .init(hex: "#F7F3FF"),
  17 + ],
  18 + startPoint: .top,
  19 + endPoint: .bottom)
  20 + .ignoresSafeArea()
  21 +
  22 + ScrollView {
  23 + VStack(alignment: .leading, spacing: 0) {
  24 + HStack {
  25 + Spacer()
  26 + Text("诊脉失败原因")
  27 + .wenyiheiFont(size: 14)
  28 + .foregroundColor(.init(hex: "#2C2020"))
  29 + Spacer()
  30 + }
  31 +
  32 + Text("1、健康权限未开启")
  33 + .wenyiheiFont(size: 12)
  34 + .foregroundColor(.init(hex: "#896CDC"))
  35 + .padding(.top, 12)
  36 +
  37 + Text(
  38 + """
  39 + 手机端开启路径
  40 + 打开健康应用→共享→找到
  41 + DoubleFeel→全部打开手表端检查
  42 + 打开系统设置→健康→app服务→
  43 + 找到DoubleFeel→全部打开
  44 + """
  45 + )
  46 + .font(.custom("Songti SC Bold", size: 11))
  47 + .foregroundColor(.init(hex: "#2C2020"))
  48 + .padding(.top, 6)
  49 +
  50 + Text("2、设备佩戴过松,未紧贴手腕")
  51 + .wenyiheiFont(size: 12)
  52 + .foregroundColor(.init(hex: "#896CDC"))
  53 + .padding(.top, 16)
  54 +
  55 + Text("完成以上检查后\n请点击开始诊脉重新测量")
  56 + .font(.custom("Songti SC Bold", size: 9))
  57 + .foregroundColor(.init(hex: "#896CDC"))
  58 + .multilineTextAlignment(.center)
  59 + .padding(.vertical, 9)
  60 + .frame(maxWidth: .infinity)
  61 + .background(Color(hex: "#DECFFF"))
  62 + .cornerRadius(16)
  63 + .padding(.bottom, 28)
  64 + .padding(.top, 16)
  65 + .onTapGesture {
  66 + isPresent = false
  67 + }
  68 +
  69 +
  70 + }
  71 + .padding(.horizontal, 12)
  72 + }
  73 + }
  74 + }
  75 +}
  1 +//
  2 +// PulseModels.swift
  3 +// hippo-watch Watch App
  4 +//
  5 +
  6 +import SwiftUI
  7 +
  8 +enum PulseType: Int, Codable {
  9 + case fu = 1
  10 + case chen = 2
  11 + case chi = 3
  12 + case shu = 4
  13 + case hua = 5
  14 + case se = 6
  15 + case xu = 7
  16 + case pin = 8
  17 +
  18 + var model: PulseModel {
  19 + switch self {
  20 + case .fu:
  21 + return PulseModel(
  22 + name: "浮脉",
  23 + characteristic: "轻取即得",
  24 + traditionalChineseMedicine: PulseTraditionalChineseMedicine(
  25 + summary: "脉位表浅,轻按明显,重按稍弱。",
  26 + bodyShape: "常见于外感初起、体表不适等状态。",
  27 + mainDisease: "注意保暖与休息。"
  28 + ),
  29 + dietAdvice: "饮食宜清淡温和,少食生冷。",
  30 + sportAdvice: "适合低强度散步和舒展。"
  31 + )
  32 + case .chen:
  33 + return PulseModel(
  34 + name: "沉脉",
  35 + characteristic: "重按始得",
  36 + traditionalChineseMedicine: PulseTraditionalChineseMedicine(
  37 + summary: "脉位偏深,轻取不明显。",
  38 + bodyShape: "可能提示身体处于较疲惫或内在压力状态。",
  39 + mainDisease: "注意睡眠和规律作息。"
  40 + ),
  41 + dietAdvice: "可选择温热易消化饮食。",
  42 + sportAdvice: "以轻量活动为主,避免过度消耗。"
  43 + )
  44 + case .chi:
  45 + return PulseModel(
  46 + name: "迟脉",
  47 + characteristic: "节律偏缓",
  48 + traditionalChineseMedicine: PulseTraditionalChineseMedicine(
  49 + summary: "脉来较缓,整体节奏偏慢。",
  50 + bodyShape: "可能与寒凉、代谢偏慢或疲劳相关。",
  51 + mainDisease: "注意保暖,观察身体状态。"
  52 + ),
  53 + dietAdvice: "少食寒凉,适当补充温热汤水。",
  54 + sportAdvice: "适合缓慢热身后的轻运动。"
  55 + )
  56 + case .shu:
  57 + return PulseModel(
  58 + name: "数脉",
  59 + characteristic: "节律偏快",
  60 + traditionalChineseMedicine: PulseTraditionalChineseMedicine(
  61 + summary: "脉来较快,身体兴奋度偏高。",
  62 + bodyShape: "可能与紧张、运动后或睡眠不足相关。",
  63 + mainDisease: "先休息后复测更准确。"
  64 + ),
  65 + dietAdvice: "减少辛辣刺激,补充水分。",
  66 + sportAdvice: "避免高强度训练,优先放松。"
  67 + )
  68 + case .hua:
  69 + return PulseModel(
  70 + name: "滑脉",
  71 + characteristic: "往来流利",
  72 + traditionalChineseMedicine: PulseTraditionalChineseMedicine(
  73 + summary: "脉象圆滑流畅,来去较利。",
  74 + bodyShape: "可见于身体能量较足或饮食偏丰盛时。",
  75 + mainDisease: nil
  76 + ),
  77 + dietAdvice: "饮食保持均衡,避免过饱。",
  78 + sportAdvice: "适合规律有氧运动。"
  79 + )
  80 + case .se:
  81 + return PulseModel(
  82 + name: "涩脉",
  83 + characteristic: "往来艰涩",
  84 + traditionalChineseMedicine: PulseTraditionalChineseMedicine(
  85 + summary: "脉行不够流畅,节律感偏滞。",
  86 + bodyShape: "可能与疲劳、压力或循环状态相关。",
  87 + mainDisease: "建议持续观察。"
  88 + ),
  89 + dietAdvice: "适当补水,饮食清淡。",
  90 + sportAdvice: "从舒缓拉伸开始,循序渐进。"
  91 + )
  92 + case .xu:
  93 + return PulseModel(
  94 + name: "虚脉",
  95 + characteristic: "按之无力",
  96 + traditionalChineseMedicine: PulseTraditionalChineseMedicine(
  97 + summary: "脉势偏弱,身体恢复感不足。",
  98 + bodyShape: "可能提示近期休息不足或消耗较多。",
  99 + mainDisease: "优先保证睡眠。"
  100 + ),
  101 + dietAdvice: "选择高质量蛋白和温和主食。",
  102 + sportAdvice: "减少强度,安排恢复性活动。"
  103 + )
  104 + case .pin:
  105 + return PulseModel(
  106 + name: "平脉",
  107 + characteristic: "节律平稳",
  108 + traditionalChineseMedicine: PulseTraditionalChineseMedicine(
  109 + summary: "脉象较平稳,整体状态相对均衡。",
  110 + bodyShape: "继续保持规律作息和适度运动。",
  111 + mainDisease: nil
  112 + ),
  113 + dietAdvice: "保持均衡饮食。",
  114 + sportAdvice: "可进行日常规律运动。"
  115 + )
  116 + }
  117 + }
  118 +
  119 + var waveformImage: ImageResource {
  120 + switch self {
  121 + case .fu:
  122 + return .pulseWaveformFu
  123 + case .chen:
  124 + return .pulseWaveformChen
  125 + case .chi:
  126 + return .pulseWaveformChi
  127 + case .shu:
  128 + return .pulseWaveformShu
  129 + case .hua:
  130 + return .pulseWaveformHua
  131 + case .se:
  132 + return .pulseWaveformSe
  133 + case .xu:
  134 + return .pulseWaveformXu
  135 + case .pin:
  136 + return .pulseWaveformPin
  137 + }
  138 + }
  139 +}
  140 +
  141 +struct PulseModel {
  142 + let name: String
  143 + let characteristic: String
  144 + let traditionalChineseMedicine: PulseTraditionalChineseMedicine
  145 + let dietAdvice: String
  146 + let sportAdvice: String
  147 +}
  148 +
  149 +struct PulseTraditionalChineseMedicine {
  150 + let summary: String
  151 + let bodyShape: String
  152 + let mainDisease: String?
  153 +}
  154 +
  155 +struct PulseWaveformView: View {
  156 + let pulseType: PulseType
  157 + var lineColor: Color = .white
  158 +
  159 + var body: some View {
  160 + Image(pulseType.waveformImage)
  161 + .renderingMode(.template)
  162 + .resizable()
  163 + .scaledToFit()
  164 + .foregroundStyle(lineColor)
  165 + }
  166 +}
  1 +//
  2 +// PulseMonitor.swift
  3 +// hippo-watch Watch App
  4 +//
  5 +// Created by shihao on 2025/10/22.
  6 +//
  7 +
  8 +import Foundation
  9 +import HealthKit
  10 +import SwiftUI
  11 +import Combine
  12 +
  13 +class PulseMonitor: NSObject, ObservableObject {
  14 + @Published var progress: Double = 0
  15 + @Published var pulseType: PulseType?
  16 +
  17 + private let healthStore = HKHealthStore()
  18 + private var session: HKWorkoutSession?
  19 + private var builder: HKLiveWorkoutBuilder?
  20 + private var heartRateQuery: HKAnchoredObjectQuery?
  21 + private var progressTimer: Timer?
  22 +
  23 +
  24 +
  25 + var onHeartRateUpdate: ((Double) -> Void)?
  26 + var onOxygenUpdate: ((Double) -> Void)?
  27 +
  28 + // MARK: - Heart Rate Buffer
  29 + private var heartRateSamplesBuffer: [Double] = []
  30 +
  31 + // MARK: - 脉象表
  32 + struct PulsePattern {
  33 + let heartRateRange: ClosedRange<Double>
  34 + let oxygenRange: ClosedRange<Double>
  35 + let pulseType: PulseType
  36 + }
  37 +
  38 + private let pulsePatterns: [PulsePattern] = [
  39 + PulsePattern(heartRateRange: 60...80, oxygenRange: 86...94, pulseType: .fu),
  40 + PulsePattern(heartRateRange: 40...60, oxygenRange: 88...92, pulseType: .chen),
  41 + PulsePattern(heartRateRange: 40...60, oxygenRange: 92...98, pulseType: .chi),
  42 + PulsePattern(heartRateRange: 80...120, oxygenRange: 90...94, pulseType: .shu),
  43 + PulsePattern(heartRateRange: 80...100, oxygenRange: 98...100, pulseType: .hua),
  44 + PulsePattern(heartRateRange: 80...120, oxygenRange: 86...90, pulseType: .se),
  45 + PulsePattern(heartRateRange: 80...100, oxygenRange: 94...98, pulseType: .xu),
  46 + PulsePattern(heartRateRange: 60...80, oxygenRange: 94...100, pulseType: .pin)
  47 + ]
  48 +
  49 + private func matchPulsePattern(heartRate: Double, oxygen: Double) -> PulseType? {
  50 + return pulsePatterns.first { $0.heartRateRange.contains(heartRate) && $0.oxygenRange.contains(oxygen) }?.pulseType
  51 + }
  52 +
  53 + func startMonitoring() {
  54 + let configuration = HKWorkoutConfiguration()
  55 + configuration.activityType = .other
  56 + configuration.locationType = .indoor
  57 +
  58 + do {
  59 + session = try HKWorkoutSession(healthStore: healthStore, configuration: configuration)
  60 + builder = session?.associatedWorkoutBuilder()
  61 + session?.delegate = self
  62 + builder?.dataSource = HKLiveWorkoutDataSource(healthStore: healthStore, workoutConfiguration: configuration)
  63 +
  64 + session?.startActivity(with: Date())
  65 + builder?.beginCollection(withStart: Date()) { success, error in
  66 + if let error = error {
  67 + print("💥 Failed to start collection: \(error.localizedDescription)")
  68 + } else {
  69 + print("✅ Workout session started")
  70 + self.startHeartRateQuery()
  71 + }
  72 + }
  73 + } catch {
  74 + print("❌ Failed to create session: \(error.localizedDescription)")
  75 + }
  76 + }
  77 +
  78 + func stopMonitoring() {
  79 + session?.end()
  80 + builder?.endCollection(withEnd: Date()) { success, error in
  81 + self.builder?.finishWorkout { workout, error in
  82 + print("✅ Workout ended")
  83 + }
  84 + }
  85 + if let query = heartRateQuery {
  86 + healthStore.stop(query)
  87 + }
  88 + }
  89 +
  90 + private func startHeartRateQuery() {
  91 + let type = HKQuantityType.quantityType(forIdentifier: .heartRate)!
  92 + let predicate = HKQuery.predicateForSamples(withStart: Date(), end: nil, options: .strictStartDate)
  93 +
  94 + heartRateQuery = HKAnchoredObjectQuery(type: type, predicate: predicate, anchor: nil, limit: HKObjectQueryNoLimit) { _, samples, _, _, _ in
  95 + self.handleHeartRateSamples(samples)
  96 + }
  97 +
  98 + heartRateQuery?.updateHandler = { _, samples, _, _, _ in
  99 + self.handleHeartRateSamples(samples)
  100 + }
  101 +
  102 + if let query = heartRateQuery {
  103 + healthStore.execute(query)
  104 + }
  105 + }
  106 +
  107 + private func handleHeartRateSamples(_ samples: [HKSample]?) {
  108 + guard let heartRateSamples = samples as? [HKQuantitySample] else { return }
  109 + for sample in heartRateSamples {
  110 + let bpm = sample.quantity.doubleValue(for: HKUnit(from: "count/min"))
  111 + heartRateSamplesBuffer.append(bpm)
  112 + print("❤️ 心率: \(Int(bpm)) bpm")
  113 + }
  114 + }
  115 +
  116 + // MARK: - Get Latest Oxygen
  117 + private func getLatestOxygen(completion: @escaping (Double?) -> Void) {
  118 + guard let type = HKQuantityType.quantityType(forIdentifier: .oxygenSaturation) else {
  119 + completion(nil)
  120 + return
  121 + }
  122 +
  123 + let sort = NSSortDescriptor(key: HKSampleSortIdentifierStartDate, ascending: false)
  124 + let query = HKSampleQuery(sampleType: type, predicate: nil, limit: 1, sortDescriptors: [sort]) { _, samples, _ in
  125 + guard let sample = samples?.first as? HKQuantitySample else {
  126 + completion(nil)
  127 + return
  128 + }
  129 + let value = sample.quantity.doubleValue(for: HKUnit.percent()) * 100
  130 + completion(value)
  131 + }
  132 + healthStore.execute(query)
  133 + }
  134 +
  135 + private func getLatestHeartRate() async -> Double? {
  136 + return await withCheckedContinuation { continuation in
  137 + guard let type = HKQuantityType.quantityType(forIdentifier: .heartRate) else {
  138 + continuation.resume(returning: nil)
  139 + return
  140 + }
  141 +
  142 + let sort = NSSortDescriptor(key: HKSampleSortIdentifierStartDate, ascending: false)
  143 + let query = HKSampleQuery(sampleType: type,
  144 + predicate: nil,
  145 + limit: 1,
  146 + sortDescriptors: [sort]) { _, samples, _ in
  147 +
  148 + guard let sample = samples?.first as? HKQuantitySample else {
  149 + continuation.resume(returning: nil)
  150 + return
  151 + }
  152 +
  153 + let bpm = sample.quantity.doubleValue(for: HKUnit(from: "count/min"))
  154 + let timeDiff = Date().timeIntervalSince(sample.startDate)
  155 +
  156 + // ✅ 只接受两分钟内的心率值
  157 + continuation.resume(returning: timeDiff <= 120 ? bpm : nil)
  158 + }
  159 +
  160 + healthStore.execute(query)
  161 + }
  162 + }
  163 +
  164 + // MARK: - Start Diagnosis
  165 + func startDiagnosis(duration: TimeInterval = 11,
  166 + completion: @escaping (PulseType?) -> Void) {
  167 + heartRateSamplesBuffer.removeAll()
  168 + startMonitoring()
  169 +
  170 + // 进度计时器,每 0.1 秒更新一次 progress
  171 + let interval = 0.2
  172 + let steps = duration / interval
  173 + var currentStep = 0
  174 +
  175 + progressTimer?.invalidate()
  176 + progressTimer = Timer.scheduledTimer(withTimeInterval: interval, repeats: true) { [weak self] timer in
  177 + guard let self = self else { return }
  178 + currentStep += 1
  179 + withAnimation {
  180 + self.progress = min(Double(currentStep) / steps, 1)
  181 + }
  182 + if self.progress >= 1 {
  183 + timer.invalidate()
  184 + }
  185 + }
  186 +
  187 + DispatchQueue.main.asyncAfter(deadline: .now() + duration) { [weak self] in
  188 + guard let self = self else { return }
  189 + self.stopMonitoring()
  190 +
  191 + let avgHeartRate = self.heartRateSamplesBuffer.average()
  192 + self.getLatestOxygen { oxygen in
  193 + Task {
  194 + var finalHR = avgHeartRate
  195 +
  196 + // ✅ 只有 avgHeartRate == 0 时才调用兜底
  197 + if finalHR == 0 {
  198 + if let hr = await self.getLatestHeartRate() {
  199 + finalHR = hr
  200 + }
  201 + }
  202 +
  203 + DispatchQueue.main.async {
  204 + if oxygen == nil && avgHeartRate == 0 {
  205 + completion(nil)
  206 + return
  207 + }
  208 +
  209 + guard let oxygen else {
  210 + self.pulseType = .pin
  211 + completion(.pin)
  212 + return
  213 + }
  214 + guard finalHR != 0 else {
  215 + completion(nil)
  216 + return
  217 + }
  218 +
  219 + let pulseType = self.matchPulsePattern(heartRate: finalHR, oxygen: oxygen)
  220 + self.pulseType = pulseType
  221 + print("诊脉结果:\(pulseType), avgHeartRate: \(finalHR), oxygen:\(oxygen)")
  222 + completion(pulseType)
  223 + }
  224 + }
  225 + }
  226 + }
  227 + }
  228 +}
  229 +
  230 +
  231 +extension PulseMonitor: HKWorkoutSessionDelegate {
  232 + func workoutSession(_ workoutSession: HKWorkoutSession, didChangeTo toState: HKWorkoutSessionState, from fromState: HKWorkoutSessionState, date: Date) {
  233 + print("🔄 Workout state: \(fromState.rawValue)\(toState.rawValue)")
  234 + }
  235 +
  236 + func workoutSession(_ workoutSession: HKWorkoutSession, didFailWithError error: Error) {
  237 + print("💥 Workout error: \(error.localizedDescription)")
  238 + }
  239 +}
  1 +//
  2 +// TakePulseView.swift
  3 +// hippo-watch Watch App
  4 +//
  5 +// Created by shihao on 2025/10/21.
  6 +//
  7 +
  8 +import Foundation
  9 +import SwiftUI
  10 +
  11 +struct TakePulseView: View {
  12 + @StateObject private var pulseMonitor = PulseMonitor()
  13 + @State private var showPulseDetailView = false
  14 + @State private var showPulseFailedView = false
  15 + @State private var displayType: TakePulseViewDisplayType = .waitForTakePulse
  16 +
  17 + enum TakePulseViewDisplayType {
  18 + case waitForTakePulse
  19 + case takingPulse
  20 + case pulseFailed
  21 + }
  22 +
  23 + var body: some View {
  24 + ZStack(alignment: .top) {
  25 + Image(.takePulseBg)
  26 + .resizable()
  27 + .frame(maxWidth: .infinity, maxHeight: .infinity)
  28 + .aspectRatio(contentMode: .fill)
  29 + .ignoresSafeArea()
  30 +
  31 + switch displayType {
  32 + case .waitForTakePulse:
  33 + waitForTakePulseView
  34 + case .takingPulse:
  35 + takingPulseView
  36 + case .pulseFailed:
  37 + pulseFailedView
  38 + }
  39 +
  40 + HStack {
  41 + Image(.takePulseHelp)
  42 + .padding(.top, 22)
  43 + .padding(.leading, 8)
  44 + .onTapGesture {
  45 + showPulseFailedView = true
  46 + }
  47 +
  48 + Spacer()
  49 + }
  50 +
  51 + }
  52 + .fullScreenCover(isPresented: $showPulseDetailView, content: {
  53 + if let pulseType = pulseMonitor.pulseType {
  54 + PulseDetailView(pulseType: pulseType)
  55 + }
  56 + })
  57 + .fullScreenCover(isPresented: $showPulseFailedView, content: {
  58 + PulseFailedReasonView(isPresent: $showPulseFailedView)
  59 + })
  60 + .onAppear {
  61 + ThinkSDKUtil.track(eventName: "enter_doublefeel_watch_page",
  62 + properties: ["page_type": "诊脉"])
  63 + }
  64 + }
  65 +
  66 + @ViewBuilder
  67 + private var waitForTakePulseView: some View {
  68 + VStack(spacing: 0) {
  69 +
  70 + Image(.takePulseIcon)
  71 + .padding(.top, 35.x)
  72 +
  73 + Spacer()
  74 +
  75 + Text("请保持静坐,并将手腕放平")
  76 + .font(.custom("Songti SC Regular", size: 11))
  77 + .foregroundColor(.init(hex: "#896CDC"))
  78 + .padding(.top, 10.x)
  79 +
  80 + Button {
  81 + withAnimation {
  82 + displayType = .takingPulse
  83 + pulseMonitor.startDiagnosis { pulseType in
  84 + if let pulseType {
  85 + showPulseDetailView = true
  86 + reset()
  87 + Task {
  88 + let _ = try? await HealthService().uploadPulse(pulseType: pulseType)
  89 + WatchSessionManager.share.notifyPhoneToRefreshPulse()
  90 + }
  91 + }else {
  92 + displayType = .pulseFailed
  93 + }
  94 + }
  95 + }
  96 + ThinkSDKUtil.track(eventName: "click_doublefeel_watch_page",
  97 + properties: ["page_type": "诊断",
  98 + "click_content": "开始诊脉"])
  99 + } label: {
  100 + Text("立即诊脉")
  101 + .font(.system(size: 15, weight: .semibold))
  102 + .foregroundColor(.white)
  103 + .frame(height: 36, alignment: .center)
  104 + .frame(maxWidth: .infinity)
  105 + .background(Color(hex: "#896CDC"))
  106 + .cornerRadius(18)
  107 + }
  108 + .padding(.top, 4.x)
  109 + .padding(.horizontal, 8)
  110 + .padding(.bottom, 10.x)
  111 + }
  112 + }
  113 +
  114 + @ViewBuilder
  115 + private var takingPulseView: some View {
  116 + VStack(spacing: 0) {
  117 +
  118 + ZStack(alignment: .center) {
  119 + RingProgressView(progress: pulseMonitor.progress, color: .init(hex: "#896CDC"), size: 110)
  120 +
  121 + Image(.takePulseIcon)
  122 +
  123 + }
  124 + .padding(.top, 38.x)
  125 +
  126 + Spacer()
  127 +
  128 + Text("正在分析数据\n诊脉中…")
  129 + .font(.custom("Songti SC Regular", size: 11))
  130 + .foregroundColor(.init(hex: "#896CDC"))
  131 + .padding(.top, 19.x)
  132 + .padding(.bottom, 24.x)
  133 + .multilineTextAlignment(.center)
  134 + }
  135 + .background (
  136 + AnimatedGIFView(gifName: "takePulseBg")
  137 + .frame(width: ScreenSize.width, height: ScreenSize.height)
  138 + )
  139 + }
  140 +
  141 + @ViewBuilder
  142 + private var pulseFailedView: some View {
  143 + VStack(alignment: .center, spacing: 0) {
  144 + Spacer()
  145 + Text("诊断失败\n未读取到数据")
  146 + .font(.custom("Songti SC Regular", size: 11))
  147 + .foregroundColor(.init(hex: "#896CDC"))
  148 + .multilineTextAlignment(.center)
  149 + Spacer()
  150 + Button {
  151 + withAnimation {
  152 + showPulseFailedView = true
  153 + reset()
  154 + }
  155 + ThinkSDKUtil.track(eventName: "click_doublefeel_watch_page",
  156 + properties: ["page_type": "诊断",
  157 + "click_content": "为何会诊脉失败"])
  158 + } label: {
  159 + Text("为何会诊脉失败")
  160 + .font(.system(size: 15, weight: .semibold))
  161 + .foregroundColor(.white)
  162 + .frame(height: 36, alignment: .center)
  163 + .frame(maxWidth: .infinity)
  164 + .background(Color(hex: "#896CDC"))
  165 + .cornerRadius(18)
  166 + }
  167 + .padding(.horizontal, 8)
  168 + .padding(.bottom, 18.x)
  169 + }
  170 + }
  171 +
  172 + private func reset() {
  173 + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
  174 + displayType = .waitForTakePulse
  175 + pulseMonitor.progress = 0
  176 + }
  177 + }
  178 +}
  179 +
  180 +
  181 +fileprivate struct RingProgressView: View {
  182 + var progress: Double // 0.0 ~ 1.0
  183 + var lineWidth: CGFloat = 4
  184 + var color: Color = .blue
  185 + var size: CGFloat = 100
  186 +
  187 + var body: some View {
  188 + ZStack {
  189 + // 背景环
  190 + Circle()
  191 + .stroke(Color.gray.opacity(0.2), lineWidth: lineWidth)
  192 +
  193 + // 前景进度环
  194 + Circle()
  195 + .trim(from: 0, to: CGFloat(progress))
  196 + .stroke(color, style: StrokeStyle(lineWidth: lineWidth, lineCap: .round))
  197 + .rotationEffect(.degrees(-90)) // 从顶部开始
  198 + .animation(.easeInOut(duration: 0.3), value: progress)
  199 + }
  200 + .frame(width: size, height: size)
  201 + }
  202 +}
@@ -2,7 +2,15 @@ @@ -2,7 +2,15 @@
2 <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> 2 <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3 <plist version="1.0"> 3 <plist version="1.0">
4 <dict> 4 <dict>
5 - <key>PreviewsEnabled</key>  
6 - <false/> 5 + <key>aps-environment</key>
  6 + <string>development</string>
  7 + <key>com.apple.developer.healthkit</key>
  8 + <true/>
  9 + <key>com.apple.developer.healthkit.background-delivery</key>
  10 + <true/>
  11 + <key>com.apple.security.application-groups</key>
  12 + <array>
  13 + <string>group.com.luiz.doublefeel.watchkitapp</string>
  14 + </array>
7 </dict> 15 </dict>
8 </plist> 16 </plist>
  1 +//
  2 +// APIClient.swift
  3 +// hippo-watch Watch App
  4 +//
  5 +
  6 +import Foundation
  7 +
  8 +enum NetworkEnvironment: Int {
  9 + case dev = 0
  10 + case xlab = 1
  11 + case release = 2
  12 +
  13 + static var current: NetworkEnvironment = .release
  14 +
  15 + var baseURL: URL {
  16 + switch self {
  17 + case .dev:
  18 + return URL(string: "https://dev-api.doublefeel.cn")!
  19 + case .xlab:
  20 + return URL(string: "https://xlab-api.doublefeel.cn")!
  21 + case .release:
  22 + return URL(string: "https://api.doublefeel.cn")!
  23 + }
  24 + }
  25 +}
  26 +
  27 +enum HTTPMethod: String {
  28 + case get = "GET"
  29 + case post = "POST"
  30 +}
  31 +
  32 +enum ParameterEncoding {
  33 + case url
  34 + case json
  35 +}
  36 +
  37 +protocol APIEndpoint {
  38 + var path: String { get }
  39 + var method: HTTPMethod { get }
  40 + var parameters: [String: Any]? { get }
  41 + var encoding: ParameterEncoding { get }
  42 +}
  43 +
  44 +enum APIClientError: LocalizedError {
  45 + case invalidURL
  46 + case invalidResponse
  47 + case badStatus(Int, String?)
  48 + case emptyResponse
  49 +
  50 + var errorDescription: String? {
  51 + switch self {
  52 + case .invalidURL:
  53 + return "接口地址无效"
  54 + case .invalidResponse:
  55 + return "接口响应无效"
  56 + case .badStatus(let statusCode, let message):
  57 + return message ?? "接口请求失败:\(statusCode)"
  58 + case .emptyResponse:
  59 + return "接口返回为空"
  60 + }
  61 + }
  62 +}
  63 +
  64 +final class APIClient {
  65 + static let shared = APIClient()
  66 +
  67 + private let session: URLSession
  68 + private let decoder: JSONDecoder
  69 +
  70 + private init(session: URLSession = .shared) {
  71 + self.session = session
  72 + self.decoder = JSONDecoder()
  73 + self.decoder.keyDecodingStrategy = .convertFromSnakeCase
  74 + }
  75 +
  76 + func request<T: Decodable>(_ endpoint: APIEndpoint) async throws -> T {
  77 + let request = try makeRequest(for: endpoint)
  78 + let (data, response) = try await session.data(for: request)
  79 +
  80 + guard let httpResponse = response as? HTTPURLResponse else {
  81 + throw APIClientError.invalidResponse
  82 + }
  83 +
  84 + guard (200..<300).contains(httpResponse.statusCode) else {
  85 + throw APIClientError.badStatus(httpResponse.statusCode, parseErrorMessage(from: data))
  86 + }
  87 +
  88 + if T.self == EmptyResponse.self {
  89 + return EmptyResponse() as! T
  90 + }
  91 +
  92 + guard !data.isEmpty else {
  93 + throw APIClientError.emptyResponse
  94 + }
  95 +
  96 + return try decoder.decode(T.self, from: data)
  97 + }
  98 +
  99 + private func makeRequest(for endpoint: APIEndpoint) throws -> URLRequest {
  100 + guard var components = URLComponents(
  101 + url: NetworkEnvironment.current.baseURL.appendingPathComponent(endpoint.path),
  102 + resolvingAgainstBaseURL: false
  103 + ) else {
  104 + throw APIClientError.invalidURL
  105 + }
  106 +
  107 + if endpoint.encoding == .url, let parameters = endpoint.parameters {
  108 + components.queryItems = parameters.map { key, value in
  109 + URLQueryItem(name: key, value: String(describing: value))
  110 + }
  111 + }
  112 +
  113 + guard let url = components.url else {
  114 + throw APIClientError.invalidURL
  115 + }
  116 +
  117 + var request = URLRequest(url: url)
  118 + request.httpMethod = endpoint.method.rawValue
  119 + request.timeoutInterval = 30
  120 + request.setValue("application/json", forHTTPHeaderField: "Content-Type")
  121 + request.setValue(WatchUserAgent.share.finalUA.isEmpty ? "doublefeel" : WatchUserAgent.share.finalUA, forHTTPHeaderField: "User-Agent")
  122 +
  123 + if let token = WatchUserinfoManager.share.token, !token.isEmpty {
  124 + request.setValue(token, forHTTPHeaderField: "access_token")
  125 + }
  126 +
  127 + if endpoint.encoding == .json, let parameters = endpoint.parameters {
  128 + request.httpBody = try JSONSerialization.data(withJSONObject: parameters)
  129 + }
  130 +
  131 + return request
  132 + }
  133 +
  134 + private func parseErrorMessage(from data: Data) -> String? {
  135 + guard !data.isEmpty,
  136 + let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
  137 + return nil
  138 + }
  139 + return object["error"] as? String
  140 + ?? object["message"] as? String
  141 + ?? object["detail"] as? String
  142 + }
  143 +}
  144 +
  145 +extension Error {
  146 + func getToastErrorDescription() -> String {
  147 + if let localizedError = self as? LocalizedError,
  148 + let description = localizedError.errorDescription {
  149 + return description
  150 + }
  151 + return localizedDescription
  152 + }
  153 +}
  1 +//
  2 +// HealthEndpoint.swift
  3 +// hippo-watch Watch App
  4 +//
  5 +// Created by shihao on 2025/7/8.
  6 +//
  7 +
  8 +import Foundation
  9 +
  10 +enum HealthEndpoint: APIEndpoint {
  11 + case getTodayStatusInfo
  12 + case getLatestHRV
  13 + case upload(data: [String: Any])
  14 + case uploadPulse(pulseType: PulseType)
  15 +
  16 + var path: String {
  17 + switch self {
  18 + case .getTodayStatusInfo:
  19 + return "/client/doublefeel/health/info_today/"
  20 + case .getLatestHRV:
  21 + return "/client/doublefeel/health/lastest_hrv/"
  22 + case .upload:
  23 + return "/client/doublefeel/health/data_upload/common/"
  24 + case .uploadPulse:
  25 + return "/client/doublefeel/health/pulse/"
  26 + }
  27 + }
  28 +
  29 + var method: HTTPMethod {
  30 + switch self {
  31 + case .getTodayStatusInfo:
  32 + return .get
  33 + case .getLatestHRV:
  34 + return .get
  35 + case .upload:
  36 + return .post
  37 + case .uploadPulse:
  38 + return .post
  39 + }
  40 + }
  41 +
  42 + var parameters: [String: Any]? {
  43 + switch self {
  44 + case .getTodayStatusInfo:
  45 + return ["is_other": 1]
  46 + case .getLatestHRV:
  47 + return nil
  48 + case .upload(let data):
  49 + return ["data_list": [data]]
  50 + case .uploadPulse(let pulseType):
  51 + return ["pulse_type": pulseType.rawValue]
  52 + }
  53 + }
  54 +
  55 + var encoding: ParameterEncoding {
  56 + switch self {
  57 + case .getTodayStatusInfo:
  58 + return .url
  59 + case .getLatestHRV:
  60 + return .url
  61 + case .upload:
  62 + return .json
  63 + case .uploadPulse:
  64 + return .json
  65 + }
  66 + }
  67 +}
  68 +