Commit fb3eedec9456d26ba3b7e36b4092b342fea7ee7e
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.
| @@ -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 | + "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 | +} |
No preview for this file type
| @@ -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.77 KB
| 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.63 KB
| 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 | +} |
581 Bytes
| 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 | +} |
ios/Runner Watch App/Assets.xcassets/pulseDetailDietIcon.imageset/pulseDetailDietIcon@2x.png
0 → 100644
904 Bytes
| 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 | +} |
ios/Runner Watch App/Assets.xcassets/pulseDetailSportIcon.imageset/pulseDetailSportIcon@2x.png
0 → 100644
1.08 KB
| 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 | +} |
ios/Runner Watch App/Assets.xcassets/pulseDetailTitleBg.imageset/pulseDetailTitleBg@2x.png
0 → 100644
1.61 KB
| 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.65 KB
| 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 | +} |
ios/Runner Watch App/Assets.xcassets/pulseDetailZhongyiIcon.imageset/pulseDetailZhongyiIcon@2x.png
0 → 100644
917 Bytes
| 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 | +} |
8.14 KB
| 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 | +} |
5.15 KB
| 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 | +} |
ios/Runner Watch App/Assets.xcassets/pulseWaveformEmpty.imageset/pulseWaveformEmpty@2x.png
0 → 100644
6.34 KB
| 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 | +} |
9.14 KB
| 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 | +} |
10.5 KB
| 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 | +} |
6.34 KB
| 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 | +} |
5.02 KB
| 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 | +} |
7.46 KB
| 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 | +} |
5.71 KB
| 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.59 KB
| 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.81 KB
| 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 | +} |
7.34 KB
| 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 | +} |
740 Bytes
| 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 | +} |
696 Bytes
| 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 | +} |
ios/Runner Watch App/Color+Extension.swift
0 → 100644
| 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 | +} |
ios/Runner Watch App/DesignSize.swift
0 → 100644
| 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 | +} |
ios/Runner Watch App/MainPagingView.swift
0 → 100644
| 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 | +// 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 | +} |
ios/Runner Watch App/Models/LatestHRV.swift
0 → 100644
| 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 | +} |
ios/Runner Watch App/PKDetailView.swift
0 → 100644
| 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 | +} |
ios/Runner Watch App/PKDetailViewModel.swift
0 → 100644
| 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 | +} |
ios/Runner Watch App/Pulse/PulseModels.swift
0 → 100644
| 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 | +} |
487 KB
ios/Runner Watch App/Resource/catNormal.gif
0 → 100644
258 KB
169 KB
424 KB
ios/Runner Watch App/Resource/dogNormal.gif
0 → 100644
158 KB
143 KB
407 KB
163 KB
147 KB
76.7 KB
101 KB
105 KB
377 KB
116 KB
140 KB
55.3 KB
| @@ -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> |
ios/Runner Watch App/Service/APIClient.swift
0 → 100644
| 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 | + |
-
Please register or login to post a comment