Commit d34cdac8c227c3051ec6e4680dbaf7c2602a0204

Authored by 权海
1 parent bb25c4b6

feat(ui):同步原生项目 healthkit上传

Showing 46 changed files with 2928 additions and 860 deletions
  1 +{
  2 + "images" : [
  3 + {
  4 + "idiom" : "universal",
  5 + "scale" : "1x"
  6 + },
  7 + {
  8 + "filename" : "theme_default_level_1@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" : "theme_default_level_2@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" : "theme_default_level_3@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" : "theme_default_level_4@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" : "theme_default_no_data@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 +import Foundation
  2 +
  3 +enum DebugLogger {
  4 + static func debugLog(_ message: String) {
  5 + #if DEBUG || CI_ENV
  6 + print(message)
  7 + #endif
  8 + }
  9 +}
  1 +//
  2 +// HAppFriend.swift
  3 +// hippo-watch Watch App
  4 +//
  5 +// Created by 权海 on 2026/6/22.
  6 +//
  7 +
  8 +struct HAppFriendList: Codable{
  9 + var list: [HAppFriend]?
  10 +}
  11 +
  12 +struct HAppFriend: Codable {
  13 + var id: Int?
  14 + /// 自己的userId
  15 + var user_id: Int?
  16 + /// 对方的userId
  17 + var friend_user_id: Int?
  18 + var remark_name: String?
  19 + /// 是否显示在表盘
  20 + var show_in_dial: Int?
  21 + var state: Int?
  22 + var create_time: Int?
  23 + var update_time: Int?
  24 + var avatar: String?
  25 +}
  26 +
  1 +//
  2 +// HFriendTodayData.swift
  3 +// hippo-watch Watch App
  4 +//
  5 +// Created by 权海 on 2026/6/23.
  6 +//
  7 +
  8 +struct HFriendTodayData: Codable {
  9 + var avg_hrv: Int?
  10 + var latest_hr: Int?
  11 + var move: Int?
  12 + var steps: Int?
  13 +}
  14 +
  1 +//
  2 +// HVipInfo.swift
  3 +// hippo-watch Watch App
  4 +//
  5 +// Created by 权海 on 2026/6/22.
  6 +//
  7 +
  8 +struct HVipInfo: Codable {
  9 + var is_vip: Bool?
  10 + var vip_is_forever: Bool?
  11 + var is_share: Bool?
  12 +
  13 +
  14 + var isAvaiableVip: Bool{
  15 + is_vip == true || vip_is_forever == true
  16 + }
  17 +}
  18 +
@@ -16,28 +16,32 @@ struct LatestHRV: Codable { @@ -16,28 +16,32 @@ struct LatestHRV: Codable {
16 } 16 }
17 17
18 18
19 -extension HRVStatus { 19 +extension HRVStressState {
20 var watchStatusColors: [Color] { 20 var watchStatusColors: [Color] {
21 switch self { 21 switch self {
22 - case .fullOfEnergy: 22 + case .energetic:
23 return [.init(hex: "#FF6EA271"), 23 return [.init(hex: "#FF6EA271"),
24 .black] 24 .black]
25 case .normal: 25 case .normal:
26 return [.init(hex: "#FF796EA2"), 26 return [.init(hex: "#FF796EA2"),
27 .black] 27 .black]
28 - case .overpressure: 28 + case .slightStress, .stressful:
29 return [.init(hex: "#FF924343"), 29 return [.init(hex: "#FF924343"),
30 .black] 30 .black]
  31 + case .wait:
  32 + return [.black, .black]
31 } 33 }
32 } 34 }
33 35
34 var watchStatusTextColor: Color { 36 var watchStatusTextColor: Color {
35 switch self { 37 switch self {
36 - case .fullOfEnergy: 38 + case .energetic:
37 return .init(hex: "#FF95FF9B") 39 return .init(hex: "#FF95FF9B")
38 case .normal: 40 case .normal:
39 return .init(hex: "#FFE2BBFF") 41 return .init(hex: "#FFE2BBFF")
40 - case .overpressure: 42 + case .slightStress, .stressful:
  43 + return .init(hex: "#FFFF8E8E")
  44 + case .wait:
41 return .init(hex: "#FFFF8E8E") 45 return .init(hex: "#FFFF8E8E")
42 } 46 }
43 } 47 }
  1 +import Foundation
  2 +import SwiftUI
  3 +
  4 +enum HRVStatus: String, Codable {
  5 + case fullOfEnergy = "活力满满"
  6 + case normal = "状态正常"
  7 + case overpressure = "压力过载"
  8 +}
  9 +
  10 +struct HRVData: Codable {
  11 + var value: Double?
  12 + var hrvBaseline: Double?
  13 + var date: Date?
  14 +
  15 + var status: HRVStatus? {
  16 + guard let value else { return nil }
  17 + guard let hrvBaseline, hrvBaseline > 0 else { return .normal }
  18 + let ratio = value / hrvBaseline
  19 + if ratio >= 1.05 { return .fullOfEnergy }
  20 + if ratio < 0.9 { return .overpressure }
  21 + return .normal
  22 + }
  23 +}
  24 +
  25 +struct UserInfo: Codable {
  26 + var id: Int?
  27 + var pairId: Int?
  28 +
  29 + var isPaired: Bool {
  30 + pairId != nil
  31 + }
  32 +}
  33 +
  34 +extension WatchThemeModel {
  35 + var isDefaultTheme: Bool? {
  36 + isOfficial.map { $0 == 1 }
  37 + }
  38 +
  39 + func hrvStatusThumnailImageURL(hrvStatus: HRVStatus) -> String? {
  40 + switch hrvStatus {
  41 + case .fullOfEnergy:
  42 + return energeticImageURL
  43 + case .normal:
  44 + return normalImageURL
  45 + case .overpressure:
  46 + return stressfulImageURL
  47 + }
  48 + }
  49 +}
  50 +
  51 +extension Text {
  52 + func wenyiheiFont(size: CGFloat) -> Text {
  53 + font(.custom("WenYue-XinQingNianTi-NC-W8", size: size))
  54 + }
  55 +}
@@ -7,13 +7,12 @@ @@ -7,13 +7,12 @@
7 7
8 import Foundation 8 import Foundation
9 9
10 -  
11 -struct TodayHealthInfo: Codable {  
12 - var recentData: RecentData?  
13 - var hrvDataList: [HRVData]?  
14 - var sleepDuration: Int 10 +enum HealthType: String{
  11 + case hrv
  12 + case realtime_stress
15 } 13 }
16 14
  15 +
17 struct RecentData: Codable { 16 struct RecentData: Codable {
18 var heartRate: Double? 17 var heartRate: Double?
19 var oxygenSaturation: Int? 18 var oxygenSaturation: Int?
  1 +//
  2 +// UserInfoModel.swift
  3 +// hippo
  4 +//
  5 +// Created by 权海 on 2026/6/26.
  6 +//
  7 +
  8 +import Foundation
  9 +
  10 +struct UserInfoModel: Codable, Equatable{
  11 + var userId: Int?
  12 + var token: String?
  13 + var baseUrl: String?
  14 +
  15 + init(userId: Int? = nil, token: String? = nil, baseUrl: String? = nil) {
  16 + self.userId = userId
  17 + self.token = token
  18 + self.baseUrl = baseUrl
  19 + }
  20 +
  21 + func toJson() -> [String: Any]{
  22 + do{
  23 + let data = try JSONEncoder().encode(self)
  24 + return try JSONSerialization.jsonObject(with: data, options: .fragmentsAllowed) as! [String : Any]
  25 + }catch{
  26 + return [:]
  27 + }
  28 + }
  29 +
  30 + var jsonString: String? {
  31 + guard let data = try? JSONEncoder().encode(self) else {
  32 + return nil
  33 + }
  34 + return String(data: data, encoding: .utf8)
  35 + }
  36 +}
  1 +import Foundation
  2 +
  3 +/// Upload boundaries returned by GET `/client/doublefeel/health/v2/data_upload/common/`.
  4 +/// The API has existed in both direct and `{ "data": ... }` response shapes.
  5 +struct WatchHealthUploadTimeList: Decodable {
  6 + struct Item: Decodable {
  7 + let dataType: Int?
  8 + let latestDataTime: TimeInterval?
  9 +
  10 + enum CodingKeys: String, CodingKey {
  11 + case dataType = "data_type"
  12 + case latestDataTime = "latest_data_time"
  13 + }
  14 + }
  15 +
  16 + let latestDataTimeList: [Item]
  17 +
  18 + private enum CodingKeys: String, CodingKey {
  19 + case latestDataTimeList = "latest_data_time_list"
  20 + case data
  21 + }
  22 +
  23 + init(from decoder: Decoder) throws {
  24 + let container = try decoder.container(keyedBy: CodingKeys.self)
  25 + if let list = try container.decodeIfPresent([Item].self, forKey: .latestDataTimeList) {
  26 + latestDataTimeList = list
  27 + return
  28 + }
  29 + if let wrapped = try container.decodeIfPresent(WatchHealthUploadTimeList.self, forKey: .data) {
  30 + latestDataTimeList = wrapped.latestDataTimeList
  31 + return
  32 + }
  33 + throw DecodingError.dataCorruptedError(
  34 + forKey: .latestDataTimeList,
  35 + in: container,
  36 + debugDescription: "Missing latest_data_time_list"
  37 + )
  38 + }
  39 +
  40 + func latestDate(for dataType: Int) -> Date? {
  41 + latestDataTimeList
  42 + .first { $0.dataType == dataType }?
  43 + .latestDataTime
  44 + .map(Date.init(timeIntervalSince1970:))
  45 + }
  46 +}
@@ -2,161 +2,173 @@ import Foundation @@ -2,161 +2,173 @@ import Foundation
2 import SwiftUI 2 import SwiftUI
3 import HealthKit 3 import HealthKit
4 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 5 +extension WatchThemeModel{
  6 +
  7 + func description(for state: HRVStressState) -> (title: String?, image: Image?){
  8 + switch state {
  9 + case .wait:
  10 + return (state.name, state.defaultThemeImage)
  11 + case .stressful:
  12 + var image: Image?
  13 + if let imageUrl = stressfulImageURL,
  14 + let imageData = AppGroupConstants.defaults?.data(forKey: imageUrl),
  15 + let uiImage = UIImage(data: imageData){
  16 + image = Image(uiImage: uiImage)
71 } 17 }
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) 18 + return (stressfulTitle, image)
  19 + case .slightStress:
  20 + var image: Image?
  21 + if let imageUrl = slightStressImageURL,
  22 + let imageData = AppGroupConstants.defaults?.data(forKey: imageUrl),
  23 + let uiImage = UIImage(data: imageData){
  24 + image = Image(uiImage: uiImage)
  25 + }
  26 + return (slightStressTitle, image)
  27 + case .normal:
  28 + var image: Image?
  29 + if let imageUrl = normalImageURL,
  30 + let imageData = AppGroupConstants.defaults?.data(forKey: imageUrl),
  31 + let uiImage = UIImage(data: imageData){
  32 + image = Image(uiImage: uiImage)
108 } 33 }
109 - if let value = try? container.decodeIfPresent(AvatarResource.self, forKey: codingKey) {  
110 - return value 34 + return (normalTitle, image)
  35 + case .energetic:
  36 + var image: Image?
  37 + if let imageUrl = energeticImageURL,
  38 + let imageData = AppGroupConstants.defaults?.data(forKey: imageUrl),
  39 + let uiImage = UIImage(data: imageData){
  40 + image = Image(uiImage: uiImage)
111 } 41 }
  42 + return (energeticTitle, image)
112 } 43 }
113 - return nil  
114 } 44 }
115 } 45 }
116 46
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"]) 47 +enum HRVStressState: Int, Codable, CaseIterable{
  48 + case wait
  49 + case stressful
  50 + case slightStress
  51 + case normal
  52 + case energetic
  53 +
  54 + var name: String{
  55 + switch self {
  56 + case .wait:
  57 + return "等待数据"
  58 + case .stressful:
  59 + return "压力过载"
  60 + case .slightStress:
  61 + return "注意压力"
  62 + case .normal:
  63 + return "状态正常"
  64 + case .energetic:
  65 + return "活力满满"
  66 + }
135 } 67 }
136 -  
137 - func hrvStatusDescription(hrvStatus: HRVStatus) -> String {  
138 - switch hrvStatus {  
139 - case .fullOfEnergy:  
140 - return positiveDescription ?? hrvStatus.rawValue 68 +
  69 + var themeColor: Color{
  70 + switch self {
  71 + case .energetic:
  72 + return DFWatchFigmaColor.green
141 case .normal: 73 case .normal:
142 - return normalDescription ?? hrvStatus.rawValue  
143 - case .overpressure:  
144 - return negativeDescription ?? hrvStatus.rawValue 74 + return DFWatchFigmaColor.blue
  75 + case .slightStress:
  76 + return DFWatchFigmaColor.orange
  77 + case .stressful:
  78 + return DFWatchFigmaColor.red
  79 + case .wait:
  80 + return DFWatchFigmaColor.grayBar
145 } 81 }
146 } 82 }
147 -  
148 - func hrvStatusThumnailImageURL(hrvStatus: HRVStatus) -> String? {  
149 - switch hrvStatus {  
150 - case .fullOfEnergy:  
151 - return positiveImageURL 83 +
  84 + var defaultThemeImage: Image{
  85 + switch self {
  86 + case .energetic:
  87 + return Image(.themeDefaultLevel1)
152 case .normal: 88 case .normal:
153 - return normalImageURL  
154 - case .overpressure:  
155 - return negativeImageURL 89 + return Image(.themeDefaultLevel2)
  90 + case .slightStress:
  91 + return Image(.themeDefaultLevel3)
  92 + case .stressful:
  93 + return Image(.themeDefaultLevel4)
  94 + case .wait:
  95 + return Image(.themeDefaultNoData)
156 } 96 }
157 } 97 }
158 } 98 }
159 99
  100 +struct WatchHRVData: Codable {
  101 + var latest_hrv_time: Int?
  102 + /// 最新的hrv
  103 + var latest_hrv: Double?
  104 + var hrv_state: HRVStressState?
  105 + var latest_stress_time: Int?
  106 + /// 直接加百分号
  107 + var latest_stress_value: Double?
  108 + var stress_state: HRVStressState?
  109 +
  110 + var friend_latest_hrv_time: Int?
  111 + var friend_latest_hrv: Double?
  112 + var friend_hrv_state: HRVStressState?
  113 +
  114 + var friend_latest_stress_time: Int?
  115 + var friend_latest_stress_value: Double?
  116 + var friend_stress_state: HRVStressState?
  117 +
  118 + var myHRVData: WatchHRVPersonalData{
  119 + return WatchHRVPersonalData(isMe: true,
  120 + latest_hrv: latest_hrv,
  121 + latest_hrv_time: latest_hrv_time,
  122 + hrv_state: hrv_state,
  123 + latest_stress_time: latest_stress_time,
  124 + latest_stress_value: latest_stress_value,
  125 + stress_state: stress_state
  126 + )
  127 + }
  128 + var otherHRVData: WatchHRVPersonalData{
  129 + return WatchHRVPersonalData(isMe: false,
  130 + latest_hrv: friend_latest_hrv,
  131 + latest_hrv_time: friend_latest_hrv_time,
  132 + hrv_state: friend_hrv_state,
  133 + latest_stress_time: friend_latest_stress_time,
  134 + latest_stress_value: friend_latest_stress_value,
  135 + stress_state: friend_stress_state
  136 + )
  137 + }
  138 +}
  139 +struct WatchHRVPersonalData{
  140 + var isMe: Bool
  141 +
  142 + var latest_hrv: Double?
  143 + var latest_hrv_time: Int?
  144 + var hrv_state: HRVStressState?
  145 +
  146 + var latest_stress_time: Int?
  147 + var latest_stress_value: Double?
  148 + var stress_state: HRVStressState?
  149 +}
  150 +
  151 +struct ActivityTarget: Codable {
  152 + var id: Int?
  153 + var create_time: Int?
  154 + var update_time: Int?
  155 + var user_id: Int?
  156 + var move: Int?
  157 + var step: Int?
  158 + var stand: Int?
  159 + var exercise: Int?
  160 + var activity_move_mode: Int?
  161 + var active_energy_burned: Double?
  162 + var active_energy_burned_goal: Double?
  163 + var apple_move_time: Double?
  164 + var apple_move_time_goal: Double?
  165 + var apple_exercise_time: Double?
  166 + var exercise_time_goal: Double?
  167 + var apple_stand_hours: Double?
  168 + var stand_hours_goal: Double?
  169 +}
  170 +
  171 +
160 extension String { 172 extension String {
161 var url: URL? { 173 var url: URL? {
162 URL(string: self) 174 URL(string: self)
@@ -190,12 +202,6 @@ extension Array where Element == Double { @@ -190,12 +202,6 @@ extension Array where Element == Double {
190 } 202 }
191 } 203 }
192 204
193 -extension Text {  
194 - func wenyiheiFont(size: CGFloat) -> Text {  
195 - font(.custom("WenYue-XinQingNianTi-NC-W8", size: size))  
196 - }  
197 -}  
198 -  
199 struct SleepStage { 205 struct SleepStage {
200 let stage: HKCategoryValueSleepAnalysis 206 let stage: HKCategoryValueSleepAnalysis
201 let startTime: Date 207 let startTime: Date
@@ -242,61 +248,3 @@ private struct DynamicCodingKey: CodingKey { @@ -242,61 +248,3 @@ private struct DynamicCodingKey: CodingKey {
242 } 248 }
243 } 249 }
244 250
245 -private extension UserInfo {  
246 - static func decodeString(from container: KeyedDecodingContainer<DynamicCodingKey>, keys: [String]) -> String? {  
247 - for key in keys {  
248 - if let codingKey = DynamicCodingKey(stringValue: key),  
249 - let value = try? container.decodeIfPresent(String.self, forKey: codingKey) {  
250 - return value  
251 - }  
252 - }  
253 - return nil  
254 - }  
255 -  
256 - static func decodeInt(from container: KeyedDecodingContainer<DynamicCodingKey>, keys: [String]) -> Int? {  
257 - for key in keys {  
258 - guard let codingKey = DynamicCodingKey(stringValue: key) else { continue }  
259 - if let value = try? container.decodeIfPresent(Int.self, forKey: codingKey) {  
260 - return value  
261 - }  
262 - if let value = try? container.decodeIfPresent(String.self, forKey: codingKey), let intValue = Int(value) {  
263 - return intValue  
264 - }  
265 - }  
266 - return nil  
267 - }  
268 -}  
269 -  
270 -extension UserInfo {  
271 - var isPaired: Bool {  
272 - pairId != nil  
273 - }  
274 -}  
275 -  
276 -private extension WatchThemeModel {  
277 - static func decodeString(from container: KeyedDecodingContainer<DynamicCodingKey>, keys: [String]) -> String? {  
278 - for key in keys {  
279 - if let codingKey = DynamicCodingKey(stringValue: key),  
280 - let value = try? container.decodeIfPresent(String.self, forKey: codingKey) {  
281 - return value  
282 - }  
283 - }  
284 - return nil  
285 - }  
286 -  
287 - static func decodeBool(from container: KeyedDecodingContainer<DynamicCodingKey>, keys: [String]) -> Bool? {  
288 - for key in keys {  
289 - guard let codingKey = DynamicCodingKey(stringValue: key) else { continue }  
290 - if let value = try? container.decodeIfPresent(Bool.self, forKey: codingKey) {  
291 - return value  
292 - }  
293 - if let value = try? container.decodeIfPresent(Int.self, forKey: codingKey) {  
294 - return value != 0  
295 - }  
296 - if let value = try? container.decodeIfPresent(String.self, forKey: codingKey) {  
297 - return ["1", "true", "yes"].contains(value.lowercased())  
298 - }  
299 - }  
300 - return nil  
301 - }  
302 -}  
  1 +//
  2 +// WatchThemeModel.swift
  3 +// hippo
  4 +//
  5 +// Created by 权海 on 2026/6/26.
  6 +//
  7 +
  8 +struct WatchThemeModel: Codable, Equatable {
  9 + var id: Int?
  10 + var userId: Int?
  11 + var themeName: String?
  12 + var isOfficial: Int?
  13 +
  14 + var energeticTitle: String?
  15 + var energeticImageURL: String?
  16 +
  17 + var normalTitle: String?
  18 + var normalImageURL: String?
  19 +
  20 + var slightStressTitle: String?
  21 + var slightStressImageURL: String?
  22 +
  23 + var stressfulTitle: String?
  24 + var stressfulImageURL: String?
  25 +
  26 + static func == (lhs: Self, rhs: Self) -> Bool{
  27 + lhs.energeticImageURL == rhs.energeticImageURL &&
  28 + lhs.normalImageURL == rhs.normalImageURL &&
  29 + lhs.slightStressImageURL == rhs.slightStressImageURL &&
  30 + lhs.stressfulImageURL == rhs.stressfulImageURL
  31 + }
  32 +
  33 + enum CodingKeys: String, CodingKey {
  34 + case id
  35 + case userId = "user_id"
  36 + case themeName = "theme_name"
  37 + case isOfficial = "is_official"
  38 + case energeticTitle = "energetic_description"
  39 + case energeticImageURL = "energetic_image"
  40 + case normalTitle = "normal_description"
  41 + case normalImageURL = "normal_image"
  42 + case slightStressTitle = "slight_stressful_description"
  43 + case slightStressImageURL = "slight_stressful_image"
  44 + case stressfulTitle = "stressful_description"
  45 + case stressfulImageURL = "stressful_image"
  46 + }
  47 +}
  48 +
@@ -7,20 +7,32 @@ @@ -7,20 +7,32 @@
7 7
8 import Foundation 8 import Foundation
9 9
  10 +
10 enum HealthEndpoint: APIEndpoint { 11 enum HealthEndpoint: APIEndpoint {
11 - case getTodayStatusInfo  
12 - case getLatestHRV 12 + case getActivityTarget
  13 + case getHealthData(type: HealthType)
  14 + case getTodayData(isFriend: Bool)
13 case upload(data: [String: Any]) 15 case upload(data: [String: Any])
  16 + case uploadBatch(data: [[String: Any]])
  17 + case uploadSleep(data: [[String: Any]])
  18 + case uploadActivityTarget(data: [String: Any])
  19 + case getUploadTimes
14 case uploadPulse(pulseType: PulseType) 20 case uploadPulse(pulseType: PulseType)
15 21
16 var path: String { 22 var path: String {
17 switch self { 23 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 .getActivityTarget:
  25 + return "/client/doublefeel/health/v2/activity_target/"
  26 + case .getHealthData:
  27 + return "/client/doublefeel/health/v2/dial_health_info/"
  28 + case .getTodayData:
  29 + return "/client/doublefeel/health/v2/dial_today_info/"
  30 + case .upload, .uploadBatch, .getUploadTimes:
  31 + return "/client/doublefeel/health/v2/data_upload/common/"
  32 + case .uploadSleep:
  33 + return "/client/doublefeel/health/v2/data_upload/sleep/"
  34 + case .uploadActivityTarget:
  35 + return "/client/doublefeel/health/v2/activity_target/"
24 case .uploadPulse: 36 case .uploadPulse:
25 return "/client/doublefeel/health/pulse/" 37 return "/client/doublefeel/health/pulse/"
26 } 38 }
@@ -28,25 +40,27 @@ enum HealthEndpoint: APIEndpoint { @@ -28,25 +40,27 @@ enum HealthEndpoint: APIEndpoint {
28 40
29 var method: HTTPMethod { 41 var method: HTTPMethod {
30 switch self { 42 switch self {
31 - case .getTodayStatusInfo:  
32 - return .get  
33 - case .getLatestHRV: 43 + case .getActivityTarget, .getHealthData, .getTodayData, .getUploadTimes:
34 return .get 44 return .get
35 - case .upload:  
36 - return .post  
37 - case .uploadPulse: 45 + case .upload, .uploadBatch, .uploadSleep, .uploadActivityTarget, .uploadPulse:
38 return .post 46 return .post
39 } 47 }
40 } 48 }
41 49
42 var parameters: [String: Any]? { 50 var parameters: [String: Any]? {
43 switch self { 51 switch self {
44 - case .getTodayStatusInfo:  
45 - return ["is_other": 1]  
46 - case .getLatestHRV: 52 + case .getActivityTarget, .getUploadTimes:
47 return nil 53 return nil
  54 + case .getTodayData(let isFriend):
  55 + return ["is_dial_friend": isFriend ? 1 : 0]
  56 + case let .getHealthData(type):
  57 + return ["show_type": type.rawValue]
48 case .upload(let data): 58 case .upload(let data):
49 return ["data_list": [data]] 59 return ["data_list": [data]]
  60 + case .uploadBatch(let data), .uploadSleep(let data):
  61 + return ["data_list": data]
  62 + case .uploadActivityTarget(let data):
  63 + return data
50 case .uploadPulse(let pulseType): 64 case .uploadPulse(let pulseType):
51 return ["pulse_type": pulseType.rawValue] 65 return ["pulse_type": pulseType.rawValue]
52 } 66 }
@@ -54,15 +68,10 @@ enum HealthEndpoint: APIEndpoint { @@ -54,15 +68,10 @@ enum HealthEndpoint: APIEndpoint {
54 68
55 var encoding: ParameterEncoding { 69 var encoding: ParameterEncoding {
56 switch self { 70 switch self {
57 - case .getTodayStatusInfo: 71 + case .getActivityTarget, .getHealthData, .getTodayData, .getUploadTimes:
58 return .url 72 return .url
59 - case .getLatestHRV:  
60 - return .url  
61 - case .upload:  
62 - return .json  
63 - case .uploadPulse: 73 + case .upload, .uploadBatch, .uploadSleep, .uploadActivityTarget, .uploadPulse:
64 return .json 74 return .json
65 } 75 }
66 } 76 }
67 } 77 }
68 -  
@@ -12,18 +12,42 @@ class HealthService { @@ -12,18 +12,42 @@ class HealthService {
12 12
13 private let apiClient = APIClient.shared 13 private let apiClient = APIClient.shared
14 14
15 - func getTodayStatusInfo() async throws -> TodayHealthInfo {  
16 - return try await apiClient.request(HealthEndpoint.getTodayStatusInfo) 15 + func getActivityTarget() async throws -> ActivityTarget {
  16 + return try await apiClient.request(HealthEndpoint.getActivityTarget)
17 } 17 }
18 18
19 - func getLatestHRV() async throws -> LatestHRV {  
20 - return try await apiClient.request(HealthEndpoint.getLatestHRV) 19 + func getHealthData(type: HealthType) async throws -> WatchHRVData {
  20 + return try await apiClient.request(HealthEndpoint.getHealthData(type: type))
  21 + }
  22 +
  23 + func getMyData() async throws -> HFriendTodayData {
  24 + return try await apiClient.request(HealthEndpoint.getTodayData(isFriend: false))
  25 + }
  26 +
  27 + func getFriendData() async throws -> HFriendTodayData {
  28 + return try await apiClient.request(HealthEndpoint.getTodayData(isFriend: true))
21 } 29 }
22 30
23 func upload(data: [String: Any]) async throws -> LatestHRV { 31 func upload(data: [String: Any]) async throws -> LatestHRV {
24 return try await apiClient.request(HealthEndpoint.upload(data: data)) 32 return try await apiClient.request(HealthEndpoint.upload(data: data))
25 } 33 }
26 - 34 +
  35 + func uploadBatch(data: [[String: Any]]) async throws -> EmptyResponse {
  36 + return try await apiClient.request(HealthEndpoint.uploadBatch(data: data))
  37 + }
  38 +
  39 + func uploadSleep(data: [[String: Any]]) async throws -> EmptyResponse {
  40 + return try await apiClient.request(HealthEndpoint.uploadSleep(data: data))
  41 + }
  42 +
  43 + func uploadActivityTarget(data: [String: Any]) async throws -> EmptyResponse {
  44 + return try await apiClient.request(HealthEndpoint.uploadActivityTarget(data: data))
  45 + }
  46 +
  47 + func getUploadTimes() async throws -> WatchHealthUploadTimeList {
  48 + return try await apiClient.request(HealthEndpoint.getUploadTimes)
  49 + }
  50 +
27 func uploadPulse(pulseType: PulseType) async throws -> EmptyResponse { 51 func uploadPulse(pulseType: PulseType) async throws -> EmptyResponse {
28 return try await apiClient.request(HealthEndpoint.uploadPulse(pulseType: pulseType)) 52 return try await apiClient.request(HealthEndpoint.uploadPulse(pulseType: pulseType))
29 } 53 }
  1 +//
  2 +// UserInfoEndpoint.swift
  3 +// hippo-watch Watch App
  4 +//
  5 +// Created by 权海 on 2026/6/22.
  6 +//
  7 +
  8 +import Foundation
  9 +
  10 +enum UserInfoEndpoint: APIEndpoint {
  11 + case getVipInfo
  12 + case getMyUserInfo
  13 + case getFriendInfo
  14 + case getWatchTheme
  15 +
  16 + case uploadDeviceInfo(deviceInfo: String?, deviceToken: String?, watchDeviceToken: String?)
  17 +
  18 + var path: String {
  19 + switch self {
  20 + case .getVipInfo:
  21 + return "/client/doublefeel/user/vip/info/"
  22 + case .getMyUserInfo:
  23 + return "/client/doublefeel/user/info/"
  24 + case .getFriendInfo:
  25 + return "/client/doublefeel/health/v2/friends/"
  26 + case .getWatchTheme:
  27 + return "/client/doublefeel/theme/watch_theme/list/"
  28 + case .uploadDeviceInfo:
  29 + return "/client/doublefeel/user/device/"
  30 + }
  31 + }
  32 +
  33 + var method: HTTPMethod {
  34 + switch self {
  35 + case .getVipInfo, .getMyUserInfo, .getFriendInfo, .getWatchTheme:
  36 + return .get
  37 + case .uploadDeviceInfo:
  38 + return .post
  39 + }
  40 + }
  41 +
  42 + var parameters: [String: Any]? {
  43 + switch self {
  44 + case .getVipInfo, .getMyUserInfo, .getWatchTheme:
  45 + return nil
  46 + case .getFriendInfo:
  47 + let params: [String: Any] = [
  48 + "with_health_data": 0
  49 + ]
  50 + return params
  51 + case let .uploadDeviceInfo(deviceInfo, deviceToken, watchDeviceToken):
  52 + var params: [String: Any] = [:]
  53 + params["push_platform"] = "100"
  54 + params["device_info"] = deviceInfo
  55 + params["device_token"] = deviceToken
  56 + params["watch_device_token"] = watchDeviceToken
  57 + return params
  58 + }
  59 + }
  60 +
  61 + var encoding: ParameterEncoding {
  62 + switch self {
  63 + case .getVipInfo, .getMyUserInfo, .getFriendInfo, .getWatchTheme:
  64 + return .url
  65 + case .uploadDeviceInfo:
  66 + return .json
  67 + }
  68 + }
  69 +}
  1 +//
  2 +// UserInfoService.swift
  3 +// hippo-watch Watch App
  4 +//
  5 +// Created by 权海 on 2026/6/22.
  6 +//
  7 +
  8 +import Foundation
  9 +
  10 +struct EmptyJson: Codable{
  11 +
  12 +}
  13 +
  14 +class UserInfoService {
  15 + private let apiClient = APIClient.shared
  16 +
  17 + func getVipInfo() async -> HVipInfo? {
  18 + do{
  19 + return try await apiClient.request(UserInfoEndpoint.getVipInfo)
  20 + }catch{
  21 + print("getVipInfo error: \(error.localizedDescription)")
  22 + return nil
  23 + }
  24 + }
  25 +
  26 +// func getMyUserInfo() async -> UserInfo?{
  27 +// do{
  28 +// return try await apiClient.request(UserInfoEndpoint.getMyUserInfo)
  29 +// }catch{
  30 +// print("getMyUserInfo error: \(error.localizedDescription)")
  31 +// return nil
  32 +// }
  33 +// }
  34 +
  35 + func getFriendInfo() async -> HAppFriendList? {
  36 + do{
  37 + return try await apiClient.request(UserInfoEndpoint.getFriendInfo)
  38 + }catch{
  39 + print("getFriendInfo error: \(error.localizedDescription)")
  40 + return nil
  41 + }
  42 + }
  43 +
  44 + func uploadDeviceInfo() async -> EmptyJson?{
  45 + do{
  46 + let deviceInfo = AppGroupConstants.defaults?.string(forKey: AppGroupConstants.Key.deviceInfo)
  47 + let deviceToken = AppGroupConstants.defaults?.string(forKey: AppGroupConstants.Key.appDeviceToken)
  48 + let watchToken = AppGroupConstants.defaults?.string(forKey: AppGroupConstants.Key.watchDeviceToken)
  49 +
  50 + return try await apiClient.request(UserInfoEndpoint.uploadDeviceInfo(deviceInfo: deviceInfo, deviceToken: deviceToken, watchDeviceToken: watchToken))
  51 + }catch{
  52 + print("getFriendInfo error: \(error.localizedDescription)")
  53 + return nil
  54 + }
  55 + }
  56 +}
@@ -8,12 +8,12 @@ @@ -8,12 +8,12 @@
8 import Foundation 8 import Foundation
9 9
10 enum ThemeEndpoint: APIEndpoint { 10 enum ThemeEndpoint: APIEndpoint {
11 - case getCurrentTheme(isOther: Bool) 11 + case getCurrentTheme(userId: Int?)
12 12
13 var path: String { 13 var path: String {
14 switch self { 14 switch self {
15 case .getCurrentTheme: 15 case .getCurrentTheme:
16 - return "/client/doublefeel/theme/watch_theme/active/" 16 + return "/client/doublefeel/theme/v2/watch_theme/active/"
17 } 17 }
18 } 18 }
19 19
@@ -26,8 +26,11 @@ enum ThemeEndpoint: APIEndpoint { @@ -26,8 +26,11 @@ enum ThemeEndpoint: APIEndpoint {
26 26
27 var parameters: [String : Any]? { 27 var parameters: [String : Any]? {
28 switch self { 28 switch self {
29 - case .getCurrentTheme(let isOther):  
30 - return ["is_other": isOther ? 1 : 0] 29 + case .getCurrentTheme(let userId):
  30 + if let userId{
  31 + return ["query_user_id": userId]
  32 + }
  33 + return nil
31 } 34 }
32 } 35 }
33 36
@@ -10,7 +10,12 @@ import Foundation @@ -10,7 +10,12 @@ import Foundation
10 class ThemeService { 10 class ThemeService {
11 private let apiClient = APIClient.shared 11 private let apiClient = APIClient.shared
12 12
  13 + func getCurrentTheme(userId: Int?) async throws -> WatchThemeModel {
  14 + return try await apiClient.request(ThemeEndpoint.getCurrentTheme(userId: userId))
  15 + }
  16 +
13 func getCurrentTheme(isOther: Bool) async throws -> WatchThemeModel { 17 func getCurrentTheme(isOther: Bool) async throws -> WatchThemeModel {
14 - return try await apiClient.request(ThemeEndpoint.getCurrentTheme(isOther: isOther)) 18 + let userId = isOther ? WatchUserinfoManager.share.displayFriend?.user_id : nil
  19 + return try await getCurrentTheme(userId: userId)
15 } 20 }
16 } 21 }
@@ -6,26 +6,18 @@ @@ -6,26 +6,18 @@
6 // 6 //
7 7
8 import Foundation 8 import Foundation
9 -//import ThinkingDataAnalyticsExtension  
10 9
11 class ThinkSDKUtil { 10 class ThinkSDKUtil {
12 11
13 static func initSDK() { 12 static func initSDK() {
14 - /*  
15 -#if DEBUG || CI_ENV  
16 - TDAnalyticsExtension.start(withAppId: "1efaeef2c4c04a3bac557d78899ecee6", serverUrl: "https://ta-api.didiapp.com")  
17 -#else  
18 - TDAnalyticsExtension.start(withAppId: "6e53fb7a8f5f483081c15b8f5f017401", serverUrl: "https://ta-api.didiapp.com")  
19 -#endif  
20 - */  
21 } 13 }
22 14
23 static func track(eventName: String, 15 static func track(eventName: String,
24 properties: [String: Any]? = nil) { 16 properties: [String: Any]? = nil) {
25 -// TDAnalyticsExtension.track(eventName, properties: properties)  
26 } 17 }
27 18
28 static func login(_ uid: Int) { 19 static func login(_ uid: Int) {
29 -// TDAnalyticsExtension.login(uid.string) 20 + }
  21 + static func logout(){
30 } 22 }
31 } 23 }
@@ -8,7 +8,15 @@ @@ -8,7 +8,15 @@
8 import WatchKit 8 import WatchKit
9 import UserNotifications 9 import UserNotifications
10 10
  11 +@MainActor
11 final class WatchAppDelegate: NSObject, WKApplicationDelegate, UNUserNotificationCenterDelegate { 12 final class WatchAppDelegate: NSObject, WKApplicationDelegate, UNUserNotificationCenterDelegate {
  13 + private enum HealthAuthorizationState {
  14 + case notRequested
  15 + case requesting
  16 + case completed
  17 + }
  18 +
  19 + private var healthAuthorizationState: HealthAuthorizationState = .notRequested
12 20
13 func applicationDidFinishLaunching() { 21 func applicationDidFinishLaunching() {
14 setup() 22 setup()
@@ -16,16 +24,49 @@ final class WatchAppDelegate: NSObject, WKApplicationDelegate, UNUserNotificatio @@ -16,16 +24,49 @@ final class WatchAppDelegate: NSObject, WKApplicationDelegate, UNUserNotificatio
16 center.delegate = self 24 center.delegate = self
17 requestNotificationPermission() 25 requestNotificationPermission()
18 } 26 }
19 - 27 +
20 private func setup(){ 28 private func setup(){
21 WatchUserAgent.share.genUA() 29 WatchUserAgent.share.genUA()
22 WatchSessionManager.share.addListen() 30 WatchSessionManager.share.addListen()
23 - WatchDataManager.share.requestAuthForHealth()  
24 - WatchDataManager.share.fetchMyTodayData()  
25 - WatchDataManager.share.fetchOtherTodayData()  
26 - ThinkSDKUtil.initSDK()  
27 - if let id = WatchUserinfoManager.share.myUserinfo?.id {  
28 - ThinkSDKUtil.login(id) 31 + WatchUserinfoManager.share.setup()
  32 + requestHealthAuthorizationIfNeeded()
  33 + }
  34 +
  35 + func sceneDidBecomeActive() {
  36 + switch healthAuthorizationState {
  37 + case .completed:
  38 + // Local HealthKit reads and observer registration never depend on
  39 + // login state. Upload methods perform their own token check.
  40 + WatchHealthWidgetRefreshCoordinator.shared.refreshAll()
  41 + WatchHealthObserverUploader.shared.startObserversIfNeeded()
  42 + WatchHealthObserverUploader.shared.performFullUpload()
  43 + case .notRequested:
  44 + requestHealthAuthorizationIfNeeded()
  45 + case .requesting:
  46 + // The completion below will refresh data and start observers.
  47 + break
  48 + }
  49 + }
  50 +
  51 + private func requestHealthAuthorizationIfNeeded() {
  52 + guard case .notRequested = healthAuthorizationState else { return }
  53 + healthAuthorizationState = .requesting
  54 + WatchDataManager.share.requestAuthForHealth { [weak self] success in
  55 + guard let self else { return }
  56 + if !success {
  57 + self.healthAuthorizationState = .notRequested
  58 + return
  59 + }
  60 +
  61 + self.healthAuthorizationState = .completed
  62 + WatchDataManager.share.fetchMyTodayData()
  63 + WatchDataManager.share.getLatestHealthData()
  64 + WatchDataManager.share.getAcitivtyTarget()
  65 + // Register observers only after the authorization request has
  66 + // completed. Registering earlier can make background delivery fail
  67 + // permanently for this process.
  68 + WatchHealthObserverUploader.shared.startObserversIfNeeded()
  69 + WatchHealthObserverUploader.shared.performFullUpload()
29 } 70 }
30 } 71 }
31 72
@@ -33,7 +74,6 @@ final class WatchAppDelegate: NSObject, WKApplicationDelegate, UNUserNotificatio @@ -33,7 +74,6 @@ final class WatchAppDelegate: NSObject, WKApplicationDelegate, UNUserNotificatio
33 UNUserNotificationCenter.current().requestAuthorization( 74 UNUserNotificationCenter.current().requestAuthorization(
34 options: [.alert, .sound, .badge] 75 options: [.alert, .sound, .badge]
35 ) { granted, error in 76 ) { granted, error in
36 - print("watch notification permission:", granted, error as Any)  
37 guard granted else { return } 77 guard granted else { return }
38 DispatchQueue.main.async { 78 DispatchQueue.main.async {
39 WKApplication.shared().registerForRemoteNotifications() 79 WKApplication.shared().registerForRemoteNotifications()
@@ -43,14 +83,15 @@ final class WatchAppDelegate: NSObject, WKApplicationDelegate, UNUserNotificatio @@ -43,14 +83,15 @@ final class WatchAppDelegate: NSObject, WKApplicationDelegate, UNUserNotificatio
43 83
44 func didRegisterForRemoteNotifications(withDeviceToken deviceToken: Data) { 84 func didRegisterForRemoteNotifications(withDeviceToken deviceToken: Data) {
45 let token = deviceToken.map { String(format: "%02x", $0) }.joined() 85 let token = deviceToken.map { String(format: "%02x", $0) }.joined()
46 - print("watch apns token:", token) 86 + AppGroupConstants.defaults?.set(token, forKey: AppGroupConstants.Key.watchDeviceToken)
47 87
48 - // TODO: 上传到你的服务端  
49 - // uploadWatchToken(token) 88 + Task{
  89 + await UserInfoService().uploadDeviceInfo()
  90 + }
50 } 91 }
51 92
52 func didFailToRegisterForRemoteNotificationsWithError(_ error: Error) { 93 func didFailToRegisterForRemoteNotificationsWithError(_ error: Error) {
53 - print("watch register push failed:", error) 94 +
54 } 95 }
55 96
56 // App 在前台收到通知:热启动前台展示 97 // App 在前台收到通知:热启动前台展示
@@ -10,19 +10,25 @@ import HealthKit @@ -10,19 +10,25 @@ import HealthKit
10 import WidgetKit 10 import WidgetKit
11 import Combine 11 import Combine
12 12
13 -class WatchDataManager: ObservableObject { 13 +@MainActor
  14 +final class WatchDataManager: ObservableObject {
14 static let share = WatchDataManager() 15 static let share = WatchDataManager()
15 private let healthStore = HKHealthStore() 16 private let healthStore = HKHealthStore()
16 - @Published var myLatestHRVData: HRVData?  
17 - @Published var otherLatestHRVData: HRVData? 17 +
  18 + @Published var latestHRVData: WatchHRVData?
  19 + @Published var activityTarget: ActivityTarget?
18 20
19 - @Published var myTodayAvgHRV: Double? 21 + @Published var myData: HFriendTodayData?
  22 + @Published var friendData: HFriendTodayData?
  23 +
  24 +// @Published var myTodayAvgHRV: Double?
20 @Published var myLatestHeartRate: Double? 25 @Published var myLatestHeartRate: Double?
21 @Published var myTodayStepCount: Int? 26 @Published var myTodayStepCount: Int?
22 @Published var myTodayActiveEnergy: Double? 27 @Published var myTodayActiveEnergy: Double?
  28 + @Published var myTodayExerciseTime: Double?
  29 + @Published var myTodayStandTime: Double?
23 @Published var myTodaySleepTime: TimeInterval? 30 @Published var myTodaySleepTime: TimeInterval?
24 - @Published var otherTodayHealthInfo: TodayHealthInfo?  
25 - 31 +
26 // 想读取的类型,比如心率、步数 32 // 想读取的类型,比如心率、步数
27 private let readTypes: Set<HKObjectType> = [ 33 private let readTypes: Set<HKObjectType> = [
28 HKObjectType.quantityType(forIdentifier: .heartRate)!, 34 HKObjectType.quantityType(forIdentifier: .heartRate)!,
@@ -33,7 +39,7 @@ class WatchDataManager: ObservableObject { @@ -33,7 +39,7 @@ class WatchDataManager: ObservableObject {
33 HKObjectType.quantityType(forIdentifier: .activeEnergyBurned)!, 39 HKObjectType.quantityType(forIdentifier: .activeEnergyBurned)!,
34 HKObjectType.quantityType(forIdentifier: .appleExerciseTime)!, 40 HKObjectType.quantityType(forIdentifier: .appleExerciseTime)!,
35 HKObjectType.quantityType(forIdentifier: .appleStandTime)!, 41 HKObjectType.quantityType(forIdentifier: .appleStandTime)!,
36 - 42 +
37 HKObjectType.quantityType(forIdentifier: .walkingHeartRateAverage)!,//步行心率 43 HKObjectType.quantityType(forIdentifier: .walkingHeartRateAverage)!,//步行心率
38 HKObjectType.quantityType(forIdentifier: .restingHeartRate)!,//静息心率 44 HKObjectType.quantityType(forIdentifier: .restingHeartRate)!,//静息心率
39 //睡眠心率(注) 45 //睡眠心率(注)
@@ -42,9 +48,18 @@ class WatchDataManager: ObservableObject { @@ -42,9 +48,18 @@ class WatchDataManager: ObservableObject {
42 , 48 ,
43 HKObjectType.quantityType(forIdentifier: .appleSleepingWristTemperature)!, 49 HKObjectType.quantityType(forIdentifier: .appleSleepingWristTemperature)!,
44 HKObjectType.quantityType(forIdentifier: .respiratoryRate)!, 50 HKObjectType.quantityType(forIdentifier: .respiratoryRate)!,
  51 + HKObjectType.categoryType(forIdentifier: .irregularHeartRhythmEvent)!,
45 ] 52 ]
46 -  
47 - func requestAuthForHealth() { 53 +
  54 + func requestAuthForHealth(completion: ((Bool) -> Void)?) {
  55 + guard HKHealthStore.isHealthDataAvailable() else {
  56 + print("❌ HealthKit is not available on this watch")
  57 + DispatchQueue.main.async {
  58 + completion?(false)
  59 + }
  60 + return
  61 + }
  62 +
48 let typesToShare: Set = [ 63 let typesToShare: Set = [
49 HKObjectType.workoutType() 64 HKObjectType.workoutType()
50 ] 65 ]
@@ -57,19 +72,49 @@ class WatchDataManager: ObservableObject { @@ -57,19 +72,49 @@ class WatchDataManager: ObservableObject {
57 } else { 72 } else {
58 print("❌ HealthKit not authorized by user") 73 print("❌ HealthKit not authorized by user")
59 } 74 }
  75 + DispatchQueue.main.async {
  76 + completion?(success && error == nil)
  77 + }
60 } 78 }
61 } 79 }
62 -  
63 - 80 +
  81 + func logout(){
  82 + latestHRVData = nil
  83 + activityTarget = nil
  84 + friendData = nil
  85 +
  86 +// myTodayAvgHRV = nil
  87 + myLatestHeartRate = nil
  88 + myTodayStepCount = nil
  89 + myTodayActiveEnergy = nil
  90 + myTodayExerciseTime = nil
  91 + myTodayStandTime = nil
  92 + myTodaySleepTime = nil
  93 +
  94 + [
  95 + AppGroupConstants.Key.latestHealthData,
  96 + AppGroupConstants.Key.acitivyTarget,
  97 + AppGroupConstants.Key.myTodayStandTime,
  98 + AppGroupConstants.Key.myTodayExerciseTime,
  99 + AppGroupConstants.Key.myTodayActiveEnergy,
  100 + AppGroupConstants.Key.myTodayStepCount,
  101 + AppGroupConstants.Key.myLatestHeartRate
  102 + ].forEach { key in
  103 + AppGroupConstants.defaults?.removeObject(forKey: key)
  104 + }
  105 + WatchWidgetReloader.reloadAllDynamicWidgets()
  106 + WatchHealthWidgetRefreshCoordinator.shared.refreshAll()
  107 + }
  108 +
64 func fetchCoupleLatestHRV() { 109 func fetchCoupleLatestHRV() {
65 - fetchLatestHRV()  
66 - fetchOtherLatestHRV() 110 + getLatestHealthData()
  111 + getAcitivtyTarget()
67 observeHealthData() 112 observeHealthData()
68 } 113 }
69 - 114 +
70 private func fetchLatestHRV() { 115 private func fetchLatestHRV() {
71 guard let hrvType = HKObjectType.quantityType(forIdentifier: .heartRateVariabilitySDNN) else { return } 116 guard let hrvType = HKObjectType.quantityType(forIdentifier: .heartRateVariabilitySDNN) else { return }
72 - 117 +
73 let sortDescriptor = NSSortDescriptor(key: HKSampleSortIdentifierEndDate, ascending: false) 118 let sortDescriptor = NSSortDescriptor(key: HKSampleSortIdentifierEndDate, ascending: false)
74 let query = HKSampleQuery(sampleType: hrvType, 119 let query = HKSampleQuery(sampleType: hrvType,
75 predicate: nil, 120 predicate: nil,
@@ -80,52 +125,27 @@ class WatchDataManager: ObservableObject { @@ -80,52 +125,27 @@ class WatchDataManager: ObservableObject {
80 print("查询最新HRV失败: \(error.localizedDescription)") 125 print("查询最新HRV失败: \(error.localizedDescription)")
81 return 126 return
82 } 127 }
83 - 128 +
84 if let sample = samples?.first as? HKQuantitySample { 129 if let sample = samples?.first as? HKQuantitySample {
85 let value = sample.quantity.doubleValue(for: .secondUnit(with: .milli)) 130 let value = sample.quantity.doubleValue(for: .secondUnit(with: .milli))
86 - Task {  
87 - let result = try? await HealthService().getLatestHRV()  
88 - let myHrvBaseline = result?.userHrvBaseline  
89 - let data = HRVData(value: value,  
90 - hrvBaseline: myHrvBaseline,  
91 - date: sample.endDate)  
92 - await MainActor.run {  
93 - self.myLatestHRVData = data  
94 - }  
95 - self.saveHRVBaselineValue(myHrvBaseline)  
96 - }  
97 DispatchQueue.main.async { 131 DispatchQueue.main.async {
98 - self.saveHRVValue(value)  
99 self.uploadHRV(value: value, date: sample.endDate) 132 self.uploadHRV(value: value, date: sample.endDate)
100 - WidgetCenter.shared.reloadAllTimelines() 133 + WatchWidgetReloader.reloadHRV()
101 print("最新HRV: \(sample)") 134 print("最新HRV: \(sample)")
102 } 135 }
103 } 136 }
104 } 137 }
105 - 138 +
106 healthStore.execute(query) 139 healthStore.execute(query)
107 } 140 }
108 -  
109 - private func fetchOtherLatestHRV() {  
110 - Task {  
111 - let result = try? await HealthService().getLatestHRV()  
112 - await MainActor.run {  
113 - if let value = result?.pairUserHrv {  
114 - let data = HRVData(value: value,  
115 - hrvBaseline: result?.pairHrvBaseline,  
116 - date: nil)  
117 - self.otherLatestHRVData = data  
118 - }  
119 - }  
120 - }  
121 - }  
122 - 141 +
  142 +
123 private func fetchTodayAvgHRV() { 143 private func fetchTodayAvgHRV() {
124 guard let hrvType = HKObjectType.quantityType(forIdentifier: .heartRateVariabilitySDNN) else { return } 144 guard let hrvType = HKObjectType.quantityType(forIdentifier: .heartRateVariabilitySDNN) else { return }
125 - 145 +
126 let startOfDay = Calendar.current.startOfDay(for: Date()) 146 let startOfDay = Calendar.current.startOfDay(for: Date())
127 let predicate = HKQuery.predicateForSamples(withStart: startOfDay, end: Date(), options: .strictStartDate) 147 let predicate = HKQuery.predicateForSamples(withStart: startOfDay, end: Date(), options: .strictStartDate)
128 - 148 +
129 let query = HKSampleQuery(sampleType: hrvType, 149 let query = HKSampleQuery(sampleType: hrvType,
130 predicate: predicate, 150 predicate: predicate,
131 limit: HKObjectQueryNoLimit, 151 limit: HKObjectQueryNoLimit,
@@ -135,26 +155,26 @@ class WatchDataManager: ObservableObject { @@ -135,26 +155,26 @@ class WatchDataManager: ObservableObject {
135 print("查询HRV失败: \(error.localizedDescription)") 155 print("查询HRV失败: \(error.localizedDescription)")
136 return 156 return
137 } 157 }
138 - 158 +
139 guard let samples = samples as? [HKQuantitySample], !samples.isEmpty else { return } 159 guard let samples = samples as? [HKQuantitySample], !samples.isEmpty else { return }
140 - 160 +
141 let total = samples.reduce(0.0) { $0 + $1.quantity.doubleValue(for: .secondUnit(with: .milli)) } 161 let total = samples.reduce(0.0) { $0 + $1.quantity.doubleValue(for: .secondUnit(with: .milli)) }
142 let average = total / Double(samples.count) 162 let average = total / Double(samples.count)
143 -  
144 - DispatchQueue.main.async {  
145 - self.myTodayAvgHRV = average  
146 - } 163 +
  164 +// DispatchQueue.main.async {
  165 +// self.myTodayAvgHRV = average
  166 +// }
147 } 167 }
148 - 168 +
149 healthStore.execute(query) 169 healthStore.execute(query)
150 } 170 }
151 - 171 +
152 private func fetchTodayLatestHeartRate() { 172 private func fetchTodayLatestHeartRate() {
153 guard let heartRateType = HKObjectType.quantityType(forIdentifier: .heartRate) else { return } 173 guard let heartRateType = HKObjectType.quantityType(forIdentifier: .heartRate) else { return }
154 - 174 +
155 let startOfDay = Calendar.current.startOfDay(for: Date()) 175 let startOfDay = Calendar.current.startOfDay(for: Date())
156 let predicate = HKQuery.predicateForSamples(withStart: startOfDay, end: Date(), options: .strictStartDate) 176 let predicate = HKQuery.predicateForSamples(withStart: startOfDay, end: Date(), options: .strictStartDate)
157 - 177 +
158 let sortDescriptor = NSSortDescriptor(key: HKSampleSortIdentifierEndDate, ascending: false) 178 let sortDescriptor = NSSortDescriptor(key: HKSampleSortIdentifierEndDate, ascending: false)
159 let query = HKSampleQuery(sampleType: heartRateType, 179 let query = HKSampleQuery(sampleType: heartRateType,
160 predicate: predicate, 180 predicate: predicate,
@@ -165,24 +185,32 @@ class WatchDataManager: ObservableObject { @@ -165,24 +185,32 @@ class WatchDataManager: ObservableObject {
165 print("查询今日最新心率失败: \(error.localizedDescription)") 185 print("查询今日最新心率失败: \(error.localizedDescription)")
166 return 186 return
167 } 187 }
168 - 188 +
169 if let sample = samples?.first as? HKQuantitySample { 189 if let sample = samples?.first as? HKQuantitySample {
170 let value = sample.quantity.doubleValue(for: .count().unitDivided(by: .minute())) 190 let value = sample.quantity.doubleValue(for: .count().unitDivided(by: .minute()))
  191 + AppGroupConstants.defaults?.set(value, forKey: AppGroupConstants.Key.myLatestHeartRate)
171 DispatchQueue.main.async { 192 DispatchQueue.main.async {
172 self.myLatestHeartRate = value 193 self.myLatestHeartRate = value
  194 + WatchWidgetReloader.reloadHeartRate()
  195 + }
  196 + } else {
  197 + AppGroupConstants.defaults?.removeObject(forKey: AppGroupConstants.Key.myLatestHeartRate)
  198 + DispatchQueue.main.async {
  199 + self.myLatestHeartRate = nil
  200 + WatchWidgetReloader.reloadHeartRate()
173 } 201 }
174 } 202 }
175 } 203 }
176 - 204 +
177 healthStore.execute(query) 205 healthStore.execute(query)
178 } 206 }
179 - 207 +
180 private func fetchTodayStepCount() { 208 private func fetchTodayStepCount() {
181 guard let stepType = HKObjectType.quantityType(forIdentifier: .stepCount) else { return } 209 guard let stepType = HKObjectType.quantityType(forIdentifier: .stepCount) else { return }
182 - 210 +
183 let startOfDay = Calendar.current.startOfDay(for: Date()) 211 let startOfDay = Calendar.current.startOfDay(for: Date())
184 let predicate = HKQuery.predicateForSamples(withStart: startOfDay, end: Date(), options: .strictStartDate) 212 let predicate = HKQuery.predicateForSamples(withStart: startOfDay, end: Date(), options: .strictStartDate)
185 - 213 +
186 let query = HKStatisticsQuery(quantityType: stepType, 214 let query = HKStatisticsQuery(quantityType: stepType,
187 quantitySamplePredicate: predicate, 215 quantitySamplePredicate: predicate,
188 options: .cumulativeSum) { [weak self] _, result, error in 216 options: .cumulativeSum) { [weak self] _, result, error in
@@ -191,26 +219,24 @@ class WatchDataManager: ObservableObject { @@ -191,26 +219,24 @@ class WatchDataManager: ObservableObject {
191 print("查询步数失败: \(error.localizedDescription)") 219 print("查询步数失败: \(error.localizedDescription)")
192 return 220 return
193 } 221 }
194 -  
195 - if let sum = result?.sumQuantity() {  
196 - let value = Int(sum.doubleValue(for: .count()))  
197 - DispatchQueue.main.async {  
198 - self.myTodayStepCount = value  
199 - self.saveStepCountValue(value)  
200 - WidgetCenter.shared.reloadAllTimelines()  
201 - } 222 +
  223 + let value = Int(result?.sumQuantity()?.doubleValue(for: .count()) ?? 0)
  224 + AppGroupConstants.defaults?.set(value, forKey: AppGroupConstants.Key.myTodayStepCount)
  225 + DispatchQueue.main.async {
  226 + self.myTodayStepCount = value
  227 + WatchWidgetReloader.reloadSteps()
202 } 228 }
203 } 229 }
204 - 230 +
205 healthStore.execute(query) 231 healthStore.execute(query)
206 } 232 }
207 - 233 +
208 private func fetchTodayActiveEnergy() { 234 private func fetchTodayActiveEnergy() {
209 guard let energyType = HKObjectType.quantityType(forIdentifier: .activeEnergyBurned) else { return } 235 guard let energyType = HKObjectType.quantityType(forIdentifier: .activeEnergyBurned) else { return }
210 - 236 +
211 let startOfDay = Calendar.current.startOfDay(for: Date()) 237 let startOfDay = Calendar.current.startOfDay(for: Date())
212 let predicate = HKQuery.predicateForSamples(withStart: startOfDay, end: Date(), options: .strictStartDate) 238 let predicate = HKQuery.predicateForSamples(withStart: startOfDay, end: Date(), options: .strictStartDate)
213 - 239 +
214 let query = HKStatisticsQuery(quantityType: energyType, 240 let query = HKStatisticsQuery(quantityType: energyType,
215 quantitySamplePredicate: predicate, 241 quantitySamplePredicate: predicate,
216 options: .cumulativeSum) { [weak self] _, result, error in 242 options: .cumulativeSum) { [weak self] _, result, error in
@@ -219,36 +245,89 @@ class WatchDataManager: ObservableObject { @@ -219,36 +245,89 @@ class WatchDataManager: ObservableObject {
219 print("查询活动能量失败: \(error.localizedDescription)") 245 print("查询活动能量失败: \(error.localizedDescription)")
220 return 246 return
221 } 247 }
222 -  
223 - if let sum = result?.sumQuantity() {  
224 - let value = sum.doubleValue(for: .kilocalorie())  
225 - DispatchQueue.main.async {  
226 - self.myTodayActiveEnergy = value  
227 - } 248 +
  249 + let value = result?.sumQuantity()?.doubleValue(for: .kilocalorie()) ?? 0
  250 + AppGroupConstants.defaults?.set(value, forKey: AppGroupConstants.Key.myTodayActiveEnergy)
  251 + DispatchQueue.main.async {
  252 + self.myTodayActiveEnergy = value
  253 + WatchWidgetReloader.reloadActivity()
228 } 254 }
229 } 255 }
230 - 256 +
231 healthStore.execute(query) 257 healthStore.execute(query)
232 } 258 }
233 -  
234 - 259 +
  260 + private func fetchTodayExerciseTime() {
  261 + guard let exerciseType = HKObjectType.quantityType(forIdentifier: .appleExerciseTime) else { return }
  262 +
  263 + let startOfDay = Calendar.current.startOfDay(for: Date())
  264 + let predicate = HKQuery.predicateForSamples(withStart: startOfDay, end: Date(), options: .strictStartDate)
  265 +
  266 + let query = HKStatisticsQuery(quantityType: exerciseType,
  267 + quantitySamplePredicate: predicate,
  268 + options: .cumulativeSum) { [weak self] _, result, error in
  269 + guard let self = self else { return }
  270 + if let error = error {
  271 + print("查询锻炼时间失败: \(error.localizedDescription)")
  272 + return
  273 + }
  274 +
  275 + let value = result?.sumQuantity()?.doubleValue(for: .minute()) ?? 0
  276 + AppGroupConstants.defaults?.set(value, forKey: AppGroupConstants.Key.myTodayExerciseTime)
  277 + DispatchQueue.main.async {
  278 + self.myTodayExerciseTime = value
  279 + WatchWidgetReloader.reloadActivity()
  280 + }
  281 + }
  282 +
  283 + healthStore.execute(query)
  284 + }
  285 +
  286 + private func fetchTodayStandTime() {
  287 + let calendar = Calendar.current
  288 + var start = calendar.dateComponents([.era, .year, .month, .day], from: Date())
  289 + var end = start
  290 + start.calendar = calendar
  291 + end.calendar = calendar
  292 +
  293 + let predicate = HKQuery.predicate(forActivitySummariesBetweenStart: start, end: end)
  294 +
  295 + let query = HKActivitySummaryQuery(predicate: predicate) { [weak self] _, summaries, error in
  296 + guard let self = self else { return }
  297 + if let error = error {
  298 + print("查询站立小时失败: \(error.localizedDescription)")
  299 + return
  300 + }
  301 +
  302 + let value = summaries?.last?.appleStandHours.doubleValue(for: .count()) ?? 0
  303 + AppGroupConstants.defaults?.set(value, forKey: AppGroupConstants.Key.myTodayStandTime)
  304 + DispatchQueue.main.async {
  305 + self.myTodayStandTime = value
  306 + WatchWidgetReloader.reloadActivity()
  307 + }
  308 + }
  309 +
  310 + healthStore.execute(query)
  311 + }
  312 +
  313 +
235 func fetchLastNightSleepDuration() { 314 func fetchLastNightSleepDuration() {
236 Task { 315 Task {
237 let calendar = Calendar.current 316 let calendar = Calendar.current
238 let today = Date() 317 let today = Date()
239 - 318 +
240 // 定义昨天晚上的时间范围(从昨天15:00到今天15:00) 319 // 定义昨天晚上的时间范围(从昨天15:00到今天15:00)
241 let yesterday = calendar.date(byAdding: .day, value: -1, to: today)! 320 let yesterday = calendar.date(byAdding: .day, value: -1, to: today)!
242 let searchStartTime = calendar.date(bySettingHour: 15, minute: 0, second: 0, of: yesterday)! 321 let searchStartTime = calendar.date(bySettingHour: 15, minute: 0, second: 0, of: yesterday)!
243 let searchEndTime = calendar.date(bySettingHour: 15, minute: 0, second: 0, of: today)! 322 let searchEndTime = calendar.date(bySettingHour: 15, minute: 0, second: 0, of: today)!
244 - 323 +
245 guard let sleepType = HKObjectType.categoryType(forIdentifier: .sleepAnalysis) else { 324 guard let sleepType = HKObjectType.categoryType(forIdentifier: .sleepAnalysis) else {
246 return 325 return
247 } 326 }
248 - 327 +
249 let predicate = HKQuery.predicateForSamples(withStart: searchStartTime, end: searchEndTime, options: .strictStartDate) 328 let predicate = HKQuery.predicateForSamples(withStart: searchStartTime, end: searchEndTime, options: .strictStartDate)
250 let sortDescriptor = NSSortDescriptor(key: HKSampleSortIdentifierStartDate, ascending: true) 329 let sortDescriptor = NSSortDescriptor(key: HKSampleSortIdentifierStartDate, ascending: true)
251 - 330 +
252 let samples = try? await withCheckedThrowingContinuation { (continuation: CheckedContinuation<[HKCategorySample], Error>) in 331 let samples = try? await withCheckedThrowingContinuation { (continuation: CheckedContinuation<[HKCategorySample], Error>) in
253 let query = HKSampleQuery( 332 let query = HKSampleQuery(
254 sampleType: sleepType, 333 sampleType: sleepType,
@@ -260,26 +339,26 @@ class WatchDataManager: ObservableObject { @@ -260,26 +339,26 @@ class WatchDataManager: ObservableObject {
260 continuation.resume(throwing: error) 339 continuation.resume(throwing: error)
261 return 340 return
262 } 341 }
263 - 342 +
264 let categorySamples = samples?.compactMap { $0 as? HKCategorySample } ?? [] 343 let categorySamples = samples?.compactMap { $0 as? HKCategorySample } ?? []
265 continuation.resume(returning: categorySamples) 344 continuation.resume(returning: categorySamples)
266 } 345 }
267 - 346 +
268 healthStore.execute(query) 347 healthStore.execute(query)
269 } 348 }
270 - 349 +
271 guard let samples, 350 guard let samples,
272 !samples.isEmpty else { return } 351 !samples.isEmpty else { return }
273 - 352 +
274 // 按开始时间排序 353 // 按开始时间排序
275 let sortedSamples = samples.sorted { $0.startDate < $1.startDate } 354 let sortedSamples = samples.sorted { $0.startDate < $1.startDate }
276 - 355 +
277 // 转换为睡眠阶段数据 356 // 转换为睡眠阶段数据
278 let sleepStages = sortedSamples.compactMap { sample -> SleepStage? in 357 let sleepStages = sortedSamples.compactMap { sample -> SleepStage? in
279 guard let sleepValue = HKCategoryValueSleepAnalysis(rawValue: sample.value) else { 358 guard let sleepValue = HKCategoryValueSleepAnalysis(rawValue: sample.value) else {
280 return nil 359 return nil
281 } 360 }
282 - 361 +
283 return SleepStage( 362 return SleepStage(
284 stage: sleepValue, 363 stage: sleepValue,
285 startTime: sample.startDate, 364 startTime: sample.startDate,
@@ -287,14 +366,14 @@ class WatchDataManager: ObservableObject { @@ -287,14 +366,14 @@ class WatchDataManager: ObservableObject {
287 duration: sample.endDate.timeIntervalSince(sample.startDate) 366 duration: sample.endDate.timeIntervalSince(sample.startDate)
288 ) 367 )
289 } 368 }
290 - 369 +
291 guard !sleepStages.isEmpty else { return } 370 guard !sleepStages.isEmpty else { return }
292 - 371 +
293 // 计算总体睡眠时间范围 372 // 计算总体睡眠时间范围
294 let overallStartTime = sleepStages.first!.startTime 373 let overallStartTime = sleepStages.first!.startTime
295 let overallEndTime = sleepStages.last!.endTime 374 let overallEndTime = sleepStages.last!.endTime
296 let totalDuration = overallEndTime.timeIntervalSince(overallStartTime) 375 let totalDuration = overallEndTime.timeIntervalSince(overallStartTime)
297 - 376 +
298 // 计算实际睡眠时间(排除清醒和在床上但未睡着的时间) 377 // 计算实际睡眠时间(排除清醒和在床上但未睡着的时间)
299 let actualSleepTime = sleepStages 378 let actualSleepTime = sleepStages
300 .filter { stage in 379 .filter { stage in
@@ -308,10 +387,10 @@ class WatchDataManager: ObservableObject { @@ -308,10 +387,10 @@ class WatchDataManager: ObservableObject {
308 } 387 }
309 } 388 }
310 .reduce(0) { $0 + $1.duration } 389 .reduce(0) { $0 + $1.duration }
311 - 390 +
312 // 计算睡眠效率 391 // 计算睡眠效率
313 let sleepEfficiency = totalDuration > 0 ? (actualSleepTime / totalDuration) * 100 : nil 392 let sleepEfficiency = totalDuration > 0 ? (actualSleepTime / totalDuration) * 100 : nil
314 - 393 +
315 let data = SleepData( 394 let data = SleepData(
316 startTime: overallStartTime, 395 startTime: overallStartTime,
317 endTime: overallEndTime, 396 endTime: overallEndTime,
@@ -320,22 +399,24 @@ class WatchDataManager: ObservableObject { @@ -320,22 +399,24 @@ class WatchDataManager: ObservableObject {
320 totalSleepTime: actualSleepTime, 399 totalSleepTime: actualSleepTime,
321 sleepEfficiency: sleepEfficiency 400 sleepEfficiency: sleepEfficiency
322 ) 401 )
323 -  
324 - self.myTodaySleepTime = data.totalSleepTime 402 +
  403 + await MainActor.run {
  404 + self.myTodaySleepTime = data.totalSleepTime
  405 + }
325 } 406 }
326 } 407 }
327 - 408 +
328 // 方法1: 获取最新的单个事件 409 // 方法1: 获取最新的单个事件
329 func fetchLatestIrregularRhythmEvent() { 410 func fetchLatestIrregularRhythmEvent() {
330 let irregularRhythmType = HKCategoryType.categoryType( 411 let irregularRhythmType = HKCategoryType.categoryType(
331 forIdentifier: .irregularHeartRhythmEvent 412 forIdentifier: .irregularHeartRhythmEvent
332 )! 413 )!
333 - 414 +
334 let sortDescriptor = NSSortDescriptor( 415 let sortDescriptor = NSSortDescriptor(
335 key: HKSampleSortIdentifierStartDate, 416 key: HKSampleSortIdentifierStartDate,
336 ascending: false // 降序,最新的在前 417 ascending: false // 降序,最新的在前
337 ) 418 )
338 - 419 +
339 let query = HKSampleQuery( 420 let query = HKSampleQuery(
340 sampleType: irregularRhythmType, 421 sampleType: irregularRhythmType,
341 predicate: nil, 422 predicate: nil,
@@ -345,18 +426,18 @@ class WatchDataManager: ObservableObject { @@ -345,18 +426,18 @@ class WatchDataManager: ObservableObject {
345 if let error = error { 426 if let error = error {
346 return 427 return
347 } 428 }
348 - 429 +
349 guard let sample = samples?.first as? HKCategorySample else { 430 guard let sample = samples?.first as? HKCategorySample else {
350 return 431 return
351 } 432 }
352 - 433 +
353 self.uploadIrregularRhythm(value: sample.value, 434 self.uploadIrregularRhythm(value: sample.value,
354 date: sample.startDate) 435 date: sample.startDate)
355 } 436 }
356 - 437 +
357 healthStore.execute(query) 438 healthStore.execute(query)
358 } 439 }
359 - 440 +
360 private func fetchLatestWristTemperature() { 441 private func fetchLatestWristTemperature() {
361 guard let wristTempType = HKObjectType.quantityType(forIdentifier: .appleSleepingWristTemperature) else { return } 442 guard let wristTempType = HKObjectType.quantityType(forIdentifier: .appleSleepingWristTemperature) else { return }
362 443
@@ -379,7 +460,7 @@ class WatchDataManager: ObservableObject { @@ -379,7 +460,7 @@ class WatchDataManager: ObservableObject {
379 460
380 healthStore.execute(query) 461 healthStore.execute(query)
381 } 462 }
382 - 463 +
383 private func fetchLatestRespiratoryRate() { 464 private func fetchLatestRespiratoryRate() {
384 guard let respiratoryType = HKObjectType.quantityType(forIdentifier: .respiratoryRate) else { return } 465 guard let respiratoryType = HKObjectType.quantityType(forIdentifier: .respiratoryRate) else { return }
385 466
@@ -406,114 +487,87 @@ class WatchDataManager: ObservableObject { @@ -406,114 +487,87 @@ class WatchDataManager: ObservableObject {
406 487
407 extension WatchDataManager { 488 extension WatchDataManager {
408 func observeHealthData() { 489 func observeHealthData() {
409 - guard HKHealthStore.isHealthDataAvailable() else { return }  
410 -  
411 - let hrvType = HKQuantityType.quantityType(forIdentifier: .heartRateVariabilitySDNN)!  
412 -  
413 - // 设置 observer query  
414 - let observerQuery = HKObserverQuery(sampleType: hrvType, predicate: nil) { [weak self] _, completionHandler, error in  
415 - if let _ = error {  
416 - return  
417 - }  
418 - // 有新数据,启动 Anchored Query 获取  
419 - self?.fetchLatestHRV()  
420 - completionHandler()  
421 - }  
422 -  
423 - healthStore.execute(observerQuery)  
424 - healthStore.enableBackgroundDelivery(for: hrvType, frequency: .immediate) { success, error in  
425 -  
426 - }  
427 -  
428 - let stepType = HKQuantityType.quantityType(forIdentifier: .stepCount)!  
429 -  
430 - // 设置 observer query  
431 - let stepObserverQuery = HKObserverQuery(sampleType: stepType, predicate: nil) { [weak self] _, completionHandler, error in  
432 - if let _ = error {  
433 - return  
434 - }  
435 - // 有新数据,启动 Anchored Query 获取  
436 - self?.fetchTodayStepCount()  
437 - completionHandler() 490 + WatchHealthObserverUploader.shared.startObserversIfNeeded()
  491 + }
  492 +
  493 + private func saveHRVData(_ healthData: WatchHRVData?) {
  494 + if let healthData{
  495 + do{
  496 + let data = try JSONEncoder().encode( healthData)
  497 + AppGroupConstants.defaults?.set(data, forKey: AppGroupConstants.Key.latestHealthData)
  498 + }catch{}
  499 + }else{
  500 + AppGroupConstants.defaults?.removeObject(forKey: AppGroupConstants.Key.latestHealthData)
438 } 501 }
439 -  
440 - healthStore.execute(stepObserverQuery)  
441 - healthStore.enableBackgroundDelivery(for: stepType, frequency: .immediate) { success, error in  
442 - 502 + }
  503 +
  504 + private func saveActivityTarget(_ target: ActivityTarget?) {
  505 + if let target{
  506 + do{
  507 + let data = try JSONEncoder().encode( target)
  508 + AppGroupConstants.defaults?.set(data, forKey: AppGroupConstants.Key.acitivyTarget)
  509 + }catch{}
  510 + }else{
  511 + AppGroupConstants.defaults?.removeObject(forKey: AppGroupConstants.Key.acitivyTarget)
443 } 512 }
444 -  
445 - //房颤  
446 - let irregularRhythmType = HKCategoryType.categoryType(  
447 - forIdentifier: .irregularHeartRhythmEvent  
448 - )!  
449 -  
450 - // 设置 observer query  
451 - let irregularRhythmObserverQuery = HKObserverQuery(sampleType: irregularRhythmType, predicate: nil) { [weak self] _, completionHandler, error in  
452 - if let _ = error { 513 + }
  514 +}
  515 +
  516 +extension WatchDataManager {
  517 + func uploadTodayActivityTarget() {
  518 + let calendar = Calendar.current
  519 + var start = calendar.dateComponents([.era, .year, .month, .day], from: Date())
  520 + var end = start
  521 + start.calendar = calendar
  522 + end.calendar = calendar
  523 +
  524 + let predicate = HKQuery.predicate(forActivitySummariesBetweenStart: start, end: end)
  525 + let query = HKActivitySummaryQuery(predicate: predicate) { [weak self] _, summaries, error in
  526 + guard let self else { return }
  527 + if let error {
  528 + print("查询活动目标失败: \(error.localizedDescription)")
453 return 529 return
454 } 530 }
455 - // 有新数据,启动 Anchored Query 获取  
456 - self?.fetchLatestIrregularRhythmEvent()  
457 - completionHandler()  
458 - }  
459 -  
460 - healthStore.execute(irregularRhythmObserverQuery)  
461 - healthStore.enableBackgroundDelivery(for: irregularRhythmType, frequency: .immediate) { success, error in  
462 -  
463 - }  
464 -  
465 - //睡眠手腕温度  
466 - let wristTempType = HKQuantityType.quantityType(forIdentifier: .appleSleepingWristTemperature)!  
467 531
468 - let wristTempObserverQuery = HKObserverQuery(sampleType: wristTempType, predicate: nil) { [weak self] _, completionHandler, error in  
469 - if let _ = error { 532 + guard let summary = summaries?.last else {
  533 + print("查询活动目标为空")
470 return 534 return
471 } 535 }
472 - self?.fetchLatestWristTemperature()  
473 - completionHandler() 536 +
  537 + let body = Self.activityTargetUploadBody(from: summary)
  538 + self.uploadActivityTarget(body: body)
474 } 539 }
475 540
476 - healthStore.execute(wristTempObserverQuery)  
477 - healthStore.enableBackgroundDelivery(for: wristTempType, frequency: .immediate) { success, error in 541 + healthStore.execute(query)
  542 + }
478 543
479 - }  
480 -  
481 - //呼吸频率  
482 - let respiratoryType = HKQuantityType.quantityType(forIdentifier: .respiratoryRate)! 544 + nonisolated private static func activityTargetUploadBody(from summary: HKActivitySummary) -> [String: Any] {
  545 + var body: [String: Any] = [
  546 + "move": Int(summary.activeEnergyBurnedGoal.doubleValue(for: .kilocalorie())),
  547 + "stand": Int(summary.appleStandHoursGoal.doubleValue(for: .count())),
  548 + "active_energy_burned": summary.activeEnergyBurned.doubleValue(for: .kilocalorie()),
  549 + "active_energy_burned_goal": summary.activeEnergyBurnedGoal.doubleValue(for: .kilocalorie()),
  550 + "apple_exercise_time": summary.appleExerciseTime.doubleValue(for: .minute()),
  551 + "apple_exercise_time_goal": summary.appleExerciseTimeGoal.doubleValue(for: .minute()),
  552 + "apple_stand_hours": summary.appleStandHours.doubleValue(for: .count()),
  553 + "apple_stand_hours_goal": summary.appleStandHoursGoal.doubleValue(for: .count())
  554 + ]
483 555
484 - let respiratoryObserverQuery = HKObserverQuery(sampleType: respiratoryType, predicate: nil) { [weak self] _, completionHandler, error in  
485 - if let _ = error {  
486 - return  
487 - }  
488 - self?.fetchLatestRespiratoryRate()  
489 - completionHandler() 556 + if #available(watchOS 7.0, *) {
  557 + body["activity_move_mode"] = summary.activityMoveMode.rawValue
  558 + body["apple_move_time"] = summary.appleMoveTime.doubleValue(for: .minute())
  559 + body["apple_move_time_goal"] = summary.appleMoveTimeGoal.doubleValue(for: .minute())
490 } 560 }
491 561
492 - healthStore.execute(respiratoryObserverQuery)  
493 - healthStore.enableBackgroundDelivery(for: respiratoryType, frequency: .immediate) { success, error in  
494 - 562 + if #available(watchOS 9.0, *) {
  563 + body["exercise_time_goal"] = summary.exerciseTimeGoal?.doubleValue(for: .minute())
  564 + body["stand_hours_goal"] = summary.standHoursGoal?.doubleValue(for: .count())
495 } 565 }
496 -  
497 - }  
498 -  
499 - private func saveHRVValue(_ value: Double) {  
500 - let defaults = AppGroupConstants.defaults  
501 - defaults?.set(value, forKey: AppGroupConstants.Key.latestHRV)  
502 - }  
503 -  
504 - private func saveHRVBaselineValue(_ value: Double?) {  
505 - let defaults = AppGroupConstants.defaults  
506 - defaults?.set(value, forKey: AppGroupConstants.Key.latestHRVBaseline)  
507 - }  
508 -  
509 - private func saveStepCountValue(_ value: Int) {  
510 - let defaults = AppGroupConstants.defaults  
511 - defaults?.set(value, forKey: AppGroupConstants.Key.latestStepCount) 566 +
  567 + return body
512 } 568 }
513 -}  
514 569
515 -extension WatchDataManager {  
516 - private func uploadHRV(value: Double, date: Date) { 570 + nonisolated private func uploadHRV(value: Double, date: Date) {
517 Task { 571 Task {
518 let _ = try? await HealthService().upload(data: [ 572 let _ = try? await HealthService().upload(data: [
519 "data_type": HealthDataType.hrv.rawValue, 573 "data_type": HealthDataType.hrv.rawValue,
@@ -522,8 +576,8 @@ extension WatchDataManager { @@ -522,8 +576,8 @@ extension WatchDataManager {
522 ]) 576 ])
523 } 577 }
524 } 578 }
525 -  
526 - private func uploadIrregularRhythm(value: Int, date: Date) { 579 +
  580 + nonisolated private func uploadIrregularRhythm(value: Int, date: Date) {
527 Task { 581 Task {
528 let _ = try? await HealthService().upload(data: [ 582 let _ = try? await HealthService().upload(data: [
529 "data_type": HealthDataType.irregularHeartRhythm.rawValue, 583 "data_type": HealthDataType.irregularHeartRhythm.rawValue,
@@ -532,8 +586,8 @@ extension WatchDataManager { @@ -532,8 +586,8 @@ extension WatchDataManager {
532 ]) 586 ])
533 } 587 }
534 } 588 }
535 -  
536 - private func uploadSleepWristTemperature(value: Double, date: Date) { 589 +
  590 + nonisolated private func uploadSleepWristTemperature(value: Double, date: Date) {
537 Task { 591 Task {
538 let _ = try? await HealthService().upload(data: [ 592 let _ = try? await HealthService().upload(data: [
539 "data_type": HealthDataType.sleepingWristTemperature.rawValue, 593 "data_type": HealthDataType.sleepingWristTemperature.rawValue,
@@ -542,8 +596,8 @@ extension WatchDataManager { @@ -542,8 +596,8 @@ extension WatchDataManager {
542 ]) 596 ])
543 } 597 }
544 } 598 }
545 -  
546 - private func uploadRespiratoryRate(value: Double, date: Date) { 599 +
  600 + nonisolated private func uploadRespiratoryRate(value: Double, date: Date) {
547 Task { 601 Task {
548 let _ = try? await HealthService().upload(data: [ 602 let _ = try? await HealthService().upload(data: [
549 "data_type": HealthDataType.respiratoryRate.rawValue, 603 "data_type": HealthDataType.respiratoryRate.rawValue,
@@ -552,23 +606,85 @@ extension WatchDataManager { @@ -552,23 +606,85 @@ extension WatchDataManager {
552 ]) 606 ])
553 } 607 }
554 } 608 }
  609 +
  610 + nonisolated private func uploadActivityTarget(body: [String: Any]) {
  611 + Task {
  612 + #if DEBUG
  613 + let debugBody = body
  614 + .map { "\($0.key)=\($0.value)" }
  615 + .sorted()
  616 + .joined(separator: ", ")
  617 + print("watch upload activity target: {\(debugBody)}")
  618 + #endif
  619 +
  620 + let _ = try? await HealthService().uploadActivityTarget(data: body)
  621 + }
  622 + }
555 } 623 }
556 624
557 extension WatchDataManager { 625 extension WatchDataManager {
558 - func fetchOtherTodayData() { 626 + func getAcitivtyTarget(){
  627 + Task{
  628 + let result = try? await HealthService().getActivityTarget()
  629 + await MainActor.run {
  630 + self.activityTarget = result
  631 + self.saveActivityTarget(result)
  632 + WatchWidgetReloader.reloadActivityTarget()
  633 + }
  634 + }
  635 + }
  636 + func getLatestHealthData(){
559 Task { 637 Task {
560 - let result = try? await HealthService().getTodayStatusInfo() 638 + let result = try? await HealthService().getHealthData(type: WatchUserinfoManager.share.displayType)
561 await MainActor.run { 639 await MainActor.run {
562 - self.otherTodayHealthInfo = result 640 + self.latestHRVData = result
  641 + self.saveHRVData(result)
  642 + WatchWidgetReloader.reloadHRV()
563 } 643 }
564 } 644 }
565 } 645 }
566 646
  647 + func getMyLatestData(){
  648 + Task{
  649 + let result = try? await HealthService().getMyData()
  650 + await MainActor.run {
  651 + self.myData = result
  652 + }
  653 + }
  654 + }
  655 +
  656 + func getFriendLatestData(){
  657 + Task{
  658 + let result = try? await HealthService().getFriendData()
  659 + await MainActor.run {
  660 + self.friendData = result
  661 + }
  662 + }
  663 + }
  664 +
  665 +
567 func fetchMyTodayData() { 666 func fetchMyTodayData() {
568 - fetchTodayAvgHRV() 667 +// fetchTodayAvgHRV()
569 fetchTodayLatestHeartRate() 668 fetchTodayLatestHeartRate()
570 fetchTodayStepCount() 669 fetchTodayStepCount()
571 fetchTodayActiveEnergy() 670 fetchTodayActiveEnergy()
  671 + fetchTodayExerciseTime()
  672 + fetchTodayStandTime()
572 fetchLastNightSleepDuration() 673 fetchLastNightSleepDuration()
  674 + WatchHealthWidgetRefreshCoordinator.shared.refreshActivityTargetData()
  675 + }
  676 +
  677 + func refreshHeartRateWidgetData() {
  678 + fetchTodayLatestHeartRate()
  679 + }
  680 +
  681 + func refreshStepWidgetData() {
  682 + fetchTodayStepCount()
  683 + }
  684 +
  685 + func refreshActivityWidgetData() {
  686 + fetchTodayActiveEnergy()
  687 + fetchTodayExerciseTime()
  688 + fetchTodayStandTime()
573 } 689 }
574 } 690 }
  1 +import Foundation
  2 +import HealthKit
  3 +
  4 +enum WatchHealthUploadConfiguration {
  5 + /// Process-lifetime throttle. Each health data type can start at most one
  6 + /// upload request during this interval.
  7 + static let minimumTriggerInterval: TimeInterval = 60
  8 +}
  9 +
  10 +private actor WatchHealthUploadQueue {
  11 + func run(_ operation: @escaping () async throws -> Void) async throws {
  12 + try await operation()
  13 + }
  14 +}
  15 +
  16 +private actor WatchHealthUploadThrottle {
  17 + private var lastTriggerDates: [Int: Date] = [:]
  18 +
  19 + func acquire(dataType: Int, now: Date = Date()) -> Bool {
  20 + if let lastDate = lastTriggerDates[dataType],
  21 + now.timeIntervalSince(lastDate) < WatchHealthUploadConfiguration.minimumTriggerInterval {
  22 + return false
  23 + }
  24 + lastTriggerDates[dataType] = now
  25 + return true
  26 + }
  27 +}
  28 +
  29 +/// Owns Watch HealthKit observers and keeps the observer completion alive until
  30 +/// the corresponding server upload has finished.
  31 +final class WatchHealthObserverUploader {
  32 + static let shared = WatchHealthObserverUploader()
  33 +
  34 + private let healthStore = HKHealthStore()
  35 + private let healthService = HealthService()
  36 + private var observers: [HKObserverQuery] = []
  37 + private var uploadHandlers: [() async throws -> Void] = []
  38 + private let uploadQueue = WatchHealthUploadQueue()
  39 + private var uploadTimeCache: WatchHealthUploadTimeList?
  40 + private var uploadTimeCacheDate: Date?
  41 + private var uploadTimeCacheUserId: Int?
  42 + private var hasStarted = false
  43 + private let uploadThrottle = WatchHealthUploadThrottle()
  44 +
  45 + private init() {}
  46 +
  47 + @MainActor
  48 + func startObserversIfNeeded() {
  49 + guard HKHealthStore.isHealthDataAvailable() else { return }
  50 + guard !hasStarted else { return }
  51 + hasStarted = true
  52 +
  53 + registerQuantity(.heartRate, dataType: .heartRate,
  54 + unit: HKUnit.count().unitDivided(by: .minute()))
  55 + registerQuantity(.heartRateVariabilitySDNN, dataType: .hrv,
  56 + unit: .secondUnit(with: .milli))
  57 + registerDailyCumulative(.stepCount, dataType: .steps, unit: .count())
  58 + registerQuantity(.oxygenSaturation, dataType: .spo2, unit: .percent(), multiplier: 100)
  59 + registerDailyCumulative(.activeEnergyBurned, dataType: .move, unit: .kilocalorie(), uploadsTarget: true)
  60 + registerDailyCumulative(.appleExerciseTime, dataType: .exercise, unit: .second(), uploadsTarget: true)
  61 + registerStand()
  62 + registerQuantity(.walkingHeartRateAverage, dataType: .walkingHeartRate,
  63 + unit: HKUnit.count().unitDivided(by: .minute()))
  64 + registerQuantity(.restingHeartRate, dataType: .restingHeartRate,
  65 + unit: HKUnit.count().unitDivided(by: .minute()))
  66 + registerQuantity(.appleSleepingWristTemperature, dataType: .sleepingWristTemperature,
  67 + unit: .degreeCelsius())
  68 + registerQuantity(.respiratoryRate, dataType: .respiratoryRate,
  69 + unit: HKUnit.count().unitDivided(by: .minute()))
  70 + registerIrregularRhythm()
  71 + registerSleep()
  72 + }
  73 +
  74 + func performFullUpload() {
  75 + Task { await uploadPendingChanges() }
  76 + }
  77 +
  78 + private func registerQuantity(
  79 + _ identifier: HKQuantityTypeIdentifier,
  80 + dataType: HealthDataType,
  81 + unit: HKUnit,
  82 + multiplier: Double = 1
  83 + ) {
  84 + guard let sampleType = HKObjectType.quantityType(forIdentifier: identifier) else { return }
  85 + register(sampleType, widgetDataType: dataType) { [weak self] in
  86 + guard let self else { return }
  87 + try await self.processQuantityChanges(type: sampleType, dataType: dataType.rawValue) { samples in
  88 + samples.forEach { sample in
  89 + self.debugLogCommonUpload(
  90 + dataType: dataType.rawValue,
  91 + time: sample.endDate,
  92 + value: sample.quantity.doubleValue(for: unit) * multiplier
  93 + )
  94 + }
  95 + let body = samples.map {
  96 + [
  97 + "data_type": dataType.rawValue,
  98 + "time": $0.endDate.timeIntervalSince1970,
  99 + "value": $0.quantity.doubleValue(for: unit) * multiplier,
  100 + ] as [String: Any]
  101 + }
  102 + try await self.uploadCommonBatches(body)
  103 + }
  104 + await MainActor.run {
  105 + switch dataType {
  106 + case .hrv:
  107 + WatchDataManager.share.getLatestHealthData()
  108 + default:
  109 + break
  110 + }
  111 + }
  112 + }
  113 + }
  114 +
  115 + private func registerDailyCumulative(
  116 + _ identifier: HKQuantityTypeIdentifier,
  117 + dataType: HealthDataType,
  118 + unit: HKUnit,
  119 + uploadsTarget: Bool = false
  120 + ) {
  121 + guard let sampleType = HKObjectType.quantityType(forIdentifier: identifier) else { return }
  122 + register(sampleType, widgetDataType: dataType) { [weak self] in
  123 + guard let self else { return }
  124 + let days = try await self.cumulativeUploadDays(
  125 + type: sampleType,
  126 + dataType: dataType.rawValue
  127 + )
  128 + var body: [[String: Any]] = []
  129 + var uploadLogs: [(time: Date, value: Double)] = []
  130 + for day in days {
  131 + let value = try await self.dailyCumulative(type: sampleType, unit: unit, day: day.day)
  132 + uploadLogs.append((day.latestSampleTime, value))
  133 + body.append([
  134 + "data_type": dataType.rawValue,
  135 + "time": day.latestSampleTime.timeIntervalSince1970,
  136 + "value": value,
  137 + ])
  138 + }
  139 + guard !body.isEmpty,
  140 + await self.allowUploadTrigger(for: dataType.rawValue) else { return }
  141 + uploadLogs.forEach {
  142 + self.debugLogCommonUpload(dataType: dataType.rawValue, time: $0.time, value: $0.value)
  143 + }
  144 + try await self.uploadCommonBatches(body)
  145 + if uploadsTarget {
  146 + try await self.uploadTodayActivityTarget()
  147 + }
  148 + }
  149 + }
  150 +
  151 + private func registerStand() {
  152 + guard let type = HKObjectType.quantityType(forIdentifier: .appleStandTime) else { return }
  153 + register(type, widgetDataType: .stand) { [weak self] in
  154 + guard let self else { return }
  155 + let days = try await self.cumulativeUploadDays(
  156 + type: type,
  157 + dataType: HealthDataType.stand.rawValue
  158 + )
  159 + var body: [[String: Any]] = []
  160 + var uploadLogs: [(time: Date, value: Double)] = []
  161 + for day in days {
  162 + guard let summary = try await self.activitySummary(for: day.day) else { continue }
  163 + let value = summary.appleStandHours.doubleValue(for: .count())
  164 + uploadLogs.append((day.latestSampleTime, value))
  165 + body.append([
  166 + "data_type": HealthDataType.stand.rawValue,
  167 + "time": day.latestSampleTime.timeIntervalSince1970,
  168 + "value": value,
  169 + ])
  170 + }
  171 + guard !body.isEmpty,
  172 + await self.allowUploadTrigger(for: HealthDataType.stand.rawValue) else { return }
  173 + uploadLogs.forEach {
  174 + self.debugLogCommonUpload(
  175 + dataType: HealthDataType.stand.rawValue,
  176 + time: $0.time,
  177 + value: $0.value
  178 + )
  179 + }
  180 + try await self.uploadCommonBatches(body)
  181 + try await self.uploadTodayActivityTarget()
  182 + }
  183 + }
  184 +
  185 + private func registerIrregularRhythm() {
  186 + guard let type = HKObjectType.categoryType(forIdentifier: .irregularHeartRhythmEvent) else { return }
  187 + register(type) { [weak self] in
  188 + guard let self else { return }
  189 + let dataType = HealthDataType.irregularHeartRhythm.rawValue
  190 + try await self.processCategoryChanges(type: type, dataType: dataType) { samples in
  191 + samples.forEach { sample in
  192 + self.debugLogCommonUpload(
  193 + dataType: dataType,
  194 + time: sample.endDate,
  195 + value: Double(sample.value)
  196 + )
  197 + }
  198 + let body = samples.map {
  199 + [
  200 + "data_type": HealthDataType.irregularHeartRhythm.rawValue,
  201 + "time": $0.endDate.timeIntervalSince1970,
  202 + "value": $0.value,
  203 + ] as [String: Any]
  204 + }
  205 + try await self.uploadCommonBatches(body)
  206 + }
  207 + }
  208 + }
  209 +
  210 + private func registerSleep() {
  211 + guard let type = HKObjectType.categoryType(forIdentifier: .sleepAnalysis) else { return }
  212 + register(type) { [weak self] in
  213 + guard let self else { return }
  214 + try await self.processCategoryChanges(type: type, dataType: 100) { samples in
  215 + samples.forEach { sample in
  216 + self.debugLogSleepUpload(sample)
  217 + }
  218 + let body = samples.map {
  219 + [
  220 + "data_type": $0.value,
  221 + "from_time": $0.startDate.timeIntervalSince1970,
  222 + "to_time": $0.endDate.timeIntervalSince1970,
  223 + ] as [String: Any]
  224 + }
  225 + try await self.uploadSleepBatches(body)
  226 + try await self.uploadSleepingHeartRates(for: samples)
  227 + }
  228 + await MainActor.run { WatchDataManager.share.fetchMyTodayData() }
  229 + }
  230 + }
  231 +
  232 + private func register(
  233 + _ sampleType: HKSampleType,
  234 + widgetDataType: HealthDataType? = nil,
  235 + handler: @escaping () async throws -> Void
  236 + ) {
  237 + uploadHandlers.append(handler)
  238 + let query = HKObserverQuery(sampleType: sampleType, predicate: nil) { _, completion, error in
  239 + guard error == nil else {
  240 + #if DEBUG
  241 + DebugLogger.debugLog("[WatchHealthObserver] \(sampleType.identifier) observer error: \(error!.localizedDescription)")
  242 + #endif
  243 + completion()
  244 + return
  245 + }
  246 +
  247 + Task {
  248 + if let widgetDataType {
  249 + await MainActor.run {
  250 + WatchHealthWidgetRefreshCoordinator.shared.refresh(for: widgetDataType)
  251 + }
  252 + }
  253 + guard self.hasValidLogin else {
  254 + #if DEBUG
  255 + DebugLogger.debugLog("[WatchHealthObserver] \(sampleType.identifier) widget refreshed; upload skipped because user is not logged in")
  256 + #endif
  257 + completion()
  258 + return
  259 + }
  260 + do {
  261 + try await self.uploadQueue.run(handler)
  262 + #if DEBUG
  263 + DebugLogger.debugLog("[WatchHealthObserver] \(sampleType.identifier) upload completed")
  264 + #endif
  265 + } catch {
  266 + DebugLogger.debugLog("[WatchHealthObserver] \(sampleType.identifier) upload failed: \(error.localizedDescription)")
  267 + }
  268 + completion()
  269 + }
  270 + }
  271 + observers.append(query)
  272 + healthStore.execute(query)
  273 + healthStore.enableBackgroundDelivery(for: sampleType, frequency: .immediate) { success, error in
  274 + if let error {
  275 + DebugLogger.debugLog("[WatchHealthObserver] \(sampleType.identifier) background delivery failed: \(error.localizedDescription)")
  276 + } else if !success {
  277 + DebugLogger.debugLog("[WatchHealthObserver] \(sampleType.identifier) background delivery was not enabled")
  278 + }
  279 + }
  280 + }
  281 +
  282 + private func uploadPendingChanges() async {
  283 + guard hasValidLogin else {
  284 + #if DEBUG
  285 + DebugLogger.debugLog("[WatchHealthObserver] pending upload skipped, user is not logged in")
  286 + #endif
  287 + return
  288 + }
  289 + for handler in uploadHandlers {
  290 + do {
  291 + try await uploadQueue.run(handler)
  292 + } catch {
  293 + DebugLogger.debugLog("[WatchHealthObserver] pending upload failed: \(error.localizedDescription)")
  294 + }
  295 + }
  296 + }
  297 +
  298 + private func processQuantityChanges(
  299 + type: HKQuantityType,
  300 + dataType: Int,
  301 + upload: @escaping ([HKQuantitySample]) async throws -> Void
  302 + ) async throws {
  303 + let startDate = try await serverUploadStartDate(for: dataType)
  304 + let predicate = uploadPredicate(startDate: startDate)
  305 + // The anchor is intentionally in-memory only and is used solely to
  306 + // page this query. The next upload boundary always comes from server.
  307 + var anchor: HKQueryAnchor?
  308 + var acquiredTrigger = false
  309 + while true {
  310 + let page: ([HKQuantitySample], HKQueryAnchor) = try await anchoredPage(
  311 + type: type,
  312 + predicate: predicate,
  313 + anchor: anchor
  314 + )
  315 + let newSamples = page.0.filter { $0.endDate > startDate }
  316 + debugLogTimeComparison(
  317 + dataType: dataType,
  318 + serverTime: startDate,
  319 + newestTime: newSamples.map(\.endDate).max()
  320 + )
  321 + if !newSamples.isEmpty {
  322 + if !acquiredTrigger {
  323 + guard await allowUploadTrigger(for: dataType) else { return }
  324 + acquiredTrigger = true
  325 + }
  326 + try await upload(newSamples)
  327 + }
  328 + anchor = page.1
  329 + if page.0.count < 200 { return }
  330 + }
  331 + }
  332 +
  333 + private struct CumulativeUploadDay {
  334 + let day: Date
  335 + let latestSampleTime: Date
  336 + }
  337 +
  338 + private func cumulativeUploadDays(
  339 + type: HKQuantityType,
  340 + dataType: Int
  341 + ) async throws -> [CumulativeUploadDay] {
  342 + let calendar = Calendar.current
  343 + let serverStartDate = try await serverUploadStartDate(for: dataType)
  344 + let queryStartDate = calendar.startOfDay(for: serverStartDate)
  345 + let predicate = uploadPredicate(startDate: queryStartDate)
  346 + var latestSampleTimeByDay: [Date: Date] = [:]
  347 + var anchor: HKQueryAnchor?
  348 +
  349 + while true {
  350 + let page: ([HKQuantitySample], HKQueryAnchor) = try await anchoredPage(
  351 + type: type,
  352 + predicate: predicate,
  353 + anchor: anchor
  354 + )
  355 + for sample in page.0 where sample.endDate > serverStartDate {
  356 + let day = calendar.startOfDay(for: sample.startDate)
  357 + latestSampleTimeByDay[day] = max(
  358 + latestSampleTimeByDay[day] ?? .distantPast,
  359 + sample.endDate
  360 + )
  361 + }
  362 + anchor = page.1
  363 + if page.0.count < 200 { break }
  364 + }
  365 +
  366 + // Upload chronologically. If a later batch fails, the server cursor
  367 + // remains on the last successful historical day instead of jumping to
  368 + // today and permanently skipping the remaining backlog.
  369 + let result = latestSampleTimeByDay
  370 + .map { CumulativeUploadDay(day: $0.key, latestSampleTime: $0.value) }
  371 + .sorted { $0.latestSampleTime < $1.latestSampleTime }
  372 + debugLogTimeComparison(
  373 + dataType: dataType,
  374 + serverTime: serverStartDate,
  375 + newestTime: result.map(\.latestSampleTime).max()
  376 + )
  377 + return result
  378 + }
  379 +
  380 + private func processCategoryChanges(
  381 + type: HKCategoryType,
  382 + dataType: Int,
  383 + upload: @escaping ([HKCategorySample]) async throws -> Void
  384 + ) async throws {
  385 + let startDate = try await serverUploadStartDate(for: dataType)
  386 + let predicate = uploadPredicate(startDate: startDate)
  387 + // The anchor is intentionally in-memory only and is used solely to
  388 + // page this query. The next upload boundary always comes from server.
  389 + var anchor: HKQueryAnchor?
  390 + var acquiredTrigger = false
  391 + while true {
  392 + let page: ([HKCategorySample], HKQueryAnchor) = try await anchoredPage(
  393 + type: type,
  394 + predicate: predicate,
  395 + anchor: anchor
  396 + )
  397 + let newSamples = page.0.filter { $0.endDate > startDate }
  398 + debugLogTimeComparison(
  399 + dataType: dataType,
  400 + serverTime: startDate,
  401 + newestTime: newSamples.map(\.endDate).max()
  402 + )
  403 + if !newSamples.isEmpty {
  404 + if !acquiredTrigger {
  405 + guard await allowUploadTrigger(for: dataType) else { return }
  406 + acquiredTrigger = true
  407 + }
  408 + try await upload(newSamples)
  409 + }
  410 + anchor = page.1
  411 + if page.0.count < 200 { return }
  412 + }
  413 + }
  414 +
  415 + private func anchoredPage<T: HKSample>(
  416 + type: HKSampleType,
  417 + predicate: NSPredicate,
  418 + anchor: HKQueryAnchor?
  419 + ) async throws -> ([T], HKQueryAnchor) {
  420 + try await withCheckedThrowingContinuation { continuation in
  421 + let query = HKAnchoredObjectQuery(
  422 + type: type,
  423 + predicate: predicate,
  424 + anchor: anchor,
  425 + limit: 200
  426 + ) { _, samples, _, newAnchor, error in
  427 + if let error { continuation.resume(throwing: error) }
  428 + else if let newAnchor {
  429 + continuation.resume(returning: (samples as? [T] ?? [], newAnchor))
  430 + } else {
  431 + continuation.resume(throwing: WatchHealthObserverError.missingAnchor)
  432 + }
  433 + }
  434 + healthStore.execute(query)
  435 + }
  436 + }
  437 +
  438 + private func dailyCumulative(type: HKQuantityType, unit: HKUnit, day: Date) async throws -> Double {
  439 + let calendar = Calendar.current
  440 + let start = calendar.startOfDay(for: day)
  441 + let nextDay = calendar.date(byAdding: .day, value: 1, to: start) ?? Date()
  442 + let end = min(nextDay, Date())
  443 + let predicate = HKQuery.predicateForSamples(
  444 + withStart: start, end: end, options: .strictStartDate
  445 + )
  446 + return try await withCheckedThrowingContinuation { continuation in
  447 + let query = HKStatisticsQuery(quantityType: type, quantitySamplePredicate: predicate, options: .cumulativeSum) {
  448 + _, result, error in
  449 + if let error { continuation.resume(throwing: error) }
  450 + else { continuation.resume(returning: result?.sumQuantity()?.doubleValue(for: unit) ?? 0) }
  451 + }
  452 + healthStore.execute(query)
  453 + }
  454 + }
  455 +
  456 + private func todayActivitySummary() async throws -> HKActivitySummary? {
  457 + try await activitySummary(for: Date())
  458 + }
  459 +
  460 + private func activitySummary(for date: Date) async throws -> HKActivitySummary? {
  461 + let calendar = Calendar.current
  462 + var day = calendar.dateComponents([.era, .year, .month, .day], from: date)
  463 + day.calendar = calendar
  464 + let predicate = HKQuery.predicate(forActivitySummariesBetweenStart: day, end: day)
  465 + return try await withCheckedThrowingContinuation { continuation in
  466 + let query = HKActivitySummaryQuery(predicate: predicate) { _, summaries, error in
  467 + if let error { continuation.resume(throwing: error) }
  468 + else { continuation.resume(returning: summaries?.last) }
  469 + }
  470 + healthStore.execute(query)
  471 + }
  472 + }
  473 +
  474 + private func uploadTodayActivityTarget() async throws {
  475 + guard let summary = try await todayActivitySummary() else { return }
  476 + try await uploadActivityTarget(summary)
  477 + }
  478 +
  479 + private func uploadSleepingHeartRates(for sleepSamples: [HKCategorySample]) async throws {
  480 + let dataType = HealthDataType.sleepingHeartRate.rawValue
  481 + let serverStartDate = try await serverUploadStartDate(for: dataType)
  482 + let asleepIntervals = sleepSamples.filter {
  483 + guard let value = HKCategoryValueSleepAnalysis(rawValue: $0.value) else { return false }
  484 + switch value {
  485 + case .asleepUnspecified, .asleepCore, .asleepDeep, .asleepREM:
  486 + return true
  487 + default:
  488 + return false
  489 + }
  490 + }
  491 + guard let start = asleepIntervals.map(\.startDate).min(),
  492 + let end = asleepIntervals.map(\.endDate).max(),
  493 + let heartRateType = HKObjectType.quantityType(forIdentifier: .heartRate) else {
  494 + return
  495 + }
  496 +
  497 + let predicate = HKQuery.predicateForSamples(withStart: start, end: end, options: [])
  498 + let heartRates: [HKQuantitySample] = try await withCheckedThrowingContinuation { continuation in
  499 + let query = HKSampleQuery(
  500 + sampleType: heartRateType,
  501 + predicate: predicate,
  502 + limit: HKObjectQueryNoLimit,
  503 + sortDescriptors: nil
  504 + ) { _, samples, error in
  505 + if let error { continuation.resume(throwing: error) }
  506 + else { continuation.resume(returning: samples as? [HKQuantitySample] ?? []) }
  507 + }
  508 + healthStore.execute(query)
  509 + }
  510 + let sleepingHeartRates = heartRates.filter { sample in
  511 + sample.endDate > serverStartDate && asleepIntervals.contains { interval in
  512 + sample.startDate < interval.endDate && sample.endDate > interval.startDate
  513 + }
  514 + }.sorted { $0.endDate < $1.endDate }
  515 + guard !sleepingHeartRates.isEmpty else { return }
  516 + guard await allowUploadTrigger(for: dataType) else { return }
  517 + let unit = HKUnit.count().unitDivided(by: .minute())
  518 + let body = sleepingHeartRates.map {
  519 + [
  520 + "data_type": HealthDataType.sleepingHeartRate.rawValue,
  521 + "time": $0.endDate.timeIntervalSince1970,
  522 + "value": $0.quantity.doubleValue(for: unit),
  523 + ] as [String: Any]
  524 + }
  525 + sleepingHeartRates.forEach { sample in
  526 + debugLogCommonUpload(
  527 + dataType: dataType,
  528 + time: sample.endDate,
  529 + value: sample.quantity.doubleValue(for: unit)
  530 + )
  531 + }
  532 + try await uploadCommonBatches(body)
  533 + }
  534 +
  535 + private func uploadActivityTarget(_ summary: HKActivitySummary) async throws {
  536 + var body: [String: Any] = [
  537 + "move": Int(summary.activeEnergyBurnedGoal.doubleValue(for: .kilocalorie())),
  538 + "stand": Int(summary.appleStandHoursGoal.doubleValue(for: .count())),
  539 + "active_energy_burned": summary.activeEnergyBurned.doubleValue(for: .kilocalorie()),
  540 + "active_energy_burned_goal": summary.activeEnergyBurnedGoal.doubleValue(for: .kilocalorie()),
  541 + "apple_exercise_time": summary.appleExerciseTime.doubleValue(for: .second()),
  542 + "apple_exercise_time_goal": summary.appleExerciseTimeGoal.doubleValue(for: .second()),
  543 + "apple_stand_hours": summary.appleStandHours.doubleValue(for: .count()),
  544 + "apple_stand_hours_goal": summary.appleStandHoursGoal.doubleValue(for: .count()),
  545 + ]
  546 + body["activity_move_mode"] = summary.activityMoveMode.rawValue
  547 + body["apple_move_time"] = summary.appleMoveTime.doubleValue(for: .second())
  548 + body["apple_move_time_goal"] = summary.appleMoveTimeGoal.doubleValue(for: .second())
  549 + body["exercise_time_goal"] = summary.exerciseTimeGoal?.doubleValue(for: .second())
  550 + body["stand_hours_goal"] = summary.standHoursGoal?.doubleValue(for: .count())
  551 + _ = try await healthService.uploadActivityTarget(data: body)
  552 + }
  553 +
  554 + private func serverUploadStartDate(for dataType: Int) async throws -> Date {
  555 + let list = try await serverUploadTimes()
  556 + if let date = list.latestDate(for: dataType) {
  557 + #if DEBUG
  558 + DebugLogger.debugLog("[WatchHealthUpload][server-time] dataType=\(dataType), time=\(debugTimestamp(date)), unix=\(Int64(date.timeIntervalSince1970))")
  559 + #endif
  560 + return date
  561 + }
  562 +
  563 + let fallback = Calendar.current.date(byAdding: .year, value: -2, to: Date())
  564 + ?? Date(timeIntervalSinceNow: -2 * 365 * 24 * 60 * 60)
  565 + let date = Calendar.current.startOfDay(for: fallback)
  566 + #if DEBUG
  567 + DebugLogger.debugLog("[WatchHealthUpload][server-time] dataType=\(dataType), missing=true, fallback=\(debugTimestamp(date)), unix=\(Int64(date.timeIntervalSince1970))")
  568 + #endif
  569 + return date
  570 + }
  571 +
  572 + private func serverUploadTimes() async throws -> WatchHealthUploadTimeList {
  573 + let userId = currentUserId
  574 + if let uploadTimeCache,
  575 + let uploadTimeCacheDate,
  576 + uploadTimeCacheUserId == userId,
  577 + Date().timeIntervalSince(uploadTimeCacheDate) < 5 {
  578 + return uploadTimeCache
  579 + }
  580 + let result = try await healthService.getUploadTimes()
  581 + uploadTimeCache = result
  582 + uploadTimeCacheDate = Date()
  583 + uploadTimeCacheUserId = userId
  584 + return result
  585 + }
  586 +
  587 + private func uploadPredicate(startDate: Date) -> NSPredicate {
  588 + HKQuery.predicateForSamples(withStart: startDate, end: Date(), options: [])
  589 + }
  590 +
  591 + private func uploadCommonBatches(_ data: [[String: Any]]) async throws {
  592 + for start in stride(from: 0, to: data.count, by: 200) {
  593 + _ = try await healthService.uploadBatch(data: Array(data[start..<min(start + 200, data.count)]))
  594 + }
  595 + }
  596 +
  597 + private func allowUploadTrigger(for dataType: Int) async -> Bool {
  598 + let allowed = await uploadThrottle.acquire(dataType: dataType)
  599 + #if DEBUG
  600 + if !allowed {
  601 + DebugLogger.debugLog(
  602 + "[WatchHealthUpload][throttle] dataType=\(dataType), skipped=true, interval=\(Int(WatchHealthUploadConfiguration.minimumTriggerInterval))s, now=\(debugTimestamp(Date()))"
  603 + )
  604 + }
  605 + #endif
  606 + return allowed
  607 + }
  608 +
  609 + private func uploadSleepBatches(_ data: [[String: Any]]) async throws {
  610 + for start in stride(from: 0, to: data.count, by: 200) {
  611 + _ = try await healthService.uploadSleep(data: Array(data[start..<min(start + 200, data.count)]))
  612 + }
  613 + }
  614 +
  615 + private static let debugDateFormatter: DateFormatter = {
  616 + let formatter = DateFormatter()
  617 + formatter.locale = Locale(identifier: "en_US_POSIX")
  618 + formatter.dateFormat = "yyyy-MM-dd HH:mm:ss"
  619 + return formatter
  620 + }()
  621 + private static let debugDateFormatterLock = NSLock()
  622 +
  623 + private func debugTimestamp(_ date: Date) -> String {
  624 + Self.debugDateFormatterLock.lock()
  625 + defer { Self.debugDateFormatterLock.unlock() }
  626 + return Self.debugDateFormatter.string(from: date)
  627 + }
  628 +
  629 + private func debugLogTimeComparison(
  630 + dataType: Int,
  631 + serverTime: Date,
  632 + newestTime: Date?
  633 + ) {
  634 + #if DEBUG || CI_ENV
  635 + let newestDescription = newestTime.map {
  636 + "\(debugTimestamp($0)) (unix=\(Int64($0.timeIntervalSince1970)))"
  637 + } ?? "<none>"
  638 + DebugLogger.debugLog(
  639 + "[WatchHealthUpload][time-check] dataType=\(dataType), server=\(debugTimestamp(serverTime)) (unix=\(Int64(serverTime.timeIntervalSince1970))), newest=\(newestDescription), shouldUpload=\(newestTime.map { $0 > serverTime } ?? false)"
  640 + )
  641 + #endif
  642 + }
  643 +
  644 + private func debugLogCommonUpload(dataType: Int, time: Date, value: Double) {
  645 + #if DEBUG || CI_ENV
  646 + DebugLogger.debugLog(
  647 + "[WatchHealthUpload][upload] dataType=\(dataType), time=\(debugTimestamp(time)), unix=\(Int64(time.timeIntervalSince1970)), value=\(value)"
  648 + )
  649 + #endif
  650 + }
  651 +
  652 + private func debugLogSleepUpload(_ sample: HKCategorySample) {
  653 + #if DEBUG || CI_ENV
  654 + DebugLogger.debugLog(
  655 + "[WatchHealthUpload][upload][sleep] dataType=\(sample.value), from=\(debugTimestamp(sample.startDate)), fromUnix=\(Int64(sample.startDate.timeIntervalSince1970)), to=\(debugTimestamp(sample.endDate)), toUnix=\(Int64(sample.endDate.timeIntervalSince1970))"
  656 + )
  657 + #endif
  658 + }
  659 +
  660 + private var hasValidLogin: Bool {
  661 + guard let data = AppGroupConstants.defaults?.data(forKey: AppGroupConstants.Key.myUserInfo),
  662 + let login = try? JSONDecoder().decode(UserInfoModel.self, from: data),
  663 + let token = login.token else {
  664 + return false
  665 + }
  666 + return !token.isEmpty
  667 + }
  668 +
  669 + private var currentUserId: Int {
  670 + guard let data = AppGroupConstants.defaults?.data(forKey: AppGroupConstants.Key.myUserInfo),
  671 + let login = try? JSONDecoder().decode(UserInfoModel.self, from: data) else {
  672 + return 0
  673 + }
  674 + return login.userId ?? 0
  675 + }
  676 +}
  677 +
  678 +private enum WatchHealthObserverError: Error {
  679 + case missingAnchor
  680 +}
  1 +import Foundation
  2 +import HealthKit
  3 +
  4 +/// Bridges HealthKit observer events to the three HealthKit-backed widgets.
  5 +/// This path is intentionally independent of login state and server uploads.
  6 +@MainActor
  7 +final class WatchHealthWidgetRefreshCoordinator {
  8 + static let shared = WatchHealthWidgetRefreshCoordinator()
  9 +
  10 + private let healthStore = HKHealthStore()
  11 + private let defaultStepGoal = 10_000
  12 +
  13 + private init() {}
  14 +
  15 + func refresh(for dataType: HealthDataType) {
  16 + switch dataType {
  17 + case .heartRate:
  18 + WatchDataManager.share.refreshHeartRateWidgetData()
  19 + WatchDataManager.share.getMyLatestData()
  20 + case .steps:
  21 + WatchDataManager.share.refreshStepWidgetData()
  22 + refreshLocalActivityTarget()
  23 + case .move, .exercise, .stand:
  24 + WatchDataManager.share.refreshActivityWidgetData()
  25 + refreshLocalActivityTarget()
  26 + default:
  27 + break
  28 + }
  29 + }
  30 +
  31 + func refreshAll() {
  32 + WatchDataManager.share.getMyLatestData()
  33 + WatchDataManager.share.refreshHeartRateWidgetData()
  34 + WatchDataManager.share.refreshStepWidgetData()
  35 + WatchDataManager.share.refreshActivityWidgetData()
  36 + refreshLocalActivityTarget()
  37 + }
  38 +
  39 + func refreshActivityTargetData() {
  40 + refreshLocalActivityTarget()
  41 + }
  42 +
  43 + private func refreshLocalActivityTarget() {
  44 + let defaultStepGoal = defaultStepGoal
  45 + let calendar = Calendar.current
  46 + var start = calendar.dateComponents([.era, .year, .month, .day], from: Date())
  47 + var end = start
  48 + start.calendar = calendar
  49 + end.calendar = calendar
  50 +
  51 + let predicate = HKQuery.predicate(forActivitySummariesBetweenStart: start, end: end)
  52 + let query = HKActivitySummaryQuery(predicate: predicate) { _, summaries, error in
  53 + guard error == nil else { return }
  54 + guard let summary = summaries?.last else {
  55 + AppGroupConstants.defaults?.removeObject(
  56 + forKey: AppGroupConstants.Key.localHealthActivityTarget
  57 + )
  58 + WatchWidgetReloader.reloadActivityTarget()
  59 + return
  60 + }
  61 +
  62 + let target = ActivityTarget(
  63 + id: nil,
  64 + create_time: nil,
  65 + update_time: nil,
  66 + user_id: nil,
  67 + move: Int(summary.activeEnergyBurnedGoal.doubleValue(for: .kilocalorie())),
  68 + step: defaultStepGoal,
  69 + stand: Int(summary.appleStandHoursGoal.doubleValue(for: .count())),
  70 + exercise: Int(summary.appleExerciseTimeGoal.doubleValue(for: .minute())),
  71 + activity_move_mode: summary.activityMoveMode.rawValue,
  72 + active_energy_burned: summary.activeEnergyBurned.doubleValue(for: .kilocalorie()),
  73 + active_energy_burned_goal: summary.activeEnergyBurnedGoal.doubleValue(for: .kilocalorie()),
  74 + apple_move_time: summary.appleMoveTime.doubleValue(for: .minute()),
  75 + apple_move_time_goal: summary.appleMoveTimeGoal.doubleValue(for: .minute()),
  76 + apple_exercise_time: summary.appleExerciseTime.doubleValue(for: .minute()),
  77 + exercise_time_goal: summary.exerciseTimeGoal?.doubleValue(for: .minute()),
  78 + apple_stand_hours: summary.appleStandHours.doubleValue(for: .count()),
  79 + stand_hours_goal: summary.standHoursGoal?.doubleValue(for: .count())
  80 + )
  81 +
  82 + guard let data = try? JSONEncoder().encode(target) else { return }
  83 + AppGroupConstants.defaults?.set(data, forKey: AppGroupConstants.Key.localHealthActivityTarget)
  84 + WatchWidgetReloader.reloadActivityTarget()
  85 + }
  86 + healthStore.execute(query)
  87 + }
  88 +}
@@ -11,85 +11,177 @@ import Combine @@ -11,85 +11,177 @@ import Combine
11 import ClockKit 11 import ClockKit
12 12
13 class WatchSessionManager: NSObject, WCSessionDelegate, ObservableObject { 13 class WatchSessionManager: NSObject, WCSessionDelegate, ObservableObject {
14 - 14 +
15 static let share = WatchSessionManager() 15 static let share = WatchSessionManager()
16 - 16 +
17 override init() { 17 override init() {
18 super.init() 18 super.init()
19 } 19 }
20 - 20 +
21 func addListen() { 21 func addListen() {
22 if WCSession.isSupported() { 22 if WCSession.isSupported() {
23 WCSession.default.delegate = self 23 WCSession.default.delegate = self
24 - WCSession.default.activate() 24 + if WCSession.default.activationState == .notActivated {
  25 + WCSession.default.activate()
  26 + } else if WCSession.default.activationState == .activated {
  27 + requestLoginStateFromPhone()
  28 + }
25 } 29 }
26 } 30 }
27 -  
28 - func session(_ session: WCSession, didReceiveMessage message: [String : Any], replyHandler: @escaping ([String : Any]) -> Void) {  
29 - print("message-replyHandler: \(message)")  
30 - if let command = message["command"] as? String {  
31 - if command == "watchThemeChanged" {  
32 - NotificationCenter.default.post(name: NotificationType.watchThemeChanged, object: nil) 31 +
  32 + private func handleLoginData(_ data: Data) {
  33 + do {
  34 + let model = try JSONDecoder().decode(UserInfoModel.self, from: data)
  35 + Task { @MainActor in
  36 + applyLoginInfo(model, data: data)
33 } 37 }
  38 + } catch {
  39 + print("decode watch login info failed: \(error.localizedDescription)")
34 } 40 }
35 } 41 }
36 -  
37 - func session(_ session: WCSession, didReceiveUserInfo userInfo: [String : Any] = [:]) {  
38 - DispatchQueue.main.async {  
39 - print("userInfo: \(userInfo)")  
40 -  
41 - if let envValue = userInfo["env"] as? Int,  
42 - let env = NetworkEnvironment(rawValue: envValue) {  
43 - NetworkEnvironment.current = env 42 +
  43 + @MainActor
  44 + private func applyLoginInfo(_ model: UserInfoModel, data: Data? = nil) {
  45 + guard model.token?.isEmpty == false else {
  46 + applyLogout()
  47 + return
  48 + }
  49 +
  50 + let storeData = data ?? (try? JSONEncoder().encode(model))
  51 + if let storeData {
  52 + AppGroupConstants.defaults?.set(storeData, forKey: AppGroupConstants.Key.myUserInfo)
  53 + }
  54 +
  55 + guard model != WatchUserinfoManager.share.myUserInfo else {
  56 + return
  57 + }
  58 +
  59 + WatchUserinfoManager.share.login(userInfo: model)
  60 + WatchDataManager.share.fetchMyTodayData()
  61 + WatchDataManager.share.fetchCoupleLatestHRV()
  62 + WatchHealthObserverUploader.shared.performFullUpload()
  63 + }
  64 +
  65 + @MainActor
  66 + private func applyLogout() {
  67 + WatchUserinfoManager.share.logout()
  68 + WatchDataManager.share.logout()
  69 + }
  70 +
  71 + private func requestLoginStateFromPhone() {
  72 + guard WCSession.default.isReachable else { return }
  73 + WCSession.default.sendMessage(
  74 + ["command": AppGroupMessageKey.requestLoginState],
  75 + replyHandler: { [weak self] reply in
  76 + self?.handleLoginStatePayload(reply)
  77 + },
  78 + errorHandler: { error in
  79 + print("❌ Failed to request login state: \(error.localizedDescription)")
  80 + }
  81 + )
  82 + }
  83 +
  84 + private func handleLoginStatePayload(_ payload: [String: Any]) {
  85 + if let command = payload["command"] as? String,
  86 + command == AppGroupMessageKey.logout {
  87 + Task { @MainActor in
  88 + self.applyLogout()
  89 + }
  90 + return
  91 + }
  92 +
  93 + if let json = payload["json"] as? String,
  94 + let data = json.data(using: .utf8) {
  95 + handleLoginData(data)
  96 + return
  97 + }
  98 +
  99 + do {
  100 + let data = try JSONSerialization.data(withJSONObject: payload)
  101 + handleLoginData(data)
  102 + } catch {
  103 + print("serialize watch login state failed: \(error.localizedDescription)")
  104 + }
  105 + }
  106 +
  107 + private func handleCommand(command: String, json: String?){
  108 + if command == AppGroupMessageKey.login{
  109 + // 登录, 刷新所有数据
  110 + guard let data = json?.data(using: .utf8) else{
  111 + return
44 } 112 }
45 -  
46 - if let token = userInfo["token"] as? String {  
47 - WatchUserinfoManager.share.token = token 113 + handleLoginData(data)
  114 +
  115 + }else if command == AppGroupMessageKey.reloadTheme {
  116 + // 刷新表盘等
  117 + Task { @MainActor in
  118 + WatchUserinfoManager.share.reloadTheme()
48 } 119 }
49 -  
50 - if let map = userInfo["myUserinfo"] as? [String: Any],  
51 - let data = try? JSONSerialization.data(withJSONObject: map),  
52 - let myUserinfo = try? JSONDecoder().decode(UserInfo.self, from: data) {  
53 - WatchUserinfoManager.share.myUserinfo = myUserinfo  
54 - if let id = myUserinfo.id {  
55 - ThinkSDKUtil.login(id)  
56 - } 120 + }else if command == AppGroupMessageKey.reloadAll{
  121 + // 刷新数据
  122 + Task { @MainActor in
  123 + WatchUserinfoManager.share.reloadLogin()
57 } 124 }
58 -  
59 - if let map = userInfo["otherUserinfo"] as? [String: Any],  
60 - let data = try? JSONSerialization.data(withJSONObject: map),  
61 - let otherUserinfo = try? JSONDecoder().decode(UserInfo.self, from: data) {  
62 - WatchUserinfoManager.share.otherUserinfo = otherUserinfo 125 + }else if command == AppGroupMessageKey.reloadVip{
  126 + // 刷新会员
  127 + Task { @MainActor in
  128 + WatchUserinfoManager.share.reloadVip()
63 } 129 }
64 -  
65 - if let interactionActionTypeValue = userInfo["interactionActionType"] as? Int,  
66 - let type = InteractionActionType(rawValue: interactionActionTypeValue) {  
67 - WatchUserinfoManager.share.interactionActionType = type 130 + }else if command == AppGroupMessageKey.logout{
  131 + // 退出登录
  132 + Task { @MainActor in
  133 + self.applyLogout()
68 } 134 }
69 -  
70 - if let isMeVip = userInfo["isMeVip"] as? Bool {  
71 - WatchUserinfoManager.share.isMeVip = isMeVip 135 + }else if command == AppGroupMessageKey.statusPulseRefresh{
  136 + Task { @MainActor in
  137 + WatchDataManager.share.fetchMyTodayData()
72 } 138 }
73 } 139 }
74 } 140 }
75 - 141 +
  142 + func session(_ session: WCSession, didReceiveMessage message: [String : Any], replyHandler: @escaping ([String : Any]) -> Void) {
  143 + print("message-replyHandler: \(message)")
  144 + if let command = message["command"] as? String {
  145 + handleCommand(command: command, json: message["json"] as? String)
  146 + }
  147 + replyHandler(["success": true])
  148 + }
  149 +
  150 + func session(_ session: WCSession, didReceiveUserInfo userInfo: [String : Any] = [:]) {
  151 + print("received userInfo transform: \(userInfo)")
  152 + }
  153 +
76 func session(_ session: WCSession, didReceiveMessage message: [String : Any]) { 154 func session(_ session: WCSession, didReceiveMessage message: [String : Any]) {
77 print("message: \(message)") 155 print("message: \(message)")
  156 + if let command = message["command"] as? String {
  157 + handleCommand(command: command, json: message["json"] as? String)
  158 + }
78 } 159 }
79 160
80 func session(_ session: WCSession, didReceiveApplicationContext applicationContext: [String : Any]) { 161 func session(_ session: WCSession, didReceiveApplicationContext applicationContext: [String : Any]) {
81 - print("applicationContext: \(applicationContext)") 162 + print("received applicationContext: \(applicationContext)")
  163 + do{
  164 + let data = try JSONSerialization.data(withJSONObject: applicationContext)
  165 + let model = try JSONDecoder().decode(UserInfoModel.self, from: data)
  166 + Task { @MainActor in
  167 + self.applyLoginInfo(model, data: data)
  168 + }
  169 + }catch{
  170 + print("handle applicationContext failed: \(error.localizedDescription)")
  171 + }
82 } 172 }
83 - 173 +
84 func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: (any Error)?) { 174 func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: (any Error)?) {
85 print("Activation state: \(activationState.rawValue)") 175 print("Activation state: \(activationState.rawValue)")
86 if let error = error { 176 if let error = error {
87 print("Activation error: \(error.localizedDescription)") 177 print("Activation error: \(error.localizedDescription)")
  178 + } else if activationState == .activated {
  179 + requestLoginStateFromPhone()
88 } 180 }
89 } 181 }
90 #if os(iOS) 182 #if os(iOS)
91 func sessionDidBecomeInactive(_ session: WCSession) { 183 func sessionDidBecomeInactive(_ session: WCSession) {
92 - 184 +
93 } 185 }
94 func sessionDidDeactivate(_ session: WCSession) { 186 func sessionDidDeactivate(_ session: WCSession) {
95 187
@@ -103,7 +195,7 @@ class WatchSessionManager: NSObject, WCSessionDelegate, ObservableObject { @@ -103,7 +195,7 @@ class WatchSessionManager: NSObject, WCSessionDelegate, ObservableObject {
103 extension WatchSessionManager { 195 extension WatchSessionManager {
104 func notifyPhoneToRefreshPulse() { 196 func notifyPhoneToRefreshPulse() {
105 if WCSession.default.isReachable { 197 if WCSession.default.isReachable {
106 - WCSession.default.sendMessage(["command": "statusPulseRefresh"], 198 + WCSession.default.sendMessage(["command": AppGroupMessageKey.statusPulseRefresh],
107 replyHandler: nil, 199 replyHandler: nil,
108 errorHandler: { error in 200 errorHandler: { error in
109 print("❌ Failed to send refresh message: \(error)") 201 print("❌ Failed to send refresh message: \(error)")
@@ -112,4 +204,18 @@ extension WatchSessionManager { @@ -112,4 +204,18 @@ extension WatchSessionManager {
112 print("📵 iPhone not reachable") 204 print("📵 iPhone not reachable")
113 } 205 }
114 } 206 }
  207 +
  208 + func sendWatchDeviceToken(token: String){
  209 + if WCSession.default.isReachable {
  210 + WCSession.default.sendMessage(["command": AppGroupMessageKey.watchDeviceToken,
  211 + "token": token
  212 + ],
  213 + replyHandler: nil,
  214 + errorHandler: { error in
  215 + print("❌ Failed to send token message: \(error)")
  216 + })
  217 + } else {
  218 + print("📵 iPhone not reachable")
  219 + }
  220 + }
115 } 221 }
@@ -6,71 +6,335 @@ @@ -6,71 +6,335 @@
6 // 6 //
7 7
8 import Foundation 8 import Foundation
  9 +import WidgetKit
9 import Combine 10 import Combine
10 11
11 -class WatchUserinfoManager: ObservableObject { 12 +extension WatchThemeModel{
  13 + func cleanCache() {
  14 + if let energeticImageURL{
  15 + AppGroupConstants.defaults?.removeObject(forKey: energeticImageURL)
  16 + }
  17 + if let normalImageURL{
  18 + AppGroupConstants.defaults?.removeObject(forKey: normalImageURL)
  19 + }
  20 + if let slightStressImageURL{
  21 + AppGroupConstants.defaults?.removeObject(forKey: slightStressImageURL)
  22 + }
  23 + if let stressfulImageURL{
  24 + AppGroupConstants.defaults?.removeObject(forKey: stressfulImageURL)
  25 + }
  26 + }
  27 +}
  28 +
  29 +@MainActor
  30 +final class WatchUserinfoManager: ObservableObject {
12 static let share = WatchUserinfoManager() 31 static let share = WatchUserinfoManager()
13 - var token: String? {  
14 - didSet {  
15 - UserDefaults.standard.set(token, forKey: "access_token")  
16 - UserDefaults.standard.synchronize() 32 +
  33 + private let service = UserInfoService()
  34 + private let themeService = ThemeService()
  35 +
  36 + var isLogin: Bool{
  37 + return token?.count ?? 0 > 0
  38 + }
  39 +
  40 + var isMeVip: Bool? {
  41 + vipInfo?.isAvaiableVip
  42 + }
  43 + var baseUrl: String{
  44 + myUserInfo?.baseUrl ?? "https://api.doublefeel.cn"
  45 + }
  46 +
  47 + var token: String?{
  48 + myUserInfo?.token
  49 + }
  50 +
  51 + private(set) var myUserInfo: UserInfoModel?
  52 +
  53 + @Published var displayFriend: HAppFriend?
  54 + @Published var vipInfo: HVipInfo?
  55 + @Published var showRealtimeHRV: Bool
  56 + @Published var interactionActionType: InteractionActionType = .stick
  57 +
  58 + var myUserinfo: UserInfo? {
  59 + nil
  60 + }
  61 +
  62 + // 表盘主题
  63 + @Published var myTheme: WatchThemeModel?
  64 + @Published var otherTheme: WatchThemeModel?
  65 +
  66 + private var didSetup = false
  67 +
  68 + var displayType: HealthType{
  69 + showRealtimeHRV ? .realtime_stress : .hrv
  70 + }
  71 +
  72 + private init(){
  73 + showRealtimeHRV = AppGroupConstants.defaults?.bool(
  74 + forKey: AppGroupConstants.Key.showRealtimeHRV
  75 + ) ?? false
  76 +
  77 + guard let data = AppGroupConstants.defaults?.data(forKey: AppGroupConstants.Key.myUserInfo) else{
  78 + return
  79 + }
  80 + do{
  81 + self.myUserInfo = try JSONDecoder().decode(UserInfoModel.self, from: data)
  82 + }catch{}
  83 + }
  84 +
  85 + func setup() {
  86 + guard !didSetup else { return }
  87 + didSetup = true
  88 +
  89 + Task { @MainActor [weak self] in
  90 + await Task.yield()
  91 + self?.reloadLocalFriend()
  92 + self?.loadLocalWatchTheme()
  93 +
  94 + self?.reloadLogin()
17 } 95 }
18 } 96 }
19 - @Published var myUserinfo: UserInfo? {  
20 - didSet {  
21 - UserDefaults.standard.set(myUserinfo?.toWatchMap(), forKey: "myUserinfo")  
22 - UserDefaults.standard.synchronize()  
23 - self.saveMyUserCharacter(myUserinfo?.persona?.rawValue ?? 0) 97 +
  98 + func login(userInfo: UserInfoModel){
  99 + guard let _ = userInfo.token, let _ = userInfo.userId, let _ = userInfo.baseUrl else{
  100 + return
24 } 101 }
  102 + self.myUserInfo = userInfo
  103 +
  104 + reloadLogin()
25 } 105 }
26 - @Published var otherUserinfo: UserInfo? {  
27 - didSet {  
28 - UserDefaults.standard.set(otherUserinfo?.toWatchMap(), forKey: "otherUserinfo")  
29 - UserDefaults.standard.synchronize() 106 +
  107 + @MainActor
  108 + func reloadLogin(){
  109 + // 刷新所有数据
  110 + if isLogin{
  111 + reloadVip()
  112 + reloadFriend()
30 } 113 }
  114 + if let id = self.myUserInfo?.userId {
  115 + ThinkSDKUtil.login(id)
  116 + }
  117 + }
  118 + @MainActor
  119 + func logout(){
  120 + myTheme?.cleanCache()
  121 + otherTheme?.cleanCache()
  122 + myUserInfo = nil
  123 + displayFriend = nil
  124 + vipInfo = nil
  125 + showRealtimeHRV = false
  126 + myTheme = nil
  127 + otherTheme = nil
  128 + AppGroupConstants.clearUserSessionData()
  129 +
  130 + ThinkSDKUtil.logout()
31 } 131 }
32 - @Published var interactionActionType: InteractionActionType {  
33 - didSet {  
34 - UserDefaults.standard.set(interactionActionType.rawValue.string, forKey: "interactionActionType")  
35 - UserDefaults.standard.synchronize() 132 +
  133 + @MainActor
  134 + func switchHRVType(to realtime: Bool){
  135 + showRealtimeHRV = realtime
  136 + AppGroupConstants.defaults?.set(showRealtimeHRV, forKey: AppGroupConstants.Key.showRealtimeHRV)
  137 + // 刷新数据
  138 + WatchDataManager.share.getLatestHealthData()
  139 + WatchDataManager.share.getMyLatestData()
  140 + if let _ = displayFriend?.friend_user_id{
  141 + WatchDataManager.share.getFriendLatestData()
36 } 142 }
37 } 143 }
38 - var isMeVip: Bool? {  
39 - didSet {  
40 - UserDefaults.standard.set(token, forKey: "isMeVip")  
41 - UserDefaults.standard.synchronize()  
42 - }  
43 - }  
44 -  
45 -  
46 - init() {  
47 - let token = UserDefaults.standard.string(forKey: "access_token")  
48 - if let token {  
49 - self.token = token  
50 - }  
51 -  
52 - if let myUserinfo = UserDefaults.standard.dictionary(forKey: "myUserinfo"),  
53 - let data = try? JSONSerialization.data(withJSONObject: myUserinfo),  
54 - let myUserinfo = try? JSONDecoder().decode(UserInfo.self, from: data) {  
55 - self.myUserinfo = myUserinfo  
56 - }  
57 -  
58 - if let otherUserinfo = UserDefaults.standard.dictionary(forKey: "otherUserinfo"),  
59 - let data = try? JSONSerialization.data(withJSONObject: otherUserinfo),  
60 - let otherUserinfo = try? JSONDecoder().decode(UserInfo.self, from: data) {  
61 - self.otherUserinfo = otherUserinfo  
62 - }  
63 -  
64 - let interactionActionTypeString = UserDefaults.standard.string(forKey: "interactionActionType")  
65 - if let interactionActionType = interactionActionTypeString?.int {  
66 - self.interactionActionType = InteractionActionType(rawValue: interactionActionType) ?? .stick  
67 - }else {  
68 - self.interactionActionType = .stick  
69 - }  
70 - }  
71 -  
72 - private func saveMyUserCharacter(_ character: Int) {  
73 - let defaults = AppGroupConstants.defaults  
74 - defaults?.set(character, forKey: AppGroupConstants.Key.myUserCharacter) 144 +
  145 + func reloadTheme(){
  146 + reloadFriend()
75 } 147 }
  148 + //MARK: - 表盘主题
  149 + @MainActor
  150 + private func loadLocalWatchTheme(){
  151 + if let data = AppGroupConstants.defaults?.data(forKey: AppGroupConstants.Key.myWatchTheme),
  152 + let model = try? JSONDecoder().decode(WatchThemeModel.self, from: data) {
  153 + self.myTheme = model
  154 + }else{
  155 + self.myTheme = nil
  156 + }
  157 +
  158 + if let data = AppGroupConstants.defaults?.data(forKey: AppGroupConstants.Key.otherWatchTheme),
  159 + let model = try? JSONDecoder().decode(WatchThemeModel.self, from: data) {
  160 + self.otherTheme = model
  161 + }else{
  162 + self.otherTheme = nil
  163 + }
  164 + downloadThemeImageIfNeeded()
  165 + }
  166 +
  167 + private func loadRemoteWatchTheme() async{
  168 + let myNewTheme = try? await themeService.getCurrentTheme(userId: nil)
  169 + var otherNewTheme: WatchThemeModel?
  170 + if let userId = displayFriend?.user_id{
  171 + otherNewTheme = try? await themeService.getCurrentTheme(userId: userId)
  172 + }
  173 + if let myNewTheme{
  174 + if myNewTheme != myTheme{
  175 + // 清除缓存
  176 + myTheme?.cleanCache()
  177 + myTheme = myNewTheme
  178 + do{
  179 + let data = try JSONEncoder().encode(myNewTheme)
  180 + AppGroupConstants.defaults?.set(data, forKey: AppGroupConstants.Key.myWatchTheme)
  181 + }catch{}
  182 + }
  183 + }else{
  184 + myTheme?.cleanCache()
  185 + myTheme = nil
  186 + AppGroupConstants.defaults?.removeObject(forKey: AppGroupConstants.Key.myWatchTheme)
  187 + }
  188 + if let otherNewTheme{
  189 + if otherNewTheme != otherTheme{
  190 + // 清除缓存
  191 + otherTheme?.cleanCache()
  192 + otherTheme = otherNewTheme
  193 + do{
  194 + let data = try JSONEncoder().encode(otherNewTheme)
  195 + AppGroupConstants.defaults?.set(data, forKey: AppGroupConstants.Key.otherWatchTheme)
  196 + }catch{}
  197 + }
  198 + }else{
  199 + otherTheme?.cleanCache()
  200 + otherTheme = nil
  201 + AppGroupConstants.defaults?.removeObject(forKey: AppGroupConstants.Key.otherWatchTheme)
  202 + }
  203 +
  204 + downloadThemeImageIfNeeded()
  205 + }
  206 +
  207 + @MainActor
  208 + private func downloadThemeImageIfNeeded(){
  209 + guard let defaults = AppGroupConstants.defaults else { return }
  210 +
  211 + let imageURLs = [myTheme, otherTheme]
  212 + .compactMap { $0 }
  213 + .flatMap {
  214 + [
  215 + $0.energeticImageURL,
  216 + $0.normalImageURL,
  217 + $0.slightStressImageURL,
  218 + $0.stressfulImageURL
  219 + ]
  220 + }
  221 + .compactMap { $0 }
  222 +
  223 + let downloadUrls = imageURLs.filter({ defaults.data(forKey: $0) == nil })
  224 +
  225 + let downloadGroup = DispatchGroup()
  226 + var hasDownloadTask = false
  227 +
  228 + for urlString in Set(downloadUrls) {
  229 + guard let url = URL(string: urlString) else { continue }
  230 +
  231 + hasDownloadTask = true
  232 + downloadGroup.enter()
  233 + URLSession.shared.dataTask(with: url) { data, _, error in
  234 + defer { downloadGroup.leave() }
  235 + guard error == nil, let data else { return }
  236 + defaults.set(data, forKey: urlString)
  237 + }.resume()
  238 + }
  239 +
  240 + guard hasDownloadTask else {
  241 + WidgetCenter.shared.reloadTimelines(ofKind: HippoWidgetKind.hrv.rawValue)
  242 + return
  243 + }
  244 + downloadGroup.notify(queue: .main) { [weak self] in
  245 + // 通过published刷新了watch
  246 + self?.objectWillChange.send()
  247 + // 再刷新小组件
  248 + WidgetCenter.shared.reloadTimelines(ofKind: HippoWidgetKind.hrv.rawValue)
  249 + }
  250 + }
  251 +
  252 + //MARK: - 我的信息
  253 +// private func reloadUserInfo(){
  254 +// guard isLogin else{
  255 +// return
  256 +// }
  257 +// Task{
  258 +// myUserInfo = await service.getMyUserInfo()
  259 +// guard let myUserInfo else{
  260 +// AppGroupConstants.defaults?.removeObject(forKey: AppGroupConstants.Key.myUserInfo)
  261 +// return
  262 +// }
  263 +// do{
  264 +// let data = try JSONEncoder().encode(myUserInfo)
  265 +// AppGroupConstants.defaults?.set(data, forKey: AppGroupConstants.Key.myUserInfo)
  266 +// }catch{}
  267 +// }
  268 +// }
  269 +
  270 + private func reloadFriend(){
  271 + guard isLogin else{
  272 + return
  273 + }
  274 + Task{
  275 + let friendList = await service.getFriendInfo()
  276 + let friend = friendList?.list?.first(where: { $0.show_in_dial == 1 })
  277 + if let _ = friend?.friend_user_id{
  278 + WatchDataManager.share.getFriendLatestData()
  279 + }
  280 + self.saveOtherData(displayFriend: friend)
  281 +
  282 + await loadRemoteWatchTheme()
  283 + }
  284 + }
  285 + func reloadVip(){
  286 + guard isLogin else{
  287 + return
  288 + }
  289 + Task{
  290 + let vipInfo = await service.getVipInfo()
  291 + self.saveVip(info: vipInfo)
  292 + }
  293 + }
  294 +
  295 +
  296 + //MARK: - OtherData
  297 + private func reloadLocalFriend(){
  298 + if let data = AppGroupConstants.defaults?.data(forKey: AppGroupConstants.Key.showOtherUserInfo),
  299 + let model = try? JSONDecoder().decode(HAppFriend.self, from: data) {
  300 + self.displayFriend = model
  301 + }else{
  302 + self.displayFriend = nil
  303 + }
  304 + }
  305 + private func saveOtherData(displayFriend: HAppFriend?){
  306 + self.displayFriend = displayFriend
  307 +
  308 + guard let displayFriend else{
  309 + AppGroupConstants.defaults?.removeObject(forKey: AppGroupConstants.Key.showOtherUserInfo)
  310 + return
  311 + }
  312 + do{
  313 + let data = try JSONEncoder().encode(displayFriend)
  314 + AppGroupConstants.defaults?.set(data, forKey: AppGroupConstants.Key.showOtherUserInfo)
  315 + }catch{}
  316 + }
  317 +
  318 + //MARK: - VIP
  319 + private func reloadVipInfo(){
  320 + if let data = AppGroupConstants.defaults?.data(forKey: AppGroupConstants.Key.vipInfo),
  321 + let model = try? JSONDecoder().decode(HVipInfo.self, from: data) {
  322 + self.vipInfo = model
  323 + }else{
  324 + self.vipInfo = nil
  325 + }
  326 + reloadVip()
  327 + }
  328 + private func saveVip(info: HVipInfo?){
  329 + self.vipInfo = info
  330 + if let info{
  331 + do{
  332 + let data = try JSONEncoder().encode(info)
  333 + AppGroupConstants.defaults?.set(data, forKey: AppGroupConstants.Key.vipInfo)
  334 + }catch{}
  335 + }else{
  336 + AppGroupConstants.defaults?.removeObject(forKey: AppGroupConstants.Key.vipInfo)
  337 + }
  338 + }
  339 +
76 } 340 }
  1 +//
  2 +// WidgetKind.swift
  3 +// hippo-watch Watch App
  4 +//
  5 +// Created by 权海 on 2026/6/22.
  6 +//
  7 +
  8 +import WidgetKit
  9 +
  10 +enum HippoWidgetKind: String{
  11 + case hrv = "HRVWidget"
  12 + case mySummary = "MyStatusSummaryWidget"
  13 + case heartBeatCircle = "HeartBeatCircleWidget"
  14 + case activityCircle = "ActivityCircleWidget"
  15 + case stepCountCircle = "StepCountCircleWidget"
  16 +}
  17 +
  18 +/// Keeps HealthKit-to-widget refresh mappings in one place so a data change
  19 +/// only invalidates the timelines that actually consume it.
  20 +enum WatchWidgetReloader {
  21 + static func reloadHRV() {
  22 + reload(.hrv, .mySummary, .heartBeatCircle)
  23 + }
  24 +
  25 + static func reloadHeartRate() {
  26 + reload(.mySummary, .heartBeatCircle)
  27 + }
  28 +
  29 + static func reloadSteps() {
  30 + reload(.mySummary, .stepCountCircle)
  31 + }
  32 +
  33 + static func reloadActivity() {
  34 + reload(.mySummary, .activityCircle)
  35 + }
  36 +
  37 + static func reloadActivityTarget() {
  38 + reload(.mySummary, .activityCircle, .stepCountCircle)
  39 + }
  40 +
  41 + static func reloadAllDynamicWidgets() {
  42 + reload(.hrv, .mySummary, .heartBeatCircle, .activityCircle, .stepCountCircle)
  43 + }
  44 +
  45 + private static func reload(_ kinds: HippoWidgetKind...) {
  46 + kinds.forEach {
  47 + WidgetCenter.shared.reloadTimelines(ofKind: $0.rawValue)
  48 + }
  49 + }
  50 +}
@@ -358,12 +358,8 @@ @@ -358,12 +358,8 @@
358 ); 358 );
359 inputFileListPaths = ( 359 inputFileListPaths = (
360 ); 360 );
361 - inputPaths = (  
362 - );  
363 outputFileListPaths = ( 361 outputFileListPaths = (
364 ); 362 );
365 - outputPaths = (  
366 - );  
367 runOnlyForDeploymentPostprocessing = 0; 363 runOnlyForDeploymentPostprocessing = 0;
368 shellPath = /bin/sh; 364 shellPath = /bin/sh;
369 shellScript = "# Type a script or drag a script file from your workspace to insert its path.\n/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build\n"; 365 shellScript = "# Type a script or drag a script file from your workspace to insert its path.\n/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build\n";
@@ -24,12 +24,14 @@ class AppDelegate: NSObject, UIApplicationDelegate { @@ -24,12 +24,14 @@ class AppDelegate: NSObject, UIApplicationDelegate {
24 24
25 enum FlutterBridgeMethodName: String{ 25 enum FlutterBridgeMethodName: String{
26 case handleFlutterUrl 26 case handleFlutterUrl
  27 + case uploadHealthData
27 } 28 }
28 private var methods: [String: FlutterBridgeMethod] = [:] 29 private var methods: [String: FlutterBridgeMethod] = [:]
29 30
30 var flutterEngine: FlutterEngine? 31 var flutterEngine: FlutterEngine?
31 var channel: FlutterMethodChannel? 32 var channel: FlutterMethodChannel?
32 private var isFlutterEngineReady = false 33 private var isFlutterEngineReady = false
  34 + private var healthDataUploadObserver: NSObjectProtocol?
33 35
34 func application( 36 func application(
35 _ application: UIApplication, 37 _ application: UIApplication,
@@ -73,6 +75,7 @@ class AppDelegate: NSObject, UIApplicationDelegate { @@ -73,6 +75,7 @@ class AppDelegate: NSObject, UIApplicationDelegate {
73 requestIDFAAuthorization() 75 requestIDFAAuthorization()
74 76
75 WatchConnectivityService.shared.activate() 77 WatchConnectivityService.shared.activate()
  78 + observeHealthDataUploadsIfNeeded()
76 HealthKitService.shared.startBackgroundObserversIfNeeded() 79 HealthKitService.shared.startBackgroundObserversIfNeeded()
77 } 80 }
78 81
@@ -126,6 +129,36 @@ extension AppDelegate{ @@ -126,6 +129,36 @@ extension AppDelegate{
126 channel?.invokeMethod(method.rawValue, arguments: arguments, result: result) 129 channel?.invokeMethod(method.rawValue, arguments: arguments, result: result)
127 } 130 }
128 131
  132 + private func observeHealthDataUploadsIfNeeded() {
  133 + guard healthDataUploadObserver == nil else { return }
  134 + healthDataUploadObserver = NotificationCenter.default.addObserver(
  135 + forName: .nativeHealthDataDidUpload,
  136 + object: nil,
  137 + queue: .main
  138 + ) { [weak self] notification in
  139 + guard let dataType = notification.userInfo?["dataType"] as? Int,
  140 + let timestamp = notification.userInfo?["timestamp"] as? Int64 else {
  141 + return
  142 + }
  143 + self?.notifyFlutterHealthDataUploaded(dataType: dataType, timestamp: timestamp)
  144 + }
  145 + }
  146 +
  147 + private func notifyFlutterHealthDataUploaded(dataType: Int, timestamp: Int64) {
  148 + guard channel != nil else {
  149 + DebugLogger.log(desc: "uploadHealthData notification skipped because Flutter channel is not ready")
  150 + return
  151 + }
  152 + invoke(
  153 + method: .uploadHealthData,
  154 + arguments: ["dataType": dataType, "timestamp": timestamp]
  155 + ) { result in
  156 + if let error = result as? FlutterError {
  157 + DebugLogger.log(desc: "uploadHealthData callback error: \(error.message ?? error.code)")
  158 + }
  159 + }
  160 + }
  161 +
129 private func cacheLaunchURLIfNeeded(_ launchOptions: [UIApplication.LaunchOptionsKey: Any]?) { 162 private func cacheLaunchURLIfNeeded(_ launchOptions: [UIApplication.LaunchOptionsKey: Any]?) {
130 if let url = launchOptions?[.url] as? URL { 163 if let url = launchOptions?[.url] as? URL {
131 AppShared.shared.unhandedUrl = url.absoluteString 164 AppShared.shared.unhandedUrl = url.absoluteString
@@ -184,6 +217,10 @@ extension AppDelegate{ @@ -184,6 +217,10 @@ extension AppDelegate{
184 } 217 }
185 } 218 }
186 219
  220 +extension Notification.Name {
  221 + static let nativeHealthDataDidUpload = Notification.Name("nativeHealthDataDidUpload")
  222 +}
  223 +
187 extension AppDelegate: UNUserNotificationCenterDelegate{ 224 extension AppDelegate: UNUserNotificationCenterDelegate{
188 func userNotificationCenter(_ center: UNUserNotificationCenter, 225 func userNotificationCenter(_ center: UNUserNotificationCenter,
189 willPresent notification: UNNotification, 226 willPresent notification: UNNotification,
@@ -289,7 +289,7 @@ final class HealthDataReader { @@ -289,7 +289,7 @@ final class HealthDataReader {
289 activeEnergyBurnedGoal: summary.activeEnergyBurnedGoal.doubleValue(for: .kilocalorie()), 289 activeEnergyBurnedGoal: summary.activeEnergyBurnedGoal.doubleValue(for: .kilocalorie()),
290 appleMoveTime: Self.appleMoveTimeValue(from: summary), 290 appleMoveTime: Self.appleMoveTimeValue(from: summary),
291 appleMoveTimeGoal: Self.appleMoveTimeGoalValue(from: summary), 291 appleMoveTimeGoal: Self.appleMoveTimeGoalValue(from: summary),
292 - appleExerciseTime: summary.appleExerciseTime.doubleValue(for: .minute()), 292 + appleExerciseTime: summary.appleExerciseTime.doubleValue(for: .second()),
293 exerciseTimeGoal: Self.exerciseTimeGoalValue(from: summary), 293 exerciseTimeGoal: Self.exerciseTimeGoalValue(from: summary),
294 appleStandHours: summary.appleStandHours.doubleValue(for: .count()), 294 appleStandHours: summary.appleStandHours.doubleValue(for: .count()),
295 standHoursGoal: Self.standHoursGoalValue(from: summary) 295 standHoursGoal: Self.standHoursGoalValue(from: summary)
@@ -309,21 +309,21 @@ final class HealthDataReader { @@ -309,21 +309,21 @@ final class HealthDataReader {
309 309
310 private static func appleMoveTimeValue(from summary: HKActivitySummary) -> Double? { 310 private static func appleMoveTimeValue(from summary: HKActivitySummary) -> Double? {
311 if #available(iOS 14.0, *) { 311 if #available(iOS 14.0, *) {
312 - return summary.appleMoveTime.doubleValue(for: .minute()) 312 + return summary.appleMoveTime.doubleValue(for: .second())
313 } 313 }
314 return nil 314 return nil
315 } 315 }
316 316
317 private static func appleMoveTimeGoalValue(from summary: HKActivitySummary) -> Double? { 317 private static func appleMoveTimeGoalValue(from summary: HKActivitySummary) -> Double? {
318 if #available(iOS 14.0, *) { 318 if #available(iOS 14.0, *) {
319 - return summary.appleMoveTimeGoal.doubleValue(for: .minute()) 319 + return summary.appleMoveTimeGoal.doubleValue(for: .second())
320 } 320 }
321 return nil 321 return nil
322 } 322 }
323 323
324 private static func exerciseTimeGoalValue(from summary: HKActivitySummary) -> Double? { 324 private static func exerciseTimeGoalValue(from summary: HKActivitySummary) -> Double? {
325 if #available(iOS 16.0, *) { 325 if #available(iOS 16.0, *) {
326 - return summary.exerciseTimeGoal?.doubleValue(for: .minute()) 326 + return summary.exerciseTimeGoal?.doubleValue(for: .second())
327 } 327 }
328 return nil 328 return nil
329 } 329 }
@@ -371,34 +371,23 @@ final class HealthDataReader { @@ -371,34 +371,23 @@ final class HealthDataReader {
371 throw NativeHealthKitError.invalidType(identifier.rawValue) 371 throw NativeHealthKitError.invalidType(identifier.rawValue)
372 } 372 }
373 373
  374 + // HealthKit can represent derived values such as resting heart rate and
  375 + // walking heart rate average as a day-long sample, then replace that
  376 + // sample as the estimate improves. Requiring the sample's start date to
  377 + // be inside the upload range would drop the replacement whenever the
  378 + // server cursor is later than midnight. Query by interval overlap instead.
374 let predicate = HKQuery.predicateForSamples( 379 let predicate = HKQuery.predicateForSamples(
375 withStart: startDate, 380 withStart: startDate,
376 end: endDate, 381 end: endDate,
377 - options: .strictStartDate 382 + options: []
378 ) 383 )
379 - let sort = NSSortDescriptor(key: HKSampleSortIdentifierStartDate, ascending: true)  
380 -  
381 - return try await withCheckedThrowingContinuation { continuation in  
382 - let query = HKSampleQuery(  
383 - sampleType: type,  
384 - predicate: predicate,  
385 - limit: HKObjectQueryNoLimit,  
386 - sortDescriptors: [sort]  
387 - ) { _, samples, error in  
388 - if let error {  
389 - continuation.resume(throwing: error)  
390 - return  
391 - }  
392 - let points = (samples as? [HKQuantitySample] ?? []).map { sample in  
393 - NativeHealthDataPoint(  
394 - dataType: dataType,  
395 - time: sample.startDate.timeIntervalSince1970,  
396 - value: sample.quantity.doubleValue(for: unit)  
397 - )  
398 - }  
399 - continuation.resume(returning: points)  
400 - }  
401 - healthStore.execute(query) 384 + let samples = try await fetchQuantitySampleObjects(type: type, predicate: predicate)
  385 + return samples.map { sample in
  386 + NativeHealthDataPoint(
  387 + dataType: dataType,
  388 + time: sample.endDate.timeIntervalSince1970,
  389 + value: sample.quantity.doubleValue(for: unit)
  390 + )
402 } 391 }
403 } 392 }
404 393
@@ -412,52 +401,56 @@ final class HealthDataReader { @@ -412,52 +401,56 @@ final class HealthDataReader {
412 guard let type = NativeHealthTypeCatalog.quantity(identifier) else { 401 guard let type = NativeHealthTypeCatalog.quantity(identifier) else {
413 throw NativeHealthKitError.invalidType(identifier.rawValue) 402 throw NativeHealthKitError.invalidType(identifier.rawValue)
414 } 403 }
415 - var interval = DateComponents()  
416 - interval.day = 1  
417 - let anchorDate = Calendar.current.startOfDay(for: startDate)  
418 - let predicate = HKQuery.predicateForSamples(withStart: startDate, end: endDate) 404 + let calendar = Calendar.current
  405 + let queryStartDate = calendar.startOfDay(for: startDate)
  406 + let predicate = HKQuery.predicateForSamples(withStart: queryStartDate, end: endDate)
  407 + let samples = try await fetchQuantitySampleObjects(type: type, predicate: predicate)
  408 + let latestEndByDay = samples
  409 + .filter { $0.endDate > startDate }
  410 + .reduce(into: [Date: Date]()) { result, sample in
  411 + let day = calendar.startOfDay(for: sample.startDate)
  412 + result[day] = max(result[day] ?? .distantPast, sample.endDate)
  413 + }
419 414
420 - return try await withCheckedThrowingContinuation { continuation in  
421 - let query = HKStatisticsCollectionQuery(  
422 - quantityType: type,  
423 - quantitySamplePredicate: predicate,  
424 - options: .cumulativeSum,  
425 - anchorDate: anchorDate,  
426 - intervalComponents: interval 415 + var points: [NativeHealthDataPoint] = []
  416 + for day in latestEndByDay.keys.sorted() {
  417 + guard let latestEnd = latestEndByDay[day] else { continue }
  418 + let value = try await dailyCumulative(type: type, unit: unit, day: day, endDate: endDate)
  419 + points.append(
  420 + NativeHealthDataPoint(
  421 + dataType: dataType,
  422 + time: latestEnd.timeIntervalSince1970,
  423 + value: value
  424 + )
427 ) 425 )
428 - query.initialResultsHandler = { _, collection, error in  
429 - if let error {  
430 - continuation.resume(throwing: error)  
431 - return  
432 - }  
433 - var points: [NativeHealthDataPoint] = []  
434 - collection?.enumerateStatistics(from: startDate, to: endDate) { statistics, _ in  
435 - guard let value = statistics.sumQuantity()?.doubleValue(for: unit) else { return }  
436 - points.append(  
437 - NativeHealthDataPoint(  
438 - dataType: dataType,  
439 - time: statistics.startDate.timeIntervalSince1970,  
440 - value: value  
441 - )  
442 - )  
443 - }  
444 - continuation.resume(returning: points)  
445 - }  
446 - healthStore.execute(query)  
447 } 426 }
  427 + return points
448 } 428 }
449 429
450 private func fetchDailyStandHours(startDate: Date, endDate: Date) async throws -> [NativeHealthDataPoint] { 430 private func fetchDailyStandHours(startDate: Date, endDate: Date) async throws -> [NativeHealthDataPoint] {
451 let calendar = Calendar.current 431 let calendar = Calendar.current
452 - var start = calendar.dateComponents([.era, .year, .month, .day], from: startDate)  
453 - var end = calendar.dateComponents([.era, .year, .month, .day], from: endDate) 432 + guard let standType = NativeHealthTypeCatalog.quantity(.appleStandTime) else {
  433 + throw NativeHealthKitError.invalidType("appleStandTime")
  434 + }
  435 + let queryStartDate = calendar.startOfDay(for: startDate)
  436 + let samplePredicate = HKQuery.predicateForSamples(withStart: queryStartDate, end: endDate)
  437 + let samples = try await fetchQuantitySampleObjects(type: standType, predicate: samplePredicate)
  438 + let latestEndByDay = samples
  439 + .filter { $0.endDate > startDate }
  440 + .reduce(into: [Date: Date]()) { result, sample in
  441 + let day = calendar.startOfDay(for: sample.startDate)
  442 + result[day] = max(result[day] ?? .distantPast, sample.endDate)
  443 + }
  444 +
  445 + guard !latestEndByDay.isEmpty else { return [] }
  446 + var start = calendar.dateComponents([.era, .year, .month, .day], from: latestEndByDay.keys.min()!)
  447 + var end = calendar.dateComponents([.era, .year, .month, .day], from: latestEndByDay.keys.max()!)
454 start.calendar = calendar 448 start.calendar = calendar
455 end.calendar = calendar 449 end.calendar = calendar
456 -  
457 - let predicate = HKQuery.predicate(forActivitySummariesBetweenStart: start, end: end) 450 + let summaryPredicate = HKQuery.predicate(forActivitySummariesBetweenStart: start, end: end)
458 451
459 return try await withCheckedThrowingContinuation { continuation in 452 return try await withCheckedThrowingContinuation { continuation in
460 - let query = HKActivitySummaryQuery(predicate: predicate) { _, summaries, error in 453 + let query = HKActivitySummaryQuery(predicate: summaryPredicate) { _, summaries, error in
461 if let error { 454 if let error {
462 continuation.resume(throwing: error) 455 continuation.resume(throwing: error)
463 return 456 return
@@ -465,12 +458,13 @@ final class HealthDataReader { @@ -465,12 +458,13 @@ final class HealthDataReader {
465 458
466 let points = (summaries ?? []) 459 let points = (summaries ?? [])
467 .compactMap { summary -> NativeHealthDataPoint? in 460 .compactMap { summary -> NativeHealthDataPoint? in
468 - guard let date = calendar.date(from: summary.dateComponents(for: calendar)) else { 461 + guard let date = calendar.date(from: summary.dateComponents(for: calendar)),
  462 + let latestEnd = latestEndByDay[calendar.startOfDay(for: date)] else {
469 return nil 463 return nil
470 } 464 }
471 return NativeHealthDataPoint( 465 return NativeHealthDataPoint(
472 dataType: .stand, 466 dataType: .stand,
473 - time: date.timeIntervalSince1970, 467 + time: latestEnd.timeIntervalSince1970,
474 value: summary.appleStandHours.doubleValue(for: .count()) 468 value: summary.appleStandHours.doubleValue(for: .count())
475 ) 469 )
476 } 470 }
@@ -486,10 +480,12 @@ final class HealthDataReader { @@ -486,10 +480,12 @@ final class HealthDataReader {
486 guard let type = NativeHealthTypeCatalog.category(.sleepAnalysis) else { 480 guard let type = NativeHealthTypeCatalog.category(.sleepAnalysis) else {
487 throw NativeHealthKitError.invalidType("sleepAnalysis") 481 throw NativeHealthKitError.invalidType("sleepAnalysis")
488 } 482 }
  483 + // Sleep stages are intervals and can begin before the requested boundary.
  484 + // Include any stage that overlaps the range so overnight data is not lost.
489 let predicate = HKQuery.predicateForSamples( 485 let predicate = HKQuery.predicateForSamples(
490 withStart: startDate, 486 withStart: startDate,
491 end: endDate, 487 end: endDate,
492 - options: .strictStartDate 488 + options: []
493 ) 489 )
494 let sort = NSSortDescriptor(key: HKSampleSortIdentifierStartDate, ascending: true) 490 let sort = NSSortDescriptor(key: HKSampleSortIdentifierStartDate, ascending: true)
495 491
@@ -527,7 +523,7 @@ final class HealthDataReader { @@ -527,7 +523,7 @@ final class HealthDataReader {
527 let predicate = HKQuery.predicateForSamples( 523 let predicate = HKQuery.predicateForSamples(
528 withStart: startDate, 524 withStart: startDate,
529 end: endDate, 525 end: endDate,
530 - options: .strictStartDate 526 + options: []
531 ) 527 )
532 let sort = NSSortDescriptor(key: HKSampleSortIdentifierStartDate, ascending: true) 528 let sort = NSSortDescriptor(key: HKSampleSortIdentifierStartDate, ascending: true)
533 529
@@ -545,7 +541,7 @@ final class HealthDataReader { @@ -545,7 +541,7 @@ final class HealthDataReader {
545 let points = (samples as? [HKCategorySample] ?? []).map { sample in 541 let points = (samples as? [HKCategorySample] ?? []).map { sample in
546 NativeHealthDataPoint( 542 NativeHealthDataPoint(
547 dataType: .irregularHeartRhythm, 543 dataType: .irregularHeartRhythm,
548 - time: sample.startDate.timeIntervalSince1970, 544 + time: sample.endDate.timeIntervalSince1970,
549 value: Double(sample.value) 545 value: Double(sample.value)
550 ) 546 )
551 } 547 }
@@ -573,4 +569,57 @@ final class HealthDataReader { @@ -573,4 +569,57 @@ final class HealthDataReader {
573 healthStore.execute(query) 569 healthStore.execute(query)
574 } 570 }
575 } 571 }
  572 +
  573 + private func fetchQuantitySampleObjects(
  574 + type: HKQuantityType,
  575 + predicate: NSPredicate
  576 + ) async throws -> [HKQuantitySample] {
  577 + let sort = NSSortDescriptor(key: HKSampleSortIdentifierEndDate, ascending: true)
  578 + return try await withCheckedThrowingContinuation { continuation in
  579 + let query = HKSampleQuery(
  580 + sampleType: type,
  581 + predicate: predicate,
  582 + limit: HKObjectQueryNoLimit,
  583 + sortDescriptors: [sort]
  584 + ) { _, samples, error in
  585 + if let error {
  586 + continuation.resume(throwing: error)
  587 + } else {
  588 + continuation.resume(returning: samples as? [HKQuantitySample] ?? [])
  589 + }
  590 + }
  591 + healthStore.execute(query)
  592 + }
  593 + }
  594 +
  595 + private func dailyCumulative(
  596 + type: HKQuantityType,
  597 + unit: HKUnit,
  598 + day: Date,
  599 + endDate: Date
  600 + ) async throws -> Double {
  601 + let calendar = Calendar.current
  602 + let start = calendar.startOfDay(for: day)
  603 + let nextDay = calendar.date(byAdding: .day, value: 1, to: start) ?? endDate
  604 + let end = min(nextDay, endDate)
  605 + let predicate = HKQuery.predicateForSamples(
  606 + withStart: start,
  607 + end: end,
  608 + options: .strictStartDate
  609 + )
  610 + return try await withCheckedThrowingContinuation { continuation in
  611 + let query = HKStatisticsQuery(
  612 + quantityType: type,
  613 + quantitySamplePredicate: predicate,
  614 + options: .cumulativeSum
  615 + ) { _, statistics, error in
  616 + if let error {
  617 + continuation.resume(throwing: error)
  618 + } else {
  619 + continuation.resume(returning: statistics?.sumQuantity()?.doubleValue(for: unit) ?? 0)
  620 + }
  621 + }
  622 + healthStore.execute(query)
  623 + }
  624 + }
576 } 625 }
@@ -15,6 +15,7 @@ final class HealthKitService { @@ -15,6 +15,7 @@ final class HealthKitService {
15 private let syncStore = HealthSyncStateStore() 15 private let syncStore = HealthSyncStateStore()
16 private lazy var reader = HealthDataReader(healthStore: healthStore) 16 private lazy var reader = HealthDataReader(healthStore: healthStore)
17 private var observersStarted = false 17 private var observersStarted = false
  18 + private var observerQueries: [HKObserverQuery] = []
18 19
19 private init() {} 20 private init() {}
20 21
@@ -52,45 +53,21 @@ final class HealthKitService { @@ -52,45 +53,21 @@ final class HealthKitService {
52 return await authorizationRequestStatus() != .unnecessary 53 return await authorizationRequestStatus() != .unnecessary
53 } 54 }
54 55
55 - func hasAnyReadableData() async -> Bool { 56 + func hasAnyReadableData(startingAt requestedStartDate: Date? = nil) async -> Bool {
56 guard isHealthDataAvailable else { return false } 57 guard isHealthDataAvailable else { return false }
57 58
58 let endDate = Date() 59 let endDate = Date()
59 - let startDate = Calendar.current.date(byAdding: .year, value: -2, to: endDate) 60 + let startDate = requestedStartDate
  61 + ?? Calendar.current.date(byAdding: .year, value: -2, to: endDate)
60 ?? Date(timeInterval: -2 * 365 * 24 * 60 * 60, since: endDate) 62 ?? Date(timeInterval: -2 * 365 * 24 * 60 * 60, since: endDate)
61 63
62 - let commonFetches: [(Date, Date) async throws -> [NativeHealthDataPoint]] = [  
63 - reader.fetchHrvData,  
64 - reader.fetchHeartRateData,  
65 - reader.fetchWalkingHeartRateData,  
66 - reader.fetchRestingHeartRateData,  
67 - reader.fetchSleepingHeartRateData,  
68 - reader.fetchOxygenSaturationData,  
69 - reader.fetchActiveEnergyData,  
70 - reader.fetchExerciseData,  
71 - reader.fetchStandData,  
72 - reader.fetchStepCountData,  
73 - reader.fetchSleepingWristTemperatureData,  
74 - reader.fetchRespiratoryRateData,  
75 - reader.fetchIrregularHeartRhythmData,  
76 - ]  
77 -  
78 - for fetch in commonFetches {  
79 - do {  
80 - if try await !fetch(startDate, endDate).isEmpty {  
81 - return true  
82 - }  
83 - } catch {  
84 - // Keep probing other data types; HealthKit may deny or lack a single type.  
85 - }  
86 - }  
87 -  
88 - do {  
89 - if try await !reader.fetchSleepData(startDate: startDate, endDate: endDate).isEmpty {  
90 - return true  
91 - }  
92 - } catch {  
93 - // Keep probing activity summaries. 64 + let sampleTypes = NativeHealthTypeCatalog.readTypes.compactMap { $0 as? HKSampleType }
  65 + if await hasAnyReadableSample(
  66 + of: sampleTypes,
  67 + startDate: startDate,
  68 + endDate: endDate
  69 + ) {
  70 + return true
94 } 71 }
95 72
96 do { 73 do {
@@ -100,12 +77,53 @@ final class HealthKitService { @@ -100,12 +77,53 @@ final class HealthKitService {
100 } 77 }
101 } 78 }
102 79
  80 + private func hasAnyReadableSample(
  81 + of sampleTypes: [HKSampleType],
  82 + startDate: Date,
  83 + endDate: Date
  84 + ) async -> Bool {
  85 + guard !sampleTypes.isEmpty else { return false }
  86 +
  87 + let predicate = HKQuery.predicateForSamples(
  88 + withStart: startDate,
  89 + end: endDate,
  90 + options: []
  91 + )
  92 + let state = HealthReadableDataProbeState()
  93 +
  94 + return await withCheckedContinuation { continuation in
  95 + let group = DispatchGroup()
  96 + for sampleType in sampleTypes {
  97 + group.enter()
  98 + let query = HKSampleQuery(
  99 + sampleType: sampleType,
  100 + predicate: predicate,
  101 + limit: 1,
  102 + sortDescriptors: nil
  103 + ) { _, samples, _ in
  104 + if samples?.isEmpty == false {
  105 + state.markDataFound()
  106 + }
  107 + group.leave()
  108 + }
  109 + healthStore.execute(query)
  110 + }
  111 +
  112 + group.notify(queue: .global(qos: .utility)) {
  113 + continuation.resume(returning: state.hasData)
  114 + }
  115 + }
  116 + }
  117 +
103 func startBackgroundObserversIfNeeded() { 118 func startBackgroundObserversIfNeeded() {
104 - guard isHealthDataAvailable, !observersStarted else { return } 119 + guard isHealthDataAvailable else { return }
  120 + // Enabling background delivery is safe to repeat and must be retried after
  121 + // authorization or a transient system failure.
  122 + NativeHealthTypeCatalog.observedTypes.forEach(enableBackgroundDelivery)
  123 + guard !observersStarted else { return }
105 observersStarted = true 124 observersStarted = true
106 125
107 for sampleType in NativeHealthTypeCatalog.observedTypes { 126 for sampleType in NativeHealthTypeCatalog.observedTypes {
108 - enableBackgroundDelivery(for: sampleType)  
109 let query = HKObserverQuery(sampleType: sampleType, predicate: nil) { [weak self] _, completion, error in 127 let query = HKObserverQuery(sampleType: sampleType, predicate: nil) { [weak self] _, completion, error in
110 guard error == nil else { 128 guard error == nil else {
111 completion() 129 completion()
@@ -116,6 +134,7 @@ final class HealthKitService { @@ -116,6 +134,7 @@ final class HealthKitService {
116 completion() 134 completion()
117 } 135 }
118 } 136 }
  137 + observerQueries.append(query)
119 healthStore.execute(query) 138 healthStore.execute(query)
120 } 139 }
121 } 140 }
@@ -236,9 +255,59 @@ final class HealthKitService { @@ -236,9 +255,59 @@ final class HealthKitService {
236 break 255 break
237 } 256 }
238 257
239 - if let type = NativeHealthDataType(sampleTypeIdentifier: sampleType.identifier) {  
240 - syncStore.save(date: Date(), for: type) 258 + let uploadTypes: [NativeHealthDataType]
  259 + let includeActivityTarget: Bool
  260 + switch sampleType.identifier {
  261 + case HKQuantityTypeIdentifier.heartRate.rawValue:
  262 + uploadTypes = [.heartRate, .sleepingHeartRate]
  263 + includeActivityTarget = false
  264 + case HKCategoryTypeIdentifier.sleepAnalysis.rawValue:
  265 + uploadTypes = [.sleep, .sleepingHeartRate]
  266 + includeActivityTarget = false
  267 + default:
  268 + uploadTypes = NativeHealthDataType(sampleTypeIdentifier: sampleType.identifier).map { [$0] } ?? []
  269 + includeActivityTarget = [
  270 + HKQuantityTypeIdentifier.activeEnergyBurned.rawValue,
  271 + HKQuantityTypeIdentifier.appleExerciseTime.rawValue,
  272 + HKQuantityTypeIdentifier.appleStandTime.rawValue,
  273 + ].contains(sampleType.identifier)
241 } 274 }
  275 +
  276 + guard !uploadTypes.isEmpty || includeActivityTarget else { return }
  277 + let success = await NativeHealthDataUploader.shared.uploadObservedChange(
  278 + types: uploadTypes,
  279 + includeActivityTarget: includeActivityTarget,
  280 + service: self
  281 + )
  282 + if success {
  283 + uploadTypes.forEach { syncStore.save(date: Date(), for: $0) }
  284 + }
  285 + }
  286 +
  287 + func uploadActivityTargetAfterForeground() async {
  288 + guard AppShared.shared.token?.isEmpty == false else { return }
  289 + _ = await NativeHealthDataUploader.shared.uploadObservedChange(
  290 + types: [],
  291 + includeActivityTarget: true,
  292 + service: self
  293 + )
  294 + }
  295 +}
  296 +
  297 +private final class HealthReadableDataProbeState: @unchecked Sendable {
  298 + private let lock = NSLock()
  299 + private var dataFound = false
  300 +
  301 + func markDataFound() {
  302 + lock.lock()
  303 + dataFound = true
  304 + lock.unlock()
  305 + }
  306 +
  307 + var hasData: Bool {
  308 + lock.lock()
  309 + defer { lock.unlock() }
  310 + return dataFound
242 } 311 }
243 } 312 }
244 313
@@ -259,6 +328,10 @@ private extension NativeHealthDataType { @@ -259,6 +328,10 @@ private extension NativeHealthDataType {
259 self = .exercise 328 self = .exercise
260 case HKQuantityTypeIdentifier.appleStandTime.rawValue: 329 case HKQuantityTypeIdentifier.appleStandTime.rawValue:
261 self = .stand 330 self = .stand
  331 + case HKQuantityTypeIdentifier.walkingHeartRateAverage.rawValue:
  332 + self = .walkingHeartRate
  333 + case HKQuantityTypeIdentifier.restingHeartRate.rawValue:
  334 + self = .restingHeartRate
262 case HKQuantityTypeIdentifier.appleSleepingWristTemperature.rawValue: 335 case HKQuantityTypeIdentifier.appleSleepingWristTemperature.rawValue:
263 self = .sleepingWristTemperature 336 self = .sleepingWristTemperature
264 case HKQuantityTypeIdentifier.respiratoryRate.rawValue: 337 case HKQuantityTypeIdentifier.respiratoryRate.rawValue:
@@ -169,6 +169,8 @@ enum NativeHealthTypeCatalog { @@ -169,6 +169,8 @@ enum NativeHealthTypeCatalog {
169 quantity(.activeEnergyBurned), 169 quantity(.activeEnergyBurned),
170 quantity(.appleExerciseTime), 170 quantity(.appleExerciseTime),
171 quantity(.appleStandTime), 171 quantity(.appleStandTime),
  172 + quantity(.walkingHeartRateAverage),
  173 + quantity(.restingHeartRate),
172 quantity(.appleSleepingWristTemperature), 174 quantity(.appleSleepingWristTemperature),
173 quantity(.respiratoryRate), 175 quantity(.respiratoryRate),
174 category(.sleepAnalysis), 176 category(.sleepAnalysis),
1 import Foundation 1 import Foundation
2 2
  3 +enum NativeHealthUploadConfiguration {
  4 + /// Process-lifetime throttle. Each health data type can start at most one
  5 + /// upload request during this interval.
  6 + static let minimumTriggerInterval: TimeInterval = 60
  7 +}
  8 +
3 struct NativeHealthUploadSummary { 9 struct NativeHealthUploadSummary {
4 let commonUploadSuccess: Bool 10 let commonUploadSuccess: Bool
5 let sleepUploadSuccess: Bool 11 let sleepUploadSuccess: Bool
@@ -31,39 +37,50 @@ enum NativeHealthUploadError: LocalizedError { @@ -31,39 +37,50 @@ enum NativeHealthUploadError: LocalizedError {
31 /// Uploads Apple Health data to DoubleFeel. 37 /// Uploads Apple Health data to DoubleFeel.
32 /// 38 ///
33 /// Data reading stays in `HealthDataReader` / `HealthKitService`; this type only 39 /// Data reading stays in `HealthDataReader` / `HealthKitService`; this type only
34 -/// owns upload requests and the per-user last-upload-time cache migrated from  
35 -/// the original SwiftUI app.  
36 -final class NativeHealthDataUploader { 40 +/// owns upload requests. Upload boundaries always come from the server's
  41 +/// `/data_upload/common/` response.
  42 +actor NativeHealthDataUploader {
37 static let shared = NativeHealthDataUploader() 43 static let shared = NativeHealthDataUploader()
38 44
39 private let session: URLSession 45 private let session: URLSession
40 - private var uploadTimeRecorder: NativeHealthUploadTimeRecorder  
41 -  
42 - private let maxUploadTimeWeek = 4  
43 private let defaultUploadTimeWeek = 1 46 private let defaultUploadTimeWeek = 1
44 private let firstUploadYear = 2 47 private let firstUploadYear = 2
  48 + private let uploadBatchSize = 500
  49 + private var lastUploadTriggerDates: [NativeHealthDataType: Date] = [:]
45 50
46 - init(  
47 - session: URLSession = .shared,  
48 - uploadTimeRecorder: NativeHealthUploadTimeRecorder = NativeHealthUploadTimeRecorder()  
49 - ) { 51 + init(session: URLSession = .shared) {
50 self.session = session 52 self.session = session
51 - self.uploadTimeRecorder = uploadTimeRecorder  
52 } 53 }
53 54
54 func uploadAll(service: HealthKitService = .shared) async -> NativeHealthUploadSummary { 55 func uploadAll(service: HealthKitService = .shared) async -> NativeHealthUploadSummary {
  56 + guard AppShared.shared.token?.isEmpty == false else {
  57 + let message = NativeHealthUploadError.missingAccessToken.localizedDescription
  58 + DebugLogger.debugLog("uploadAll skipped, user is not logged in")
  59 + return NativeHealthUploadSummary(
  60 + commonUploadSuccess: false,
  61 + sleepUploadSuccess: false,
  62 + errorMessage: message,
  63 + commonCount: 0,
  64 + sleepCount: 0
  65 + )
  66 + }
55 var commonCount = 0 67 var commonCount = 0
56 var sleepCount = 0 68 var sleepCount = 0
57 var commonUploadSuccess = true 69 var commonUploadSuccess = true
58 var sleepUploadSuccess = true 70 var sleepUploadSuccess = true
59 var errorMessages: [String] = [] 71 var errorMessages: [String] = []
60 72
61 - debugLog("uploadAll started ") 73 + DebugLogger.debugLog("uploadAll started ")
62 do { 74 do {
63 let uploadTimeList = try await processLastUploadTime() 75 let uploadTimeList = try await processLastUploadTime()
64 76
65 for type in Self.commonUploadTypes { 77 for type in Self.commonUploadTypes {
66 - let result = await upload(type: type, uploadTimeList: uploadTimeList, service: service) 78 + let result = await upload(
  79 + type: type,
  80 + uploadTimeList: uploadTimeList,
  81 + trigger: .full,
  82 + service: service
  83 + )
67 commonCount += result.uploadedCount 84 commonCount += result.uploadedCount
68 if !result.success { 85 if !result.success {
69 commonUploadSuccess = false 86 commonUploadSuccess = false
@@ -73,7 +90,12 @@ final class NativeHealthDataUploader { @@ -73,7 +90,12 @@ final class NativeHealthDataUploader {
73 } 90 }
74 } 91 }
75 92
76 - let sleepResult = await upload(type: .sleep, uploadTimeList: uploadTimeList, service: service) 93 + let sleepResult = await upload(
  94 + type: .sleep,
  95 + uploadTimeList: uploadTimeList,
  96 + trigger: .full,
  97 + service: service
  98 + )
77 sleepCount = sleepResult.uploadedCount 99 sleepCount = sleepResult.uploadedCount
78 sleepUploadSuccess = sleepResult.success 100 sleepUploadSuccess = sleepResult.success
79 if let errorMessage = sleepResult.errorMessage { 101 if let errorMessage = sleepResult.errorMessage {
@@ -94,10 +116,10 @@ final class NativeHealthDataUploader { @@ -94,10 +116,10 @@ final class NativeHealthDataUploader {
94 commonUploadSuccess = false 116 commonUploadSuccess = false
95 sleepUploadSuccess = false 117 sleepUploadSuccess = false
96 errorMessages.append(error.localizedDescription) 118 errorMessages.append(error.localizedDescription)
97 - debugLog("uploadAll failed before per-type upload, error=\(error.localizedDescription)") 119 + DebugLogger.debugLog("uploadAll failed before per-type upload, error=\(error.localizedDescription)")
98 } 120 }
99 121
100 - debugLog("uploadAll finished, commonSuccess=\(commonUploadSuccess), sleepSuccess=\(sleepUploadSuccess), commonCount=\(commonCount), sleepCount=\(sleepCount), error=\(errorMessages.isEmpty ? "<nil>" : errorMessages.joined(separator: " | "))") 122 + DebugLogger.debugLog("uploadAll finished, commonSuccess=\(commonUploadSuccess), sleepSuccess=\(sleepUploadSuccess), commonCount=\(commonCount), sleepCount=\(sleepCount), error=\(errorMessages.isEmpty ? "<nil>" : errorMessages.joined(separator: " | "))")
101 123
102 return NativeHealthUploadSummary( 124 return NativeHealthUploadSummary(
103 commonUploadSuccess: commonUploadSuccess, 125 commonUploadSuccess: commonUploadSuccess,
@@ -109,12 +131,59 @@ final class NativeHealthDataUploader { @@ -109,12 +131,59 @@ final class NativeHealthDataUploader {
109 } 131 }
110 132
111 func upload(type: NativeHealthDataType, service: HealthKitService = .shared) async -> Bool { 133 func upload(type: NativeHealthDataType, service: HealthKitService = .shared) async -> Bool {
  134 + guard AppShared.shared.token?.isEmpty == false else {
  135 + DebugLogger.debugLog("upload(\(type.debugName)) skipped, user is not logged in")
  136 + return false
  137 + }
  138 + do {
  139 + let uploadTimeList = try await processLastUploadTime()
  140 + return await upload(
  141 + type: type,
  142 + uploadTimeList: uploadTimeList,
  143 + trigger: .manual,
  144 + service: service
  145 + ).success
  146 + } catch {
  147 + DebugLogger.debugLog("upload(\(type.debugName)) failed to process last upload time, error=\(error.localizedDescription)")
  148 + DebugLogger.debugLog("Health upload failed to process last upload time: \(error.localizedDescription)")
  149 + return false
  150 + }
  151 + }
  152 +
  153 + func uploadObservedChange(
  154 + types: [NativeHealthDataType],
  155 + includeActivityTarget: Bool,
  156 + service: HealthKitService = .shared
  157 + ) async -> Bool {
  158 + guard AppShared.shared.token?.isEmpty == false else {
  159 + DebugLogger.debugLog("observed change skipped, user is not logged in")
  160 + return false
  161 + }
  162 +
112 do { 163 do {
113 let uploadTimeList = try await processLastUploadTime() 164 let uploadTimeList = try await processLastUploadTime()
114 - return await upload(type: type, uploadTimeList: uploadTimeList, service: service).success 165 + var success = true
  166 + var uploadedNewData = false
  167 + for type in types {
  168 + let result = await upload(
  169 + type: type,
  170 + uploadTimeList: uploadTimeList,
  171 + trigger: .observer,
  172 + service: service
  173 + )
  174 + success = success && result.success
  175 + uploadedNewData = uploadedNewData || result.uploadedCount > 0
  176 + }
  177 + if includeActivityTarget && uploadedNewData {
  178 + let result = await uploadActivityTargetIfNeeded(
  179 + uploadTimeList: uploadTimeList,
  180 + service: service
  181 + )
  182 + success = success && result.success
  183 + }
  184 + return success
115 } catch { 185 } catch {
116 - debugLog("upload(\(type.debugName)) failed to process last upload time, error=\(error.localizedDescription)")  
117 - print("Health upload failed to process last upload time: \(error.localizedDescription)") 186 + DebugLogger.debugLog("observed change upload failed, error=\(error.localizedDescription)")
118 return false 187 return false
119 } 188 }
120 } 189 }
@@ -127,6 +196,12 @@ private extension NativeHealthDataUploader { @@ -127,6 +196,12 @@ private extension NativeHealthDataUploader {
127 let errorMessage: String? 196 let errorMessage: String?
128 } 197 }
129 198
  199 + enum UploadTrigger: String {
  200 + case full
  201 + case observer
  202 + case manual
  203 + }
  204 +
130 static let commonUploadTypes: [NativeHealthDataType] = [ 205 static let commonUploadTypes: [NativeHealthDataType] = [
131 .hrv, 206 .hrv,
132 .heartRate, 207 .heartRate,
@@ -146,20 +221,21 @@ private extension NativeHealthDataUploader { @@ -146,20 +221,21 @@ private extension NativeHealthDataUploader {
146 func upload( 221 func upload(
147 type: NativeHealthDataType, 222 type: NativeHealthDataType,
148 uploadTimeList: NativeHealthUploadTimeList, 223 uploadTimeList: NativeHealthUploadTimeList,
  224 + trigger: UploadTrigger,
149 service: HealthKitService 225 service: HealthKitService
150 ) async -> UploadTaskResult { 226 ) async -> UploadTaskResult {
151 guard type != .unknown, 227 guard type != .unknown,
152 let startUploadDate = uploadTimeList.latestDataTime(for: type) else { 228 let startUploadDate = uploadTimeList.latestDataTime(for: type) else {
153 - debugLog("[\(type.debugName)] skipped, no upload start date") 229 + DebugLogger.debugLog("[\(type.debugName)] skipped, no upload start date")
154 return UploadTaskResult(success: true, uploadedCount: 0, errorMessage: nil) 230 return UploadTaskResult(success: true, uploadedCount: 0, errorMessage: nil)
155 } 231 }
156 232
157 let endUploadDate = Date() 233 let endUploadDate = Date()
158 - debugLog(  
159 - "[\(type.debugName)] upload started, range=\(Self.debugDateFormatter.string(from: startUploadDate)) -> \(Self.debugDateFormatter.string(from: endUploadDate))" 234 + DebugLogger.debugLog(
  235 + "[\(type.debugName)] upload started, trigger=\(trigger.rawValue), range=\(Self.debugDateFormatter.string(from: startUploadDate)) -> \(Self.debugDateFormatter.string(from: endUploadDate))"
160 ) 236 )
161 guard endUploadDate >= startUploadDate else { 237 guard endUploadDate >= startUploadDate else {
162 - debugLog("[\(type.debugName)] upload failed, start date is later than end date") 238 + DebugLogger.debugLog("[\(type.debugName)] upload failed, start date is later than end date")
163 return UploadTaskResult( 239 return UploadTaskResult(
164 success: false, 240 success: false,
165 uploadedCount: 0, 241 uploadedCount: 0,
@@ -170,41 +246,65 @@ private extension NativeHealthDataUploader { @@ -170,41 +246,65 @@ private extension NativeHealthDataUploader {
170 do { 246 do {
171 switch type { 247 switch type {
172 case .sleep: 248 case .sleep:
173 - debugLog("[\(type.debugName)] reading sleep data") 249 + DebugLogger.debugLog("[\(type.debugName)] reading sleep data")
174 let data = try await service.fetchSleepData(startDate: startUploadDate, endDate: endUploadDate) 250 let data = try await service.fetchSleepData(startDate: startUploadDate, endDate: endUploadDate)
175 - debugLog("[\(type.debugName)] read finished, count=\(data.count)") 251 + .filter { $0.toTime > startUploadDate.timeIntervalSince1970 }
  252 + .sorted { $0.toTime < $1.toTime }
  253 + debugLogTimeComparison(
  254 + type: type,
  255 + serverTime: startUploadDate.timeIntervalSince1970,
  256 + newestTime: data.map(\.toTime).max()
  257 + )
  258 + DebugLogger.debugLog("[\(type.debugName)] read finished, count=\(data.count)")
176 guard !data.isEmpty else { 259 guard !data.isEmpty else {
177 - uploadTimeRecorder.save(date: endUploadDate, for: type)  
178 - debugLog("[\(type.debugName)] no data, saved upload time=\(Self.debugDateFormatter.string(from: endUploadDate))") 260 + DebugLogger.debugLog("[\(type.debugName)] no data; server upload time remains unchanged")
  261 + return UploadTaskResult(success: true, uploadedCount: 0, errorMessage: nil)
  262 + }
  263 + guard allowUploadTrigger(for: type) else {
179 return UploadTaskResult(success: true, uploadedCount: 0, errorMessage: nil) 264 return UploadTaskResult(success: true, uploadedCount: 0, errorMessage: nil)
180 } 265 }
181 - debugLog("[\(type.debugName)] uploading, count=\(data.count)") 266 + DebugLogger.debugLog("[\(type.debugName)] uploading, count=\(data.count)")
182 try await uploadSleep(data) 267 try await uploadSleep(data)
183 - uploadTimeRecorder.save(date: endUploadDate, for: type)  
184 - debugLog("[\(type.debugName)] upload success, count=\(data.count), saved upload time=\(Self.debugDateFormatter.string(from: endUploadDate))") 268 + notifyFlutterUpload(
  269 + type: type,
  270 + timestamp: data.map(\.toTime).max() ?? endUploadDate.timeIntervalSince1970
  271 + )
  272 + DebugLogger.debugLog("[\(type.debugName)] upload success, count=\(data.count); next boundary will come from server")
185 return UploadTaskResult(success: true, uploadedCount: data.count, errorMessage: nil) 273 return UploadTaskResult(success: true, uploadedCount: data.count, errorMessage: nil)
186 default: 274 default:
187 - debugLog("[\(type.debugName)] reading common data") 275 + DebugLogger.debugLog("[\(type.debugName)] reading common data")
188 let data = try await fetchCommonData( 276 let data = try await fetchCommonData(
189 type: type, 277 type: type,
190 startDate: startUploadDate, 278 startDate: startUploadDate,
191 endDate: endUploadDate, 279 endDate: endUploadDate,
192 service: service 280 service: service
193 ) 281 )
194 - debugLog("[\(type.debugName)] read finished, count=\(data.count)") 282 + .filter { $0.time > startUploadDate.timeIntervalSince1970 }
  283 + .sorted { $0.time < $1.time }
  284 + debugLogTimeComparison(
  285 + type: type,
  286 + serverTime: startUploadDate.timeIntervalSince1970,
  287 + newestTime: data.map(\.time).max()
  288 + )
  289 + DebugLogger.debugLog("[\(type.debugName)] read finished, count=\(data.count)")
195 guard !data.isEmpty else { 290 guard !data.isEmpty else {
196 - uploadTimeRecorder.save(date: endUploadDate, for: type)  
197 - debugLog("[\(type.debugName)] no data, saved upload time=\(Self.debugDateFormatter.string(from: endUploadDate))") 291 + DebugLogger.debugLog("[\(type.debugName)] no data; server upload time remains unchanged")
  292 + return UploadTaskResult(success: true, uploadedCount: 0, errorMessage: nil)
  293 + }
  294 + guard allowUploadTrigger(for: type) else {
198 return UploadTaskResult(success: true, uploadedCount: 0, errorMessage: nil) 295 return UploadTaskResult(success: true, uploadedCount: 0, errorMessage: nil)
199 } 296 }
200 - debugLog("[\(type.debugName)] uploading, count=\(data.count)") 297 + DebugLogger.debugLog("[\(type.debugName)] uploading, count=\(data.count)")
201 try await uploadCommon(data) 298 try await uploadCommon(data)
202 - uploadTimeRecorder.save(date: endUploadDate, for: type)  
203 - debugLog("[\(type.debugName)] upload success, count=\(data.count), saved upload time=\(Self.debugDateFormatter.string(from: endUploadDate))") 299 + notifyFlutterUpload(
  300 + type: type,
  301 + timestamp: data.map(\.time).max() ?? endUploadDate.timeIntervalSince1970
  302 + )
  303 + DebugLogger.debugLog("[\(type.debugName)] upload success, count=\(data.count); next boundary will come from server")
204 return UploadTaskResult(success: true, uploadedCount: data.count, errorMessage: nil) 304 return UploadTaskResult(success: true, uploadedCount: data.count, errorMessage: nil)
205 } 305 }
206 } catch { 306 } catch {
207 - debugLog("[\(type.debugName)] upload failed, error=\(error.localizedDescription)") 307 + DebugLogger.debugLog("[\(type.debugName)] upload failed, error=\(error.localizedDescription)")
208 return UploadTaskResult( 308 return UploadTaskResult(
209 success: false, 309 success: false,
210 uploadedCount: 0, 310 uploadedCount: 0,
@@ -260,21 +360,21 @@ private extension NativeHealthDataUploader { @@ -260,21 +360,21 @@ private extension NativeHealthDataUploader {
260 ?? Date(timeIntervalSinceNow: -7 * 24 * 60 * 60) 360 ?? Date(timeIntervalSinceNow: -7 * 24 * 60 * 60)
261 let endDate = Date() 361 let endDate = Date()
262 362
263 - debugLog( 363 + DebugLogger.debugLog(
264 "[activityTarget] upload started, range=\(Self.debugDateFormatter.string(from: startDate)) -> \(Self.debugDateFormatter.string(from: endDate))" 364 "[activityTarget] upload started, range=\(Self.debugDateFormatter.string(from: startDate)) -> \(Self.debugDateFormatter.string(from: endDate))"
265 ) 365 )
266 do { 366 do {
267 guard let target = try await service.fetchActivityTargetData(startDate: startDate, endDate: endDate), 367 guard let target = try await service.fetchActivityTargetData(startDate: startDate, endDate: endDate),
268 target.move != nil || target.stand != nil else { 368 target.move != nil || target.stand != nil else {
269 - debugLog("[activityTarget] no target data") 369 + DebugLogger.debugLog("[activityTarget] no target data")
270 return UploadTaskResult(success: true, uploadedCount: 0, errorMessage: nil) 370 return UploadTaskResult(success: true, uploadedCount: 0, errorMessage: nil)
271 } 371 }
272 - debugLog("[activityTarget] uploading, body=\(target.uploadBodyForDebug)") 372 + DebugLogger.debugLog("[activityTarget] uploading, body=\(target.uploadBodyForDebug)")
273 try await uploadActivityTarget(target) 373 try await uploadActivityTarget(target)
274 - debugLog("[activityTarget] upload success") 374 + DebugLogger.debugLog("[activityTarget] upload success")
275 return UploadTaskResult(success: true, uploadedCount: 1, errorMessage: nil) 375 return UploadTaskResult(success: true, uploadedCount: 1, errorMessage: nil)
276 } catch { 376 } catch {
277 - debugLog("[activityTarget] upload failed, error=\(error.localizedDescription)") 377 + DebugLogger.debugLog("[activityTarget] upload failed, error=\(error.localizedDescription)")
278 return UploadTaskResult( 378 return UploadTaskResult(
279 success: false, 379 success: false,
280 uploadedCount: 0, 380 uploadedCount: 0,
@@ -284,40 +384,20 @@ private extension NativeHealthDataUploader { @@ -284,40 +384,20 @@ private extension NativeHealthDataUploader {
284 } 384 }
285 385
286 func processLastUploadTime() async throws -> NativeHealthUploadTimeList { 386 func processLastUploadTime() async throws -> NativeHealthUploadTimeList {
287 - let localTimeList = uploadTimeRecorder.records  
288 - let serverTimeList = try? await fetchLastUploadTime() 387 + let serverTimeList = try await fetchLastUploadTime()
289 var resultTimeList: [NativeHealthUploadTimeList.HealthUploadTime] = [] 388 var resultTimeList: [NativeHealthUploadTimeList.HealthUploadTime] = []
290 - debugLog("processLastUploadTime started, hasServerTimeList=\(serverTimeList != nil)")  
291 -  
292 - let fourWeeksAgo = Calendar.current.date(byAdding: .weekOfYear, value: -maxUploadTimeWeek, to: Date())  
293 - ?? Date(timeIntervalSinceNow: -TimeInterval(maxUploadTimeWeek * 7 * 24 * 60 * 60))  
294 - let fourWeeksAgoMidnight = Calendar.current.startOfDay(for: fourWeeksAgo).timeIntervalSince1970  
295 -  
296 - let oneWeekAgo = Calendar.current.date(byAdding: .weekOfYear, value: -defaultUploadTimeWeek, to: Date())  
297 - ?? Date(timeIntervalSinceNow: -TimeInterval(defaultUploadTimeWeek * 7 * 24 * 60 * 60))  
298 - let oneWeekAgoMidnight = Calendar.current.startOfDay(for: oneWeekAgo).timeIntervalSince1970 389 + DebugLogger.debugLog("processLastUploadTime started, source=server")
299 390
300 let twoYearsAgo = Calendar.current.date(byAdding: .year, value: -firstUploadYear, to: Date()) 391 let twoYearsAgo = Calendar.current.date(byAdding: .year, value: -firstUploadYear, to: Date())
301 ?? Date(timeIntervalSinceNow: -TimeInterval(firstUploadYear * 365 * 24 * 60 * 60)) 392 ?? Date(timeIntervalSinceNow: -TimeInterval(firstUploadYear * 365 * 24 * 60 * 60))
302 let twoYearsAgoMidnight = Calendar.current.startOfDay(for: twoYearsAgo).timeIntervalSince1970 393 let twoYearsAgoMidnight = Calendar.current.startOfDay(for: twoYearsAgo).timeIntervalSince1970
303 394
304 for type in NativeHealthDataType.allCases where type != .unknown { 395 for type in NativeHealthDataType.allCases where type != .unknown {
305 - let localTime = localTimeList.latestTimeInterval(for: type)  
306 - let serverTime = serverTimeList?.latestTimeInterval(for: type)  
307 -  
308 - let finalTime: TimeInterval  
309 - if let localTime, localTime > fourWeeksAgoMidnight {  
310 - finalTime = localTime  
311 - } else if let serverTime, serverTime > fourWeeksAgoMidnight {  
312 - finalTime = serverTime  
313 - } else if localTime != nil || serverTime != nil {  
314 - finalTime = oneWeekAgoMidnight  
315 - } else {  
316 - finalTime = twoYearsAgoMidnight  
317 - } 396 + let serverTime = serverTimeList.latestTimeInterval(for: type)
  397 + let finalTime = serverTime ?? twoYearsAgoMidnight
318 398
319 - debugLog(  
320 - "[\(type.debugName)] resolved start time=\(Self.debugDateFormatter.string(from: Date(timeIntervalSince1970: finalTime))), local=\(localTime.map { Self.debugDateFormatter.string(from: Date(timeIntervalSince1970: $0)) } ?? "<nil>"), server=\(serverTime.map { Self.debugDateFormatter.string(from: Date(timeIntervalSince1970: $0)) } ?? "<nil>")" 399 + DebugLogger.debugLog(
  400 + "[\(type.debugName)] server start time=\(serverTime.map { Self.debugDateFormatter.string(from: Date(timeIntervalSince1970: $0)) } ?? "<missing; first upload uses two years>"), resolved=\(Self.debugDateFormatter.string(from: Date(timeIntervalSince1970: finalTime)))"
321 ) 401 )
322 resultTimeList.append( 402 resultTimeList.append(
323 NativeHealthUploadTimeList.HealthUploadTime( 403 NativeHealthUploadTimeList.HealthUploadTime(
@@ -331,25 +411,64 @@ private extension NativeHealthDataUploader { @@ -331,25 +411,64 @@ private extension NativeHealthDataUploader {
331 } 411 }
332 412
333 func uploadCommon(_ data: [NativeHealthDataPoint]) async throws { 413 func uploadCommon(_ data: [NativeHealthDataPoint]) async throws {
334 - let list = data.map {  
335 - [  
336 - "data_type": $0.dataType.rawValue,  
337 - "time": $0.time,  
338 - "value": $0.value,  
339 - ] as [String: Any] 414 + for batch in data.chunked(into: uploadBatchSize) {
  415 + batch.forEach { point in
  416 + DebugLogger.debugLog(
  417 + "[iOS][upload] dataType=\(point.dataType.rawValue)(\(point.dataType.debugName)), time=\(debugTimestamp(point.time)), unix=\(Int64(point.time)), value=\(point.value)"
  418 + )
  419 + }
  420 + let list = batch.map {
  421 + [
  422 + "data_type": $0.dataType.rawValue,
  423 + "time": $0.time,
  424 + "value": $0.value,
  425 + ] as [String: Any]
  426 + }
  427 + try await request(path: "/client/doublefeel/health/v2/data_upload/common/", method: "POST", body: ["data_list": list])
  428 + }
  429 + }
  430 +
  431 + func allowUploadTrigger(for type: NativeHealthDataType, now: Date = Date()) -> Bool {
  432 + if let lastDate = lastUploadTriggerDates[type],
  433 + now.timeIntervalSince(lastDate) < NativeHealthUploadConfiguration.minimumTriggerInterval {
  434 + DebugLogger.debugLog(
  435 + "[\(type.debugName)] skipped by \(Int(NativeHealthUploadConfiguration.minimumTriggerInterval))s process throttle"
  436 + )
  437 + return false
340 } 438 }
341 - try await request(path: "/client/doublefeel/health/v2/data_upload/common/", method: "POST", body: ["data_list": list]) 439 + lastUploadTriggerDates[type] = now
  440 + return true
  441 + }
  442 +
  443 + func notifyFlutterUpload(type: NativeHealthDataType, timestamp: TimeInterval) {
  444 + let seconds = Int64(timestamp)
  445 + DebugLogger.debugLog("[\(type.debugName)] notifying Flutter, dataType=\(type.rawValue), timestamp=\(seconds)")
  446 + NotificationCenter.default.post(
  447 + name: .nativeHealthDataDidUpload,
  448 + object: nil,
  449 + userInfo: [
  450 + "dataType": type.rawValue,
  451 + "timestamp": seconds,
  452 + ]
  453 + )
342 } 454 }
343 455
344 func uploadSleep(_ data: [NativeSleepInterval]) async throws { 456 func uploadSleep(_ data: [NativeSleepInterval]) async throws {
345 - let list = data.map {  
346 - [  
347 - "data_type": $0.dataType,  
348 - "from_time": $0.fromTime,  
349 - "to_time": $0.toTime,  
350 - ] as [String: Any] 457 + for batch in data.chunked(into: uploadBatchSize) {
  458 + batch.forEach { interval in
  459 + DebugLogger.debugLog(
  460 + "[iOS][upload][sleep] dataType=\(interval.dataType), from=\(debugTimestamp(interval.fromTime)), fromUnix=\(Int64(interval.fromTime)), to=\(debugTimestamp(interval.toTime)), toUnix=\(Int64(interval.toTime))"
  461 + )
  462 + }
  463 + let list = batch.map {
  464 + [
  465 + "data_type": $0.dataType,
  466 + "from_time": $0.fromTime,
  467 + "to_time": $0.toTime,
  468 + ] as [String: Any]
  469 + }
  470 + try await request(path: "/client/doublefeel/health/v2/data_upload/sleep/", method: "POST", body: ["data_list": list])
351 } 471 }
352 - try await request(path: "/client/doublefeel/health/v2/data_upload/sleep/", method: "POST", body: ["data_list": list])  
353 } 472 }
354 473
355 func uploadActivityTarget(_ target: NativeActivityTarget) async throws { 474 func uploadActivityTarget(_ target: NativeActivityTarget) async throws {
@@ -404,10 +523,21 @@ private extension NativeHealthDataUploader { @@ -404,10 +523,21 @@ private extension NativeHealthDataUploader {
404 return formatter 523 return formatter
405 }() 524 }()
406 525
407 - func debugLog(_ message: String) {  
408 - #if DEBUG || CI_ENV  
409 - print("[NativeHealthDataUploader] \(message)")  
410 - #endif 526 + func debugTimestamp(_ timeInterval: TimeInterval) -> String {
  527 + Self.debugDateFormatter.string(from: Date(timeIntervalSince1970: timeInterval))
  528 + }
  529 +
  530 + func debugLogTimeComparison(
  531 + type: NativeHealthDataType,
  532 + serverTime: TimeInterval,
  533 + newestTime: TimeInterval?
  534 + ) {
  535 + let newestDescription = newestTime.map {
  536 + "\(debugTimestamp($0)) (unix=\(Int64($0)))"
  537 + } ?? "<none>"
  538 + DebugLogger.debugLog(
  539 + "[iOS][time-check] dataType=\(type.rawValue)(\(type.debugName)), server=\(debugTimestamp(serverTime)) (unix=\(Int64(serverTime))), newest=\(newestDescription), shouldUpload=\(newestTime.map { $0 > serverTime } ?? false)"
  540 + )
411 } 541 }
412 } 542 }
413 543
@@ -451,69 +581,20 @@ struct NativeHealthUploadTimeList: Codable { @@ -451,69 +581,20 @@ struct NativeHealthUploadTimeList: Codable {
451 } 581 }
452 } 582 }
453 583
454 -private struct HealthUploadTimeListResponse: Decodable {  
455 - let data: NativeHealthUploadTimeList?  
456 -}  
457 -  
458 -struct NativeHealthUploadTimeRecorder {  
459 - private var cachedRecords: NativeHealthUploadTimeList?  
460 - private static let cacheKey = "HealthUploadlocalRecord_records"  
461 -  
462 - var records: NativeHealthUploadTimeList {  
463 - mutating get {  
464 - if let cachedRecords {  
465 - return cachedRecords  
466 - }  
467 - let records = Self.loadFromCache() ?? NativeHealthUploadTimeList(latestDataTimeList: [])  
468 - cachedRecords = records  
469 - return records 584 +private extension Array {
  585 + func chunked(into size: Int) -> [[Element]] {
  586 + guard size > 0 else { return [self] }
  587 + return stride(from: 0, to: count, by: size).map {
  588 + Array(self[$0..<Swift.min($0 + size, count)])
470 } 589 }
471 - set {  
472 - cachedRecords = newValue  
473 - Self.saveToCache(newValue)  
474 - }  
475 - }  
476 -  
477 - init(records: NativeHealthUploadTimeList? = nil) {  
478 - cachedRecords = records  
479 - }  
480 -  
481 - mutating func save(date: Date, for type: NativeHealthDataType) {  
482 - var records = records  
483 - records.latestDataTimeList.removeAll { $0.dataType == type }  
484 - records.latestDataTimeList.append(  
485 - NativeHealthUploadTimeList.HealthUploadTime(  
486 - dataType: type,  
487 - latestDataTime: date.timeIntervalSince1970  
488 - )  
489 - )  
490 - self.records = records  
491 - }  
492 -  
493 - static func clearCache() {  
494 - UserDefaults.standard.removeObject(forKey: cacheKeyForCurrentUser())  
495 - }  
496 -  
497 - private static func loadFromCache() -> NativeHealthUploadTimeList? {  
498 - guard let data = UserDefaults.standard.data(forKey: cacheKeyForCurrentUser()) else {  
499 - return nil  
500 - }  
501 - return try? JSONDecoder().decode(NativeHealthUploadTimeList.self, from: data)  
502 - }  
503 -  
504 - private static func saveToCache(_ records: NativeHealthUploadTimeList) {  
505 - guard let data = try? JSONEncoder().encode(records) else {  
506 - return  
507 - }  
508 - UserDefaults.standard.set(data, forKey: cacheKeyForCurrentUser())  
509 } 590 }
  591 +}
510 592
511 - private static func cacheKeyForCurrentUser() -> String {  
512 - let userId = "\(AppShared.shared.userId ?? 0)"  
513 - return cacheKey + userId  
514 - } 593 +private struct HealthUploadTimeListResponse: Decodable {
  594 + let data: NativeHealthUploadTimeList?
515 } 595 }
516 596
  597 +
517 private extension NativeHealthDataType { 598 private extension NativeHealthDataType {
518 var debugName: String { 599 var debugName: String {
519 switch self { 600 switch self {
@@ -21,22 +21,30 @@ final class HealthKitHostApiImpl: HealthKitHostApi { @@ -21,22 +21,30 @@ final class HealthKitHostApiImpl: HealthKitHostApi {
21 21
22 func checkHealthAppAuthorization(completion: @escaping (Result<HealthAuthorization, any Error>) -> Void) { 22 func checkHealthAppAuthorization(completion: @escaping (Result<HealthAuthorization, any Error>) -> Void) {
23 Task { 23 Task {
24 - guard service.isHealthDataAvailable else {  
25 - completion(.success(HealthAuthorization(status: -1, hasData: false)))  
26 - return  
27 - }  
28 -  
29 - let requestStatus = await service.authorizationRequestStatus()  
30 - let status: Int64  
31 - var hasData = false  
32 - if requestStatus == .shouldRequest {  
33 - status = 0 24 + let authorization: HealthAuthorization
  25 + let requestStatus: HKAuthorizationRequestStatus?
  26 +
  27 + if !service.isHealthDataAvailable {
  28 + requestStatus = nil
  29 + authorization = HealthAuthorization(status: -1, hasData: false)
34 } else { 30 } else {
35 - hasData = await service.hasAnyReadableData()  
36 - status = hasData ? 1 : 2 31 + requestStatus = await service.authorizationRequestStatus()
  32 + if requestStatus == .shouldRequest {
  33 + authorization = HealthAuthorization(status: 0, hasData: false)
  34 + } else {
  35 + let oneMonthAgo = Calendar.current.date(byAdding: .month, value: -1, to: Date())
  36 + ?? Date(timeIntervalSinceNow: -30 * 24 * 60 * 60)
  37 + let hasData = await service.hasAnyReadableData(startingAt: oneMonthAgo)
  38 + authorization = HealthAuthorization(status: hasData ? 1 : 2, hasData: hasData)
  39 + }
  40 + }
  41 +
  42 + DebugLogger.log(
  43 + desc: "checkHealthAppAuthorization result: requestStatus=\(String(describing: requestStatus)) status=\(authorization.status) hasData=\(authorization.hasData)"
  44 + )
  45 + await MainActor.run {
  46 + completion(.success(authorization))
37 } 47 }
38 - DebugLogger.log(desc: "checkHealthAppAuthorization result: requestStatus=\(status) hasData=\(hasData)")  
39 - completion(.success(HealthAuthorization(status: status, hasData: hasData)))  
40 } 48 }
41 } 49 }
42 50
@@ -50,21 +58,12 @@ final class HealthKitHostApiImpl: HealthKitHostApi { @@ -50,21 +58,12 @@ final class HealthKitHostApiImpl: HealthKitHostApi {
50 } 58 }
51 59
52 Task { 60 Task {
53 - let requestStatus = await service.authorizationRequestStatus()  
54 -  
55 - let status: Int64  
56 - if requestStatus == .shouldRequest {  
57 - status = 0  
58 - } else {  
59 - let hasData = await service.hasAnyReadableData()  
60 - status = hasData ? 1 : 2  
61 - }  
62 -  
63 - let granted = status == 1 61 + let granted = success
64 DebugLogger.log(desc:"requestHealthClientAuthorization granted: \(granted)") 62 DebugLogger.log(desc:"requestHealthClientAuthorization granted: \(granted)")
65 if granted { 63 if granted {
66 service.startBackgroundObserversIfNeeded() 64 service.startBackgroundObserversIfNeeded()
67 await service.refreshSharedWatchValues() 65 await service.refreshSharedWatchValues()
  66 + _ = await NativeHealthDataUploader.shared.uploadAll(service: service)
68 } 67 }
69 completion(.success(granted)) 68 completion(.success(granted))
70 } 69 }
@@ -160,6 +160,9 @@ final class PlatformHostApiImpl: PlatformHostApi { @@ -160,6 +160,9 @@ final class PlatformHostApiImpl: PlatformHostApi {
160 160
161 Task{ 161 Task{
162 await AppShared.shared.reportDeviceInfo() 162 await AppShared.shared.reportDeviceInfo()
  163 + if await HealthKitService.shared.hasAnyReadableData() {
  164 + _ = await NativeHealthDataUploader.shared.uploadAll()
  165 + }
163 } 166 }
164 } 167 }
165 }catch{ 168 }catch{
@@ -11,11 +11,20 @@ import UIKit @@ -11,11 +11,20 @@ import UIKit
11 @main 11 @main
12 struct RunnerApp: App { 12 struct RunnerApp: App {
13 @UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate 13 @UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
  14 + @Environment(\.scenePhase) private var scenePhase
14 15
15 var body: some Scene { 16 var body: some Scene {
16 WindowGroup { 17 WindowGroup {
17 RunnerLaunchView(appDelegate: appDelegate) 18 RunnerLaunchView(appDelegate: appDelegate)
18 } 19 }
  20 + .onChange(of: scenePhase, initial: true) { _, phase in
  21 + guard phase == .active else { return }
  22 + appDelegate.setup()
  23 + HealthKitService.shared.startBackgroundObserversIfNeeded()
  24 + Task {
  25 + await HealthKitService.shared.uploadActivityTargetAfterForeground()
  26 + }
  27 + }
19 } 28 }
20 } 29 }
21 30
@@ -11,6 +11,7 @@ enum AppGroupConstants { @@ -11,6 +11,7 @@ enum AppGroupConstants {
11 static let watchDeviceToken = "watchDeviceToken" 11 static let watchDeviceToken = "watchDeviceToken"
12 12
13 static let latestHealthData = "latestHealthData" 13 static let latestHealthData = "latestHealthData"
  14 + static let localHealthActivityTarget = "localHealthActivityTarget"
14 static let acitivyTarget = "acitivyTarget" 15 static let acitivyTarget = "acitivyTarget"
15 static let myWatchTheme = "myWatchTheme" 16 static let myWatchTheme = "myWatchTheme"
16 static let otherWatchTheme = "otherWatchTheme" 17 static let otherWatchTheme = "otherWatchTheme"
@@ -42,6 +43,7 @@ enum AppGroupConstants { @@ -42,6 +43,7 @@ enum AppGroupConstants {
42 static func clearUserSessionData() { 43 static func clearUserSessionData() {
43 [ 44 [
44 Key.latestHealthData, 45 Key.latestHealthData,
  46 + Key.localHealthActivityTarget,
45 Key.acitivyTarget, 47 Key.acitivyTarget,
46 Key.myWatchTheme, 48 Key.myWatchTheme,
47 Key.otherWatchTheme, 49 Key.otherWatchTheme,
@@ -11,6 +11,12 @@ import Foundation @@ -11,6 +11,12 @@ import Foundation
11 11
12 class DebugLogger{ 12 class DebugLogger{
13 // let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "App", category: "HealthKit") 13 // let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "App", category: "HealthKit")
  14 +
  15 + static func debugLog(_ message: String) {
  16 + #if DEBUG || CI_ENV
  17 + print(message)
  18 + #endif
  19 + }
14 20
15 static func log(desc: String){ 21 static func log(desc: String){
16 print(desc) 22 print(desc)