Commit fb3eedec9456d26ba3b7e36b4092b342fea7ee7e

Authored by 权海
1 parent 1393e4bf

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

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

xx
Showing 100 changed files with 2816 additions and 17 deletions

Too many changes to show.

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

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