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 | +} |
3.27 KB
| 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 | +} |
2.15 KB
| 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.94 KB
| 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 | +} |
2.22 KB
| 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 | +} |
2.36 KB
ios/Runner Watch App/DebugLogger.swift
0 → 100644
ios/Runner Watch App/Models/HAppFriend.swift
0 → 100644
| 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 | + |
ios/Runner Watch App/Models/HVipInfo.swift
0 → 100644
| 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 | } |
ios/Runner Watch App/WidgetKind.swift
0 → 100644
| 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) |
-
Please register or login to post a comment