Commit d34cdac8c227c3051ec6e4680dbaf7c2602a0204

Authored by 权海
1 parent bb25c4b6

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

Showing 46 changed files with 2928 additions and 860 deletions
{
"images" : [
{
"idiom" : "universal",
"scale" : "1x"
},
{
"filename" : "theme_default_level_1@2x.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
... ...
{
"images" : [
{
"idiom" : "universal",
"scale" : "1x"
},
{
"filename" : "theme_default_level_2@2x.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
... ...
{
"images" : [
{
"idiom" : "universal",
"scale" : "1x"
},
{
"filename" : "theme_default_level_3@2x.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
... ...
{
"images" : [
{
"idiom" : "universal",
"scale" : "1x"
},
{
"filename" : "theme_default_level_4@2x.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
... ...
{
"images" : [
{
"idiom" : "universal",
"scale" : "1x"
},
{
"filename" : "theme_default_no_data@2x.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
... ...
import Foundation
enum DebugLogger {
static func debugLog(_ message: String) {
#if DEBUG || CI_ENV
print(message)
#endif
}
}
... ...
//
// HAppFriend.swift
// hippo-watch Watch App
//
// Created by 权海 on 2026/6/22.
//
struct HAppFriendList: Codable{
var list: [HAppFriend]?
}
struct HAppFriend: Codable {
var id: Int?
/// 自己的userId
var user_id: Int?
/// 对方的userId
var friend_user_id: Int?
var remark_name: String?
/// 是否显示在表盘
var show_in_dial: Int?
var state: Int?
var create_time: Int?
var update_time: Int?
var avatar: String?
}
... ...
//
// HFriendTodayData.swift
// hippo-watch Watch App
//
// Created by 权海 on 2026/6/23.
//
struct HFriendTodayData: Codable {
var avg_hrv: Int?
var latest_hr: Int?
var move: Int?
var steps: Int?
}
... ...
//
// HVipInfo.swift
// hippo-watch Watch App
//
// Created by 权海 on 2026/6/22.
//
struct HVipInfo: Codable {
var is_vip: Bool?
var vip_is_forever: Bool?
var is_share: Bool?
var isAvaiableVip: Bool{
is_vip == true || vip_is_forever == true
}
}
... ...
... ... @@ -16,28 +16,32 @@ struct LatestHRV: Codable {
}
extension HRVStatus {
extension HRVStressState {
var watchStatusColors: [Color] {
switch self {
case .fullOfEnergy:
case .energetic:
return [.init(hex: "#FF6EA271"),
.black]
case .normal:
return [.init(hex: "#FF796EA2"),
.black]
case .overpressure:
case .slightStress, .stressful:
return [.init(hex: "#FF924343"),
.black]
case .wait:
return [.black, .black]
}
}
var watchStatusTextColor: Color {
switch self {
case .fullOfEnergy:
case .energetic:
return .init(hex: "#FF95FF9B")
case .normal:
return .init(hex: "#FFE2BBFF")
case .overpressure:
case .slightStress, .stressful:
return .init(hex: "#FFFF8E8E")
case .wait:
return .init(hex: "#FFFF8E8E")
}
}
... ...
import Foundation
import SwiftUI
enum HRVStatus: String, Codable {
case fullOfEnergy = "活力满满"
case normal = "状态正常"
case overpressure = "压力过载"
}
struct HRVData: Codable {
var value: Double?
var hrvBaseline: Double?
var date: Date?
var status: HRVStatus? {
guard let value else { return nil }
guard let hrvBaseline, hrvBaseline > 0 else { return .normal }
let ratio = value / hrvBaseline
if ratio >= 1.05 { return .fullOfEnergy }
if ratio < 0.9 { return .overpressure }
return .normal
}
}
struct UserInfo: Codable {
var id: Int?
var pairId: Int?
var isPaired: Bool {
pairId != nil
}
}
extension WatchThemeModel {
var isDefaultTheme: Bool? {
isOfficial.map { $0 == 1 }
}
func hrvStatusThumnailImageURL(hrvStatus: HRVStatus) -> String? {
switch hrvStatus {
case .fullOfEnergy:
return energeticImageURL
case .normal:
return normalImageURL
case .overpressure:
return stressfulImageURL
}
}
}
extension Text {
func wenyiheiFont(size: CGFloat) -> Text {
font(.custom("WenYue-XinQingNianTi-NC-W8", size: size))
}
}
... ...
... ... @@ -7,13 +7,12 @@
import Foundation
struct TodayHealthInfo: Codable {
var recentData: RecentData?
var hrvDataList: [HRVData]?
var sleepDuration: Int
enum HealthType: String{
case hrv
case realtime_stress
}
struct RecentData: Codable {
var heartRate: Double?
var oxygenSaturation: Int?
... ...
//
// UserInfoModel.swift
// hippo
//
// Created by 权海 on 2026/6/26.
//
import Foundation
struct UserInfoModel: Codable, Equatable{
var userId: Int?
var token: String?
var baseUrl: String?
init(userId: Int? = nil, token: String? = nil, baseUrl: String? = nil) {
self.userId = userId
self.token = token
self.baseUrl = baseUrl
}
func toJson() -> [String: Any]{
do{
let data = try JSONEncoder().encode(self)
return try JSONSerialization.jsonObject(with: data, options: .fragmentsAllowed) as! [String : Any]
}catch{
return [:]
}
}
var jsonString: String? {
guard let data = try? JSONEncoder().encode(self) else {
return nil
}
return String(data: data, encoding: .utf8)
}
}
... ...
import Foundation
/// Upload boundaries returned by GET `/client/doublefeel/health/v2/data_upload/common/`.
/// The API has existed in both direct and `{ "data": ... }` response shapes.
struct WatchHealthUploadTimeList: Decodable {
struct Item: Decodable {
let dataType: Int?
let latestDataTime: TimeInterval?
enum CodingKeys: String, CodingKey {
case dataType = "data_type"
case latestDataTime = "latest_data_time"
}
}
let latestDataTimeList: [Item]
private enum CodingKeys: String, CodingKey {
case latestDataTimeList = "latest_data_time_list"
case data
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
if let list = try container.decodeIfPresent([Item].self, forKey: .latestDataTimeList) {
latestDataTimeList = list
return
}
if let wrapped = try container.decodeIfPresent(WatchHealthUploadTimeList.self, forKey: .data) {
latestDataTimeList = wrapped.latestDataTimeList
return
}
throw DecodingError.dataCorruptedError(
forKey: .latestDataTimeList,
in: container,
debugDescription: "Missing latest_data_time_list"
)
}
func latestDate(for dataType: Int) -> Date? {
latestDataTimeList
.first { $0.dataType == dataType }?
.latestDataTime
.map(Date.init(timeIntervalSince1970:))
}
}
... ...
... ... @@ -2,161 +2,173 @@ import Foundation
import SwiftUI
import HealthKit
enum HRVStatus: String, Codable {
case fullOfEnergy = "活力满满"
case normal = "状态正常"
case overpressure = "压力过载"
}
struct HRVData: Codable {
var value: Double?
var hrvBaseline: Double?
var date: Date?
var status: HRVStatus? {
guard let value else { return nil }
guard let hrvBaseline, hrvBaseline > 0 else { return .normal }
let ratio = value / hrvBaseline
if ratio >= 1.05 {
return .fullOfEnergy
}
if ratio < 0.9 {
return .overpressure
}
return .normal
}
}
enum UserCharacter: Int, Codable {
case `default` = 0
case cat = 1
case dog = 2
case rabbit = 3
case elephant = 4
}
struct AvatarResource: Codable {
var rawValue: String?
var url: URL? {
guard let rawValue else { return nil }
return URL(string: rawValue)
}
init(rawValue: String?) {
self.rawValue = rawValue
}
init(from decoder: Decoder) throws {
if let value = try? decoder.singleValueContainer().decode(String.self) {
rawValue = value
return
}
let container = try decoder.container(keyedBy: DynamicCodingKey.self)
rawValue = Self.decodeString(from: container, keys: ["url", "avatar", "path"])
}
func encode(to encoder: Encoder) throws {
var container = encoder.singleValueContainer()
try container.encode(rawValue)
}
private static func decodeString(from container: KeyedDecodingContainer<DynamicCodingKey>, keys: [String]) -> String? {
for key in keys {
if let codingKey = DynamicCodingKey(stringValue: key),
let value = try? container.decodeIfPresent(String.self, forKey: codingKey) {
return value
extension WatchThemeModel{
func description(for state: HRVStressState) -> (title: String?, image: Image?){
switch state {
case .wait:
return (state.name, state.defaultThemeImage)
case .stressful:
var image: Image?
if let imageUrl = stressfulImageURL,
let imageData = AppGroupConstants.defaults?.data(forKey: imageUrl),
let uiImage = UIImage(data: imageData){
image = Image(uiImage: uiImage)
}
}
return nil
}
}
struct UserInfo: Codable {
var id: Int?
var pairId: Int?
var nickname: String?
var avatar: AvatarResource?
var persona: UserCharacter?
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: DynamicCodingKey.self)
id = Self.decodeInt(from: container, keys: ["id", "user_id", "userId"])
pairId = Self.decodeInt(from: container, keys: ["pair_id", "pairId"])
nickname = Self.decodeString(from: container, keys: ["nickname", "nick_name", "name"])
avatar = try Self.decodeAvatar(from: container)
persona = UserCharacter(rawValue: Self.decodeInt(from: container, keys: ["persona", "character", "user_character", "userCharacter"]) ?? 0)
}
func toWatchMap() -> [String: Any] {
var map: [String: Any] = [:]
if let id { map["id"] = id }
if let pairId { map["pair_id"] = pairId }
if let nickname { map["nickname"] = nickname }
if let avatar = avatar?.rawValue { map["avatar"] = avatar }
if let persona { map["persona"] = persona.rawValue }
return map
}
private static func decodeAvatar(from container: KeyedDecodingContainer<DynamicCodingKey>) throws -> AvatarResource? {
for key in ["avatar", "avatar_url", "avatarUrl"] {
guard let codingKey = DynamicCodingKey(stringValue: key) else { continue }
if let value = try? container.decodeIfPresent(String.self, forKey: codingKey) {
return AvatarResource(rawValue: value)
return (stressfulTitle, image)
case .slightStress:
var image: Image?
if let imageUrl = slightStressImageURL,
let imageData = AppGroupConstants.defaults?.data(forKey: imageUrl),
let uiImage = UIImage(data: imageData){
image = Image(uiImage: uiImage)
}
return (slightStressTitle, image)
case .normal:
var image: Image?
if let imageUrl = normalImageURL,
let imageData = AppGroupConstants.defaults?.data(forKey: imageUrl),
let uiImage = UIImage(data: imageData){
image = Image(uiImage: uiImage)
}
if let value = try? container.decodeIfPresent(AvatarResource.self, forKey: codingKey) {
return value
return (normalTitle, image)
case .energetic:
var image: Image?
if let imageUrl = energeticImageURL,
let imageData = AppGroupConstants.defaults?.data(forKey: imageUrl),
let uiImage = UIImage(data: imageData){
image = Image(uiImage: uiImage)
}
return (energeticTitle, image)
}
return nil
}
}
struct WatchThemeModel: Codable {
var isDefaultTheme: Bool?
var positiveDescription: String?
var normalDescription: String?
var negativeDescription: String?
var positiveImageURL: String?
var normalImageURL: String?
var negativeImageURL: String?
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: DynamicCodingKey.self)
isDefaultTheme = Self.decodeBool(from: container, keys: ["isDefaultTheme", "is_default_theme", "isDefault", "is_default"])
positiveDescription = Self.decodeString(from: container, keys: ["positiveDescription", "positive_description", "excellentDescription", "excellent_description"])
normalDescription = Self.decodeString(from: container, keys: ["normalDescription", "normal_description"])
negativeDescription = Self.decodeString(from: container, keys: ["negativeDescription", "negative_description", "overpressureDescription", "overpressure_description"])
positiveImageURL = Self.decodeString(from: container, keys: ["positiveImageURL", "positiveImageUrl", "positive_image_url", "positiveThumbnailURL", "positive_thumbnail_url", "positiveThumnailImageURL", "positive_thumnail_image_url"])
normalImageURL = Self.decodeString(from: container, keys: ["normalImageURL", "normalImageUrl", "normal_image_url", "normalThumbnailURL", "normal_thumbnail_url", "normalThumnailImageURL", "normal_thumnail_image_url"])
negativeImageURL = Self.decodeString(from: container, keys: ["negativeImageURL", "negativeImageUrl", "negative_image_url", "negativeThumbnailURL", "negative_thumbnail_url", "negativeThumnailImageURL", "negative_thumnail_image_url"])
enum HRVStressState: Int, Codable, CaseIterable{
case wait
case stressful
case slightStress
case normal
case energetic
var name: String{
switch self {
case .wait:
return "等待数据"
case .stressful:
return "压力过载"
case .slightStress:
return "注意压力"
case .normal:
return "状态正常"
case .energetic:
return "活力满满"
}
}
func hrvStatusDescription(hrvStatus: HRVStatus) -> String {
switch hrvStatus {
case .fullOfEnergy:
return positiveDescription ?? hrvStatus.rawValue
var themeColor: Color{
switch self {
case .energetic:
return DFWatchFigmaColor.green
case .normal:
return normalDescription ?? hrvStatus.rawValue
case .overpressure:
return negativeDescription ?? hrvStatus.rawValue
return DFWatchFigmaColor.blue
case .slightStress:
return DFWatchFigmaColor.orange
case .stressful:
return DFWatchFigmaColor.red
case .wait:
return DFWatchFigmaColor.grayBar
}
}
func hrvStatusThumnailImageURL(hrvStatus: HRVStatus) -> String? {
switch hrvStatus {
case .fullOfEnergy:
return positiveImageURL
var defaultThemeImage: Image{
switch self {
case .energetic:
return Image(.themeDefaultLevel1)
case .normal:
return normalImageURL
case .overpressure:
return negativeImageURL
return Image(.themeDefaultLevel2)
case .slightStress:
return Image(.themeDefaultLevel3)
case .stressful:
return Image(.themeDefaultLevel4)
case .wait:
return Image(.themeDefaultNoData)
}
}
}
struct WatchHRVData: Codable {
var latest_hrv_time: Int?
/// 最新的hrv
var latest_hrv: Double?
var hrv_state: HRVStressState?
var latest_stress_time: Int?
/// 直接加百分号
var latest_stress_value: Double?
var stress_state: HRVStressState?
var friend_latest_hrv_time: Int?
var friend_latest_hrv: Double?
var friend_hrv_state: HRVStressState?
var friend_latest_stress_time: Int?
var friend_latest_stress_value: Double?
var friend_stress_state: HRVStressState?
var myHRVData: WatchHRVPersonalData{
return WatchHRVPersonalData(isMe: true,
latest_hrv: latest_hrv,
latest_hrv_time: latest_hrv_time,
hrv_state: hrv_state,
latest_stress_time: latest_stress_time,
latest_stress_value: latest_stress_value,
stress_state: stress_state
)
}
var otherHRVData: WatchHRVPersonalData{
return WatchHRVPersonalData(isMe: false,
latest_hrv: friend_latest_hrv,
latest_hrv_time: friend_latest_hrv_time,
hrv_state: friend_hrv_state,
latest_stress_time: friend_latest_stress_time,
latest_stress_value: friend_latest_stress_value,
stress_state: friend_stress_state
)
}
}
struct WatchHRVPersonalData{
var isMe: Bool
var latest_hrv: Double?
var latest_hrv_time: Int?
var hrv_state: HRVStressState?
var latest_stress_time: Int?
var latest_stress_value: Double?
var stress_state: HRVStressState?
}
struct ActivityTarget: Codable {
var id: Int?
var create_time: Int?
var update_time: Int?
var user_id: Int?
var move: Int?
var step: Int?
var stand: Int?
var exercise: Int?
var activity_move_mode: Int?
var active_energy_burned: Double?
var active_energy_burned_goal: Double?
var apple_move_time: Double?
var apple_move_time_goal: Double?
var apple_exercise_time: Double?
var exercise_time_goal: Double?
var apple_stand_hours: Double?
var stand_hours_goal: Double?
}
extension String {
var url: URL? {
URL(string: self)
... ... @@ -190,12 +202,6 @@ extension Array where Element == Double {
}
}
extension Text {
func wenyiheiFont(size: CGFloat) -> Text {
font(.custom("WenYue-XinQingNianTi-NC-W8", size: size))
}
}
struct SleepStage {
let stage: HKCategoryValueSleepAnalysis
let startTime: Date
... ... @@ -242,61 +248,3 @@ private struct DynamicCodingKey: CodingKey {
}
}
private extension UserInfo {
static func decodeString(from container: KeyedDecodingContainer<DynamicCodingKey>, keys: [String]) -> String? {
for key in keys {
if let codingKey = DynamicCodingKey(stringValue: key),
let value = try? container.decodeIfPresent(String.self, forKey: codingKey) {
return value
}
}
return nil
}
static func decodeInt(from container: KeyedDecodingContainer<DynamicCodingKey>, keys: [String]) -> Int? {
for key in keys {
guard let codingKey = DynamicCodingKey(stringValue: key) else { continue }
if let value = try? container.decodeIfPresent(Int.self, forKey: codingKey) {
return value
}
if let value = try? container.decodeIfPresent(String.self, forKey: codingKey), let intValue = Int(value) {
return intValue
}
}
return nil
}
}
extension UserInfo {
var isPaired: Bool {
pairId != nil
}
}
private extension WatchThemeModel {
static func decodeString(from container: KeyedDecodingContainer<DynamicCodingKey>, keys: [String]) -> String? {
for key in keys {
if let codingKey = DynamicCodingKey(stringValue: key),
let value = try? container.decodeIfPresent(String.self, forKey: codingKey) {
return value
}
}
return nil
}
static func decodeBool(from container: KeyedDecodingContainer<DynamicCodingKey>, keys: [String]) -> Bool? {
for key in keys {
guard let codingKey = DynamicCodingKey(stringValue: key) else { continue }
if let value = try? container.decodeIfPresent(Bool.self, forKey: codingKey) {
return value
}
if let value = try? container.decodeIfPresent(Int.self, forKey: codingKey) {
return value != 0
}
if let value = try? container.decodeIfPresent(String.self, forKey: codingKey) {
return ["1", "true", "yes"].contains(value.lowercased())
}
}
return nil
}
}
... ...
//
// WatchThemeModel.swift
// hippo
//
// Created by 权海 on 2026/6/26.
//
struct WatchThemeModel: Codable, Equatable {
var id: Int?
var userId: Int?
var themeName: String?
var isOfficial: Int?
var energeticTitle: String?
var energeticImageURL: String?
var normalTitle: String?
var normalImageURL: String?
var slightStressTitle: String?
var slightStressImageURL: String?
var stressfulTitle: String?
var stressfulImageURL: String?
static func == (lhs: Self, rhs: Self) -> Bool{
lhs.energeticImageURL == rhs.energeticImageURL &&
lhs.normalImageURL == rhs.normalImageURL &&
lhs.slightStressImageURL == rhs.slightStressImageURL &&
lhs.stressfulImageURL == rhs.stressfulImageURL
}
enum CodingKeys: String, CodingKey {
case id
case userId = "user_id"
case themeName = "theme_name"
case isOfficial = "is_official"
case energeticTitle = "energetic_description"
case energeticImageURL = "energetic_image"
case normalTitle = "normal_description"
case normalImageURL = "normal_image"
case slightStressTitle = "slight_stressful_description"
case slightStressImageURL = "slight_stressful_image"
case stressfulTitle = "stressful_description"
case stressfulImageURL = "stressful_image"
}
}
... ...
... ... @@ -7,20 +7,32 @@
import Foundation
enum HealthEndpoint: APIEndpoint {
case getTodayStatusInfo
case getLatestHRV
case getActivityTarget
case getHealthData(type: HealthType)
case getTodayData(isFriend: Bool)
case upload(data: [String: Any])
case uploadBatch(data: [[String: Any]])
case uploadSleep(data: [[String: Any]])
case uploadActivityTarget(data: [String: Any])
case getUploadTimes
case uploadPulse(pulseType: PulseType)
var path: String {
switch self {
case .getTodayStatusInfo:
return "/client/doublefeel/health/info_today/"
case .getLatestHRV:
return "/client/doublefeel/health/lastest_hrv/"
case .upload:
return "/client/doublefeel/health/data_upload/common/"
case .getActivityTarget:
return "/client/doublefeel/health/v2/activity_target/"
case .getHealthData:
return "/client/doublefeel/health/v2/dial_health_info/"
case .getTodayData:
return "/client/doublefeel/health/v2/dial_today_info/"
case .upload, .uploadBatch, .getUploadTimes:
return "/client/doublefeel/health/v2/data_upload/common/"
case .uploadSleep:
return "/client/doublefeel/health/v2/data_upload/sleep/"
case .uploadActivityTarget:
return "/client/doublefeel/health/v2/activity_target/"
case .uploadPulse:
return "/client/doublefeel/health/pulse/"
}
... ... @@ -28,25 +40,27 @@ enum HealthEndpoint: APIEndpoint {
var method: HTTPMethod {
switch self {
case .getTodayStatusInfo:
return .get
case .getLatestHRV:
case .getActivityTarget, .getHealthData, .getTodayData, .getUploadTimes:
return .get
case .upload:
return .post
case .uploadPulse:
case .upload, .uploadBatch, .uploadSleep, .uploadActivityTarget, .uploadPulse:
return .post
}
}
var parameters: [String: Any]? {
switch self {
case .getTodayStatusInfo:
return ["is_other": 1]
case .getLatestHRV:
case .getActivityTarget, .getUploadTimes:
return nil
case .getTodayData(let isFriend):
return ["is_dial_friend": isFriend ? 1 : 0]
case let .getHealthData(type):
return ["show_type": type.rawValue]
case .upload(let data):
return ["data_list": [data]]
case .uploadBatch(let data), .uploadSleep(let data):
return ["data_list": data]
case .uploadActivityTarget(let data):
return data
case .uploadPulse(let pulseType):
return ["pulse_type": pulseType.rawValue]
}
... ... @@ -54,15 +68,10 @@ enum HealthEndpoint: APIEndpoint {
var encoding: ParameterEncoding {
switch self {
case .getTodayStatusInfo:
case .getActivityTarget, .getHealthData, .getTodayData, .getUploadTimes:
return .url
case .getLatestHRV:
return .url
case .upload:
return .json
case .uploadPulse:
case .upload, .uploadBatch, .uploadSleep, .uploadActivityTarget, .uploadPulse:
return .json
}
}
}
... ...
... ... @@ -12,18 +12,42 @@ class HealthService {
private let apiClient = APIClient.shared
func getTodayStatusInfo() async throws -> TodayHealthInfo {
return try await apiClient.request(HealthEndpoint.getTodayStatusInfo)
func getActivityTarget() async throws -> ActivityTarget {
return try await apiClient.request(HealthEndpoint.getActivityTarget)
}
func getLatestHRV() async throws -> LatestHRV {
return try await apiClient.request(HealthEndpoint.getLatestHRV)
func getHealthData(type: HealthType) async throws -> WatchHRVData {
return try await apiClient.request(HealthEndpoint.getHealthData(type: type))
}
func getMyData() async throws -> HFriendTodayData {
return try await apiClient.request(HealthEndpoint.getTodayData(isFriend: false))
}
func getFriendData() async throws -> HFriendTodayData {
return try await apiClient.request(HealthEndpoint.getTodayData(isFriend: true))
}
func upload(data: [String: Any]) async throws -> LatestHRV {
return try await apiClient.request(HealthEndpoint.upload(data: data))
}
func uploadBatch(data: [[String: Any]]) async throws -> EmptyResponse {
return try await apiClient.request(HealthEndpoint.uploadBatch(data: data))
}
func uploadSleep(data: [[String: Any]]) async throws -> EmptyResponse {
return try await apiClient.request(HealthEndpoint.uploadSleep(data: data))
}
func uploadActivityTarget(data: [String: Any]) async throws -> EmptyResponse {
return try await apiClient.request(HealthEndpoint.uploadActivityTarget(data: data))
}
func getUploadTimes() async throws -> WatchHealthUploadTimeList {
return try await apiClient.request(HealthEndpoint.getUploadTimes)
}
func uploadPulse(pulseType: PulseType) async throws -> EmptyResponse {
return try await apiClient.request(HealthEndpoint.uploadPulse(pulseType: pulseType))
}
... ...
//
// UserInfoEndpoint.swift
// hippo-watch Watch App
//
// Created by 权海 on 2026/6/22.
//
import Foundation
enum UserInfoEndpoint: APIEndpoint {
case getVipInfo
case getMyUserInfo
case getFriendInfo
case getWatchTheme
case uploadDeviceInfo(deviceInfo: String?, deviceToken: String?, watchDeviceToken: String?)
var path: String {
switch self {
case .getVipInfo:
return "/client/doublefeel/user/vip/info/"
case .getMyUserInfo:
return "/client/doublefeel/user/info/"
case .getFriendInfo:
return "/client/doublefeel/health/v2/friends/"
case .getWatchTheme:
return "/client/doublefeel/theme/watch_theme/list/"
case .uploadDeviceInfo:
return "/client/doublefeel/user/device/"
}
}
var method: HTTPMethod {
switch self {
case .getVipInfo, .getMyUserInfo, .getFriendInfo, .getWatchTheme:
return .get
case .uploadDeviceInfo:
return .post
}
}
var parameters: [String: Any]? {
switch self {
case .getVipInfo, .getMyUserInfo, .getWatchTheme:
return nil
case .getFriendInfo:
let params: [String: Any] = [
"with_health_data": 0
]
return params
case let .uploadDeviceInfo(deviceInfo, deviceToken, watchDeviceToken):
var params: [String: Any] = [:]
params["push_platform"] = "100"
params["device_info"] = deviceInfo
params["device_token"] = deviceToken
params["watch_device_token"] = watchDeviceToken
return params
}
}
var encoding: ParameterEncoding {
switch self {
case .getVipInfo, .getMyUserInfo, .getFriendInfo, .getWatchTheme:
return .url
case .uploadDeviceInfo:
return .json
}
}
}
... ...
//
// UserInfoService.swift
// hippo-watch Watch App
//
// Created by 权海 on 2026/6/22.
//
import Foundation
struct EmptyJson: Codable{
}
class UserInfoService {
private let apiClient = APIClient.shared
func getVipInfo() async -> HVipInfo? {
do{
return try await apiClient.request(UserInfoEndpoint.getVipInfo)
}catch{
print("getVipInfo error: \(error.localizedDescription)")
return nil
}
}
// func getMyUserInfo() async -> UserInfo?{
// do{
// return try await apiClient.request(UserInfoEndpoint.getMyUserInfo)
// }catch{
// print("getMyUserInfo error: \(error.localizedDescription)")
// return nil
// }
// }
func getFriendInfo() async -> HAppFriendList? {
do{
return try await apiClient.request(UserInfoEndpoint.getFriendInfo)
}catch{
print("getFriendInfo error: \(error.localizedDescription)")
return nil
}
}
func uploadDeviceInfo() async -> EmptyJson?{
do{
let deviceInfo = AppGroupConstants.defaults?.string(forKey: AppGroupConstants.Key.deviceInfo)
let deviceToken = AppGroupConstants.defaults?.string(forKey: AppGroupConstants.Key.appDeviceToken)
let watchToken = AppGroupConstants.defaults?.string(forKey: AppGroupConstants.Key.watchDeviceToken)
return try await apiClient.request(UserInfoEndpoint.uploadDeviceInfo(deviceInfo: deviceInfo, deviceToken: deviceToken, watchDeviceToken: watchToken))
}catch{
print("getFriendInfo error: \(error.localizedDescription)")
return nil
}
}
}
... ...
... ... @@ -8,12 +8,12 @@
import Foundation
enum ThemeEndpoint: APIEndpoint {
case getCurrentTheme(isOther: Bool)
case getCurrentTheme(userId: Int?)
var path: String {
switch self {
case .getCurrentTheme:
return "/client/doublefeel/theme/watch_theme/active/"
return "/client/doublefeel/theme/v2/watch_theme/active/"
}
}
... ... @@ -26,8 +26,11 @@ enum ThemeEndpoint: APIEndpoint {
var parameters: [String : Any]? {
switch self {
case .getCurrentTheme(let isOther):
return ["is_other": isOther ? 1 : 0]
case .getCurrentTheme(let userId):
if let userId{
return ["query_user_id": userId]
}
return nil
}
}
... ...
... ... @@ -10,7 +10,12 @@ import Foundation
class ThemeService {
private let apiClient = APIClient.shared
func getCurrentTheme(userId: Int?) async throws -> WatchThemeModel {
return try await apiClient.request(ThemeEndpoint.getCurrentTheme(userId: userId))
}
func getCurrentTheme(isOther: Bool) async throws -> WatchThemeModel {
return try await apiClient.request(ThemeEndpoint.getCurrentTheme(isOther: isOther))
let userId = isOther ? WatchUserinfoManager.share.displayFriend?.user_id : nil
return try await getCurrentTheme(userId: userId)
}
}
... ...
... ... @@ -6,26 +6,18 @@
//
import Foundation
//import ThinkingDataAnalyticsExtension
class ThinkSDKUtil {
static func initSDK() {
/*
#if DEBUG || CI_ENV
TDAnalyticsExtension.start(withAppId: "1efaeef2c4c04a3bac557d78899ecee6", serverUrl: "https://ta-api.didiapp.com")
#else
TDAnalyticsExtension.start(withAppId: "6e53fb7a8f5f483081c15b8f5f017401", serverUrl: "https://ta-api.didiapp.com")
#endif
*/
}
static func track(eventName: String,
properties: [String: Any]? = nil) {
// TDAnalyticsExtension.track(eventName, properties: properties)
}
static func login(_ uid: Int) {
// TDAnalyticsExtension.login(uid.string)
}
static func logout(){
}
}
... ...
... ... @@ -8,7 +8,15 @@
import WatchKit
import UserNotifications
@MainActor
final class WatchAppDelegate: NSObject, WKApplicationDelegate, UNUserNotificationCenterDelegate {
private enum HealthAuthorizationState {
case notRequested
case requesting
case completed
}
private var healthAuthorizationState: HealthAuthorizationState = .notRequested
func applicationDidFinishLaunching() {
setup()
... ... @@ -16,16 +24,49 @@ final class WatchAppDelegate: NSObject, WKApplicationDelegate, UNUserNotificatio
center.delegate = self
requestNotificationPermission()
}
private func setup(){
WatchUserAgent.share.genUA()
WatchSessionManager.share.addListen()
WatchDataManager.share.requestAuthForHealth()
WatchDataManager.share.fetchMyTodayData()
WatchDataManager.share.fetchOtherTodayData()
ThinkSDKUtil.initSDK()
if let id = WatchUserinfoManager.share.myUserinfo?.id {
ThinkSDKUtil.login(id)
WatchUserinfoManager.share.setup()
requestHealthAuthorizationIfNeeded()
}
func sceneDidBecomeActive() {
switch healthAuthorizationState {
case .completed:
// Local HealthKit reads and observer registration never depend on
// login state. Upload methods perform their own token check.
WatchHealthWidgetRefreshCoordinator.shared.refreshAll()
WatchHealthObserverUploader.shared.startObserversIfNeeded()
WatchHealthObserverUploader.shared.performFullUpload()
case .notRequested:
requestHealthAuthorizationIfNeeded()
case .requesting:
// The completion below will refresh data and start observers.
break
}
}
private func requestHealthAuthorizationIfNeeded() {
guard case .notRequested = healthAuthorizationState else { return }
healthAuthorizationState = .requesting
WatchDataManager.share.requestAuthForHealth { [weak self] success in
guard let self else { return }
if !success {
self.healthAuthorizationState = .notRequested
return
}
self.healthAuthorizationState = .completed
WatchDataManager.share.fetchMyTodayData()
WatchDataManager.share.getLatestHealthData()
WatchDataManager.share.getAcitivtyTarget()
// Register observers only after the authorization request has
// completed. Registering earlier can make background delivery fail
// permanently for this process.
WatchHealthObserverUploader.shared.startObserversIfNeeded()
WatchHealthObserverUploader.shared.performFullUpload()
}
}
... ... @@ -33,7 +74,6 @@ final class WatchAppDelegate: NSObject, WKApplicationDelegate, UNUserNotificatio
UNUserNotificationCenter.current().requestAuthorization(
options: [.alert, .sound, .badge]
) { granted, error in
print("watch notification permission:", granted, error as Any)
guard granted else { return }
DispatchQueue.main.async {
WKApplication.shared().registerForRemoteNotifications()
... ... @@ -43,14 +83,15 @@ final class WatchAppDelegate: NSObject, WKApplicationDelegate, UNUserNotificatio
func didRegisterForRemoteNotifications(withDeviceToken deviceToken: Data) {
let token = deviceToken.map { String(format: "%02x", $0) }.joined()
print("watch apns token:", token)
AppGroupConstants.defaults?.set(token, forKey: AppGroupConstants.Key.watchDeviceToken)
// TODO: 上传到你的服务端
// uploadWatchToken(token)
Task{
await UserInfoService().uploadDeviceInfo()
}
}
func didFailToRegisterForRemoteNotificationsWithError(_ error: Error) {
print("watch register push failed:", error)
}
// App 在前台收到通知:热启动前台展示
... ...
... ... @@ -10,19 +10,25 @@ import HealthKit
import WidgetKit
import Combine
class WatchDataManager: ObservableObject {
@MainActor
final class WatchDataManager: ObservableObject {
static let share = WatchDataManager()
private let healthStore = HKHealthStore()
@Published var myLatestHRVData: HRVData?
@Published var otherLatestHRVData: HRVData?
@Published var latestHRVData: WatchHRVData?
@Published var activityTarget: ActivityTarget?
@Published var myTodayAvgHRV: Double?
@Published var myData: HFriendTodayData?
@Published var friendData: HFriendTodayData?
// @Published var myTodayAvgHRV: Double?
@Published var myLatestHeartRate: Double?
@Published var myTodayStepCount: Int?
@Published var myTodayActiveEnergy: Double?
@Published var myTodayExerciseTime: Double?
@Published var myTodayStandTime: Double?
@Published var myTodaySleepTime: TimeInterval?
@Published var otherTodayHealthInfo: TodayHealthInfo?
// 想读取的类型,比如心率、步数
private let readTypes: Set<HKObjectType> = [
HKObjectType.quantityType(forIdentifier: .heartRate)!,
... ... @@ -33,7 +39,7 @@ class WatchDataManager: ObservableObject {
HKObjectType.quantityType(forIdentifier: .activeEnergyBurned)!,
HKObjectType.quantityType(forIdentifier: .appleExerciseTime)!,
HKObjectType.quantityType(forIdentifier: .appleStandTime)!,
HKObjectType.quantityType(forIdentifier: .walkingHeartRateAverage)!,//步行心率
HKObjectType.quantityType(forIdentifier: .restingHeartRate)!,//静息心率
//睡眠心率(注)
... ... @@ -42,9 +48,18 @@ class WatchDataManager: ObservableObject {
,
HKObjectType.quantityType(forIdentifier: .appleSleepingWristTemperature)!,
HKObjectType.quantityType(forIdentifier: .respiratoryRate)!,
HKObjectType.categoryType(forIdentifier: .irregularHeartRhythmEvent)!,
]
func requestAuthForHealth() {
func requestAuthForHealth(completion: ((Bool) -> Void)?) {
guard HKHealthStore.isHealthDataAvailable() else {
print("❌ HealthKit is not available on this watch")
DispatchQueue.main.async {
completion?(false)
}
return
}
let typesToShare: Set = [
HKObjectType.workoutType()
]
... ... @@ -57,19 +72,49 @@ class WatchDataManager: ObservableObject {
} else {
print("❌ HealthKit not authorized by user")
}
DispatchQueue.main.async {
completion?(success && error == nil)
}
}
}
func logout(){
latestHRVData = nil
activityTarget = nil
friendData = nil
// myTodayAvgHRV = nil
myLatestHeartRate = nil
myTodayStepCount = nil
myTodayActiveEnergy = nil
myTodayExerciseTime = nil
myTodayStandTime = nil
myTodaySleepTime = nil
[
AppGroupConstants.Key.latestHealthData,
AppGroupConstants.Key.acitivyTarget,
AppGroupConstants.Key.myTodayStandTime,
AppGroupConstants.Key.myTodayExerciseTime,
AppGroupConstants.Key.myTodayActiveEnergy,
AppGroupConstants.Key.myTodayStepCount,
AppGroupConstants.Key.myLatestHeartRate
].forEach { key in
AppGroupConstants.defaults?.removeObject(forKey: key)
}
WatchWidgetReloader.reloadAllDynamicWidgets()
WatchHealthWidgetRefreshCoordinator.shared.refreshAll()
}
func fetchCoupleLatestHRV() {
fetchLatestHRV()
fetchOtherLatestHRV()
getLatestHealthData()
getAcitivtyTarget()
observeHealthData()
}
private func fetchLatestHRV() {
guard let hrvType = HKObjectType.quantityType(forIdentifier: .heartRateVariabilitySDNN) else { return }
let sortDescriptor = NSSortDescriptor(key: HKSampleSortIdentifierEndDate, ascending: false)
let query = HKSampleQuery(sampleType: hrvType,
predicate: nil,
... ... @@ -80,52 +125,27 @@ class WatchDataManager: ObservableObject {
print("查询最新HRV失败: \(error.localizedDescription)")
return
}
if let sample = samples?.first as? HKQuantitySample {
let value = sample.quantity.doubleValue(for: .secondUnit(with: .milli))
Task {
let result = try? await HealthService().getLatestHRV()
let myHrvBaseline = result?.userHrvBaseline
let data = HRVData(value: value,
hrvBaseline: myHrvBaseline,
date: sample.endDate)
await MainActor.run {
self.myLatestHRVData = data
}
self.saveHRVBaselineValue(myHrvBaseline)
}
DispatchQueue.main.async {
self.saveHRVValue(value)
self.uploadHRV(value: value, date: sample.endDate)
WidgetCenter.shared.reloadAllTimelines()
WatchWidgetReloader.reloadHRV()
print("最新HRV: \(sample)")
}
}
}
healthStore.execute(query)
}
private func fetchOtherLatestHRV() {
Task {
let result = try? await HealthService().getLatestHRV()
await MainActor.run {
if let value = result?.pairUserHrv {
let data = HRVData(value: value,
hrvBaseline: result?.pairHrvBaseline,
date: nil)
self.otherLatestHRVData = data
}
}
}
}
private func fetchTodayAvgHRV() {
guard let hrvType = HKObjectType.quantityType(forIdentifier: .heartRateVariabilitySDNN) else { return }
let startOfDay = Calendar.current.startOfDay(for: Date())
let predicate = HKQuery.predicateForSamples(withStart: startOfDay, end: Date(), options: .strictStartDate)
let query = HKSampleQuery(sampleType: hrvType,
predicate: predicate,
limit: HKObjectQueryNoLimit,
... ... @@ -135,26 +155,26 @@ class WatchDataManager: ObservableObject {
print("查询HRV失败: \(error.localizedDescription)")
return
}
guard let samples = samples as? [HKQuantitySample], !samples.isEmpty else { return }
let total = samples.reduce(0.0) { $0 + $1.quantity.doubleValue(for: .secondUnit(with: .milli)) }
let average = total / Double(samples.count)
DispatchQueue.main.async {
self.myTodayAvgHRV = average
}
// DispatchQueue.main.async {
// self.myTodayAvgHRV = average
// }
}
healthStore.execute(query)
}
private func fetchTodayLatestHeartRate() {
guard let heartRateType = HKObjectType.quantityType(forIdentifier: .heartRate) else { return }
let startOfDay = Calendar.current.startOfDay(for: Date())
let predicate = HKQuery.predicateForSamples(withStart: startOfDay, end: Date(), options: .strictStartDate)
let sortDescriptor = NSSortDescriptor(key: HKSampleSortIdentifierEndDate, ascending: false)
let query = HKSampleQuery(sampleType: heartRateType,
predicate: predicate,
... ... @@ -165,24 +185,32 @@ class WatchDataManager: ObservableObject {
print("查询今日最新心率失败: \(error.localizedDescription)")
return
}
if let sample = samples?.first as? HKQuantitySample {
let value = sample.quantity.doubleValue(for: .count().unitDivided(by: .minute()))
AppGroupConstants.defaults?.set(value, forKey: AppGroupConstants.Key.myLatestHeartRate)
DispatchQueue.main.async {
self.myLatestHeartRate = value
WatchWidgetReloader.reloadHeartRate()
}
} else {
AppGroupConstants.defaults?.removeObject(forKey: AppGroupConstants.Key.myLatestHeartRate)
DispatchQueue.main.async {
self.myLatestHeartRate = nil
WatchWidgetReloader.reloadHeartRate()
}
}
}
healthStore.execute(query)
}
private func fetchTodayStepCount() {
guard let stepType = HKObjectType.quantityType(forIdentifier: .stepCount) else { return }
let startOfDay = Calendar.current.startOfDay(for: Date())
let predicate = HKQuery.predicateForSamples(withStart: startOfDay, end: Date(), options: .strictStartDate)
let query = HKStatisticsQuery(quantityType: stepType,
quantitySamplePredicate: predicate,
options: .cumulativeSum) { [weak self] _, result, error in
... ... @@ -191,26 +219,24 @@ class WatchDataManager: ObservableObject {
print("查询步数失败: \(error.localizedDescription)")
return
}
if let sum = result?.sumQuantity() {
let value = Int(sum.doubleValue(for: .count()))
DispatchQueue.main.async {
self.myTodayStepCount = value
self.saveStepCountValue(value)
WidgetCenter.shared.reloadAllTimelines()
}
let value = Int(result?.sumQuantity()?.doubleValue(for: .count()) ?? 0)
AppGroupConstants.defaults?.set(value, forKey: AppGroupConstants.Key.myTodayStepCount)
DispatchQueue.main.async {
self.myTodayStepCount = value
WatchWidgetReloader.reloadSteps()
}
}
healthStore.execute(query)
}
private func fetchTodayActiveEnergy() {
guard let energyType = HKObjectType.quantityType(forIdentifier: .activeEnergyBurned) else { return }
let startOfDay = Calendar.current.startOfDay(for: Date())
let predicate = HKQuery.predicateForSamples(withStart: startOfDay, end: Date(), options: .strictStartDate)
let query = HKStatisticsQuery(quantityType: energyType,
quantitySamplePredicate: predicate,
options: .cumulativeSum) { [weak self] _, result, error in
... ... @@ -219,36 +245,89 @@ class WatchDataManager: ObservableObject {
print("查询活动能量失败: \(error.localizedDescription)")
return
}
if let sum = result?.sumQuantity() {
let value = sum.doubleValue(for: .kilocalorie())
DispatchQueue.main.async {
self.myTodayActiveEnergy = value
}
let value = result?.sumQuantity()?.doubleValue(for: .kilocalorie()) ?? 0
AppGroupConstants.defaults?.set(value, forKey: AppGroupConstants.Key.myTodayActiveEnergy)
DispatchQueue.main.async {
self.myTodayActiveEnergy = value
WatchWidgetReloader.reloadActivity()
}
}
healthStore.execute(query)
}
private func fetchTodayExerciseTime() {
guard let exerciseType = HKObjectType.quantityType(forIdentifier: .appleExerciseTime) else { return }
let startOfDay = Calendar.current.startOfDay(for: Date())
let predicate = HKQuery.predicateForSamples(withStart: startOfDay, end: Date(), options: .strictStartDate)
let query = HKStatisticsQuery(quantityType: exerciseType,
quantitySamplePredicate: predicate,
options: .cumulativeSum) { [weak self] _, result, error in
guard let self = self else { return }
if let error = error {
print("查询锻炼时间失败: \(error.localizedDescription)")
return
}
let value = result?.sumQuantity()?.doubleValue(for: .minute()) ?? 0
AppGroupConstants.defaults?.set(value, forKey: AppGroupConstants.Key.myTodayExerciseTime)
DispatchQueue.main.async {
self.myTodayExerciseTime = value
WatchWidgetReloader.reloadActivity()
}
}
healthStore.execute(query)
}
private func fetchTodayStandTime() {
let calendar = Calendar.current
var start = calendar.dateComponents([.era, .year, .month, .day], from: Date())
var end = start
start.calendar = calendar
end.calendar = calendar
let predicate = HKQuery.predicate(forActivitySummariesBetweenStart: start, end: end)
let query = HKActivitySummaryQuery(predicate: predicate) { [weak self] _, summaries, error in
guard let self = self else { return }
if let error = error {
print("查询站立小时失败: \(error.localizedDescription)")
return
}
let value = summaries?.last?.appleStandHours.doubleValue(for: .count()) ?? 0
AppGroupConstants.defaults?.set(value, forKey: AppGroupConstants.Key.myTodayStandTime)
DispatchQueue.main.async {
self.myTodayStandTime = value
WatchWidgetReloader.reloadActivity()
}
}
healthStore.execute(query)
}
func fetchLastNightSleepDuration() {
Task {
let calendar = Calendar.current
let today = Date()
// 定义昨天晚上的时间范围(从昨天15:00到今天15:00)
let yesterday = calendar.date(byAdding: .day, value: -1, to: today)!
let searchStartTime = calendar.date(bySettingHour: 15, minute: 0, second: 0, of: yesterday)!
let searchEndTime = calendar.date(bySettingHour: 15, minute: 0, second: 0, of: today)!
guard let sleepType = HKObjectType.categoryType(forIdentifier: .sleepAnalysis) else {
return
}
let predicate = HKQuery.predicateForSamples(withStart: searchStartTime, end: searchEndTime, options: .strictStartDate)
let sortDescriptor = NSSortDescriptor(key: HKSampleSortIdentifierStartDate, ascending: true)
let samples = try? await withCheckedThrowingContinuation { (continuation: CheckedContinuation<[HKCategorySample], Error>) in
let query = HKSampleQuery(
sampleType: sleepType,
... ... @@ -260,26 +339,26 @@ class WatchDataManager: ObservableObject {
continuation.resume(throwing: error)
return
}
let categorySamples = samples?.compactMap { $0 as? HKCategorySample } ?? []
continuation.resume(returning: categorySamples)
}
healthStore.execute(query)
}
guard let samples,
!samples.isEmpty else { return }
// 按开始时间排序
let sortedSamples = samples.sorted { $0.startDate < $1.startDate }
// 转换为睡眠阶段数据
let sleepStages = sortedSamples.compactMap { sample -> SleepStage? in
guard let sleepValue = HKCategoryValueSleepAnalysis(rawValue: sample.value) else {
return nil
}
return SleepStage(
stage: sleepValue,
startTime: sample.startDate,
... ... @@ -287,14 +366,14 @@ class WatchDataManager: ObservableObject {
duration: sample.endDate.timeIntervalSince(sample.startDate)
)
}
guard !sleepStages.isEmpty else { return }
// 计算总体睡眠时间范围
let overallStartTime = sleepStages.first!.startTime
let overallEndTime = sleepStages.last!.endTime
let totalDuration = overallEndTime.timeIntervalSince(overallStartTime)
// 计算实际睡眠时间(排除清醒和在床上但未睡着的时间)
let actualSleepTime = sleepStages
.filter { stage in
... ... @@ -308,10 +387,10 @@ class WatchDataManager: ObservableObject {
}
}
.reduce(0) { $0 + $1.duration }
// 计算睡眠效率
let sleepEfficiency = totalDuration > 0 ? (actualSleepTime / totalDuration) * 100 : nil
let data = SleepData(
startTime: overallStartTime,
endTime: overallEndTime,
... ... @@ -320,22 +399,24 @@ class WatchDataManager: ObservableObject {
totalSleepTime: actualSleepTime,
sleepEfficiency: sleepEfficiency
)
self.myTodaySleepTime = data.totalSleepTime
await MainActor.run {
self.myTodaySleepTime = data.totalSleepTime
}
}
}
// 方法1: 获取最新的单个事件
func fetchLatestIrregularRhythmEvent() {
let irregularRhythmType = HKCategoryType.categoryType(
forIdentifier: .irregularHeartRhythmEvent
)!
let sortDescriptor = NSSortDescriptor(
key: HKSampleSortIdentifierStartDate,
ascending: false // 降序,最新的在前
)
let query = HKSampleQuery(
sampleType: irregularRhythmType,
predicate: nil,
... ... @@ -345,18 +426,18 @@ class WatchDataManager: ObservableObject {
if let error = error {
return
}
guard let sample = samples?.first as? HKCategorySample else {
return
}
self.uploadIrregularRhythm(value: sample.value,
date: sample.startDate)
}
healthStore.execute(query)
}
private func fetchLatestWristTemperature() {
guard let wristTempType = HKObjectType.quantityType(forIdentifier: .appleSleepingWristTemperature) else { return }
... ... @@ -379,7 +460,7 @@ class WatchDataManager: ObservableObject {
healthStore.execute(query)
}
private func fetchLatestRespiratoryRate() {
guard let respiratoryType = HKObjectType.quantityType(forIdentifier: .respiratoryRate) else { return }
... ... @@ -406,114 +487,87 @@ class WatchDataManager: ObservableObject {
extension WatchDataManager {
func observeHealthData() {
guard HKHealthStore.isHealthDataAvailable() else { return }
let hrvType = HKQuantityType.quantityType(forIdentifier: .heartRateVariabilitySDNN)!
// 设置 observer query
let observerQuery = HKObserverQuery(sampleType: hrvType, predicate: nil) { [weak self] _, completionHandler, error in
if let _ = error {
return
}
// 有新数据,启动 Anchored Query 获取
self?.fetchLatestHRV()
completionHandler()
}
healthStore.execute(observerQuery)
healthStore.enableBackgroundDelivery(for: hrvType, frequency: .immediate) { success, error in
}
let stepType = HKQuantityType.quantityType(forIdentifier: .stepCount)!
// 设置 observer query
let stepObserverQuery = HKObserverQuery(sampleType: stepType, predicate: nil) { [weak self] _, completionHandler, error in
if let _ = error {
return
}
// 有新数据,启动 Anchored Query 获取
self?.fetchTodayStepCount()
completionHandler()
WatchHealthObserverUploader.shared.startObserversIfNeeded()
}
private func saveHRVData(_ healthData: WatchHRVData?) {
if let healthData{
do{
let data = try JSONEncoder().encode( healthData)
AppGroupConstants.defaults?.set(data, forKey: AppGroupConstants.Key.latestHealthData)
}catch{}
}else{
AppGroupConstants.defaults?.removeObject(forKey: AppGroupConstants.Key.latestHealthData)
}
healthStore.execute(stepObserverQuery)
healthStore.enableBackgroundDelivery(for: stepType, frequency: .immediate) { success, error in
}
private func saveActivityTarget(_ target: ActivityTarget?) {
if let target{
do{
let data = try JSONEncoder().encode( target)
AppGroupConstants.defaults?.set(data, forKey: AppGroupConstants.Key.acitivyTarget)
}catch{}
}else{
AppGroupConstants.defaults?.removeObject(forKey: AppGroupConstants.Key.acitivyTarget)
}
//房颤
let irregularRhythmType = HKCategoryType.categoryType(
forIdentifier: .irregularHeartRhythmEvent
)!
// 设置 observer query
let irregularRhythmObserverQuery = HKObserverQuery(sampleType: irregularRhythmType, predicate: nil) { [weak self] _, completionHandler, error in
if let _ = error {
}
}
extension WatchDataManager {
func uploadTodayActivityTarget() {
let calendar = Calendar.current
var start = calendar.dateComponents([.era, .year, .month, .day], from: Date())
var end = start
start.calendar = calendar
end.calendar = calendar
let predicate = HKQuery.predicate(forActivitySummariesBetweenStart: start, end: end)
let query = HKActivitySummaryQuery(predicate: predicate) { [weak self] _, summaries, error in
guard let self else { return }
if let error {
print("查询活动目标失败: \(error.localizedDescription)")
return
}
// 有新数据,启动 Anchored Query 获取
self?.fetchLatestIrregularRhythmEvent()
completionHandler()
}
healthStore.execute(irregularRhythmObserverQuery)
healthStore.enableBackgroundDelivery(for: irregularRhythmType, frequency: .immediate) { success, error in
}
//睡眠手腕温度
let wristTempType = HKQuantityType.quantityType(forIdentifier: .appleSleepingWristTemperature)!
let wristTempObserverQuery = HKObserverQuery(sampleType: wristTempType, predicate: nil) { [weak self] _, completionHandler, error in
if let _ = error {
guard let summary = summaries?.last else {
print("查询活动目标为空")
return
}
self?.fetchLatestWristTemperature()
completionHandler()
let body = Self.activityTargetUploadBody(from: summary)
self.uploadActivityTarget(body: body)
}
healthStore.execute(wristTempObserverQuery)
healthStore.enableBackgroundDelivery(for: wristTempType, frequency: .immediate) { success, error in
healthStore.execute(query)
}
}
//呼吸频率
let respiratoryType = HKQuantityType.quantityType(forIdentifier: .respiratoryRate)!
nonisolated private static func activityTargetUploadBody(from summary: HKActivitySummary) -> [String: Any] {
var body: [String: Any] = [
"move": Int(summary.activeEnergyBurnedGoal.doubleValue(for: .kilocalorie())),
"stand": Int(summary.appleStandHoursGoal.doubleValue(for: .count())),
"active_energy_burned": summary.activeEnergyBurned.doubleValue(for: .kilocalorie()),
"active_energy_burned_goal": summary.activeEnergyBurnedGoal.doubleValue(for: .kilocalorie()),
"apple_exercise_time": summary.appleExerciseTime.doubleValue(for: .minute()),
"apple_exercise_time_goal": summary.appleExerciseTimeGoal.doubleValue(for: .minute()),
"apple_stand_hours": summary.appleStandHours.doubleValue(for: .count()),
"apple_stand_hours_goal": summary.appleStandHoursGoal.doubleValue(for: .count())
]
let respiratoryObserverQuery = HKObserverQuery(sampleType: respiratoryType, predicate: nil) { [weak self] _, completionHandler, error in
if let _ = error {
return
}
self?.fetchLatestRespiratoryRate()
completionHandler()
if #available(watchOS 7.0, *) {
body["activity_move_mode"] = summary.activityMoveMode.rawValue
body["apple_move_time"] = summary.appleMoveTime.doubleValue(for: .minute())
body["apple_move_time_goal"] = summary.appleMoveTimeGoal.doubleValue(for: .minute())
}
healthStore.execute(respiratoryObserverQuery)
healthStore.enableBackgroundDelivery(for: respiratoryType, frequency: .immediate) { success, error in
if #available(watchOS 9.0, *) {
body["exercise_time_goal"] = summary.exerciseTimeGoal?.doubleValue(for: .minute())
body["stand_hours_goal"] = summary.standHoursGoal?.doubleValue(for: .count())
}
}
private func saveHRVValue(_ value: Double) {
let defaults = AppGroupConstants.defaults
defaults?.set(value, forKey: AppGroupConstants.Key.latestHRV)
}
private func saveHRVBaselineValue(_ value: Double?) {
let defaults = AppGroupConstants.defaults
defaults?.set(value, forKey: AppGroupConstants.Key.latestHRVBaseline)
}
private func saveStepCountValue(_ value: Int) {
let defaults = AppGroupConstants.defaults
defaults?.set(value, forKey: AppGroupConstants.Key.latestStepCount)
return body
}
}
extension WatchDataManager {
private func uploadHRV(value: Double, date: Date) {
nonisolated private func uploadHRV(value: Double, date: Date) {
Task {
let _ = try? await HealthService().upload(data: [
"data_type": HealthDataType.hrv.rawValue,
... ... @@ -522,8 +576,8 @@ extension WatchDataManager {
])
}
}
private func uploadIrregularRhythm(value: Int, date: Date) {
nonisolated private func uploadIrregularRhythm(value: Int, date: Date) {
Task {
let _ = try? await HealthService().upload(data: [
"data_type": HealthDataType.irregularHeartRhythm.rawValue,
... ... @@ -532,8 +586,8 @@ extension WatchDataManager {
])
}
}
private func uploadSleepWristTemperature(value: Double, date: Date) {
nonisolated private func uploadSleepWristTemperature(value: Double, date: Date) {
Task {
let _ = try? await HealthService().upload(data: [
"data_type": HealthDataType.sleepingWristTemperature.rawValue,
... ... @@ -542,8 +596,8 @@ extension WatchDataManager {
])
}
}
private func uploadRespiratoryRate(value: Double, date: Date) {
nonisolated private func uploadRespiratoryRate(value: Double, date: Date) {
Task {
let _ = try? await HealthService().upload(data: [
"data_type": HealthDataType.respiratoryRate.rawValue,
... ... @@ -552,23 +606,85 @@ extension WatchDataManager {
])
}
}
nonisolated private func uploadActivityTarget(body: [String: Any]) {
Task {
#if DEBUG
let debugBody = body
.map { "\($0.key)=\($0.value)" }
.sorted()
.joined(separator: ", ")
print("watch upload activity target: {\(debugBody)}")
#endif
let _ = try? await HealthService().uploadActivityTarget(data: body)
}
}
}
extension WatchDataManager {
func fetchOtherTodayData() {
func getAcitivtyTarget(){
Task{
let result = try? await HealthService().getActivityTarget()
await MainActor.run {
self.activityTarget = result
self.saveActivityTarget(result)
WatchWidgetReloader.reloadActivityTarget()
}
}
}
func getLatestHealthData(){
Task {
let result = try? await HealthService().getTodayStatusInfo()
let result = try? await HealthService().getHealthData(type: WatchUserinfoManager.share.displayType)
await MainActor.run {
self.otherTodayHealthInfo = result
self.latestHRVData = result
self.saveHRVData(result)
WatchWidgetReloader.reloadHRV()
}
}
}
func getMyLatestData(){
Task{
let result = try? await HealthService().getMyData()
await MainActor.run {
self.myData = result
}
}
}
func getFriendLatestData(){
Task{
let result = try? await HealthService().getFriendData()
await MainActor.run {
self.friendData = result
}
}
}
func fetchMyTodayData() {
fetchTodayAvgHRV()
// fetchTodayAvgHRV()
fetchTodayLatestHeartRate()
fetchTodayStepCount()
fetchTodayActiveEnergy()
fetchTodayExerciseTime()
fetchTodayStandTime()
fetchLastNightSleepDuration()
WatchHealthWidgetRefreshCoordinator.shared.refreshActivityTargetData()
}
func refreshHeartRateWidgetData() {
fetchTodayLatestHeartRate()
}
func refreshStepWidgetData() {
fetchTodayStepCount()
}
func refreshActivityWidgetData() {
fetchTodayActiveEnergy()
fetchTodayExerciseTime()
fetchTodayStandTime()
}
}
... ...
import Foundation
import HealthKit
enum WatchHealthUploadConfiguration {
/// Process-lifetime throttle. Each health data type can start at most one
/// upload request during this interval.
static let minimumTriggerInterval: TimeInterval = 60
}
private actor WatchHealthUploadQueue {
func run(_ operation: @escaping () async throws -> Void) async throws {
try await operation()
}
}
private actor WatchHealthUploadThrottle {
private var lastTriggerDates: [Int: Date] = [:]
func acquire(dataType: Int, now: Date = Date()) -> Bool {
if let lastDate = lastTriggerDates[dataType],
now.timeIntervalSince(lastDate) < WatchHealthUploadConfiguration.minimumTriggerInterval {
return false
}
lastTriggerDates[dataType] = now
return true
}
}
/// Owns Watch HealthKit observers and keeps the observer completion alive until
/// the corresponding server upload has finished.
final class WatchHealthObserverUploader {
static let shared = WatchHealthObserverUploader()
private let healthStore = HKHealthStore()
private let healthService = HealthService()
private var observers: [HKObserverQuery] = []
private var uploadHandlers: [() async throws -> Void] = []
private let uploadQueue = WatchHealthUploadQueue()
private var uploadTimeCache: WatchHealthUploadTimeList?
private var uploadTimeCacheDate: Date?
private var uploadTimeCacheUserId: Int?
private var hasStarted = false
private let uploadThrottle = WatchHealthUploadThrottle()
private init() {}
@MainActor
func startObserversIfNeeded() {
guard HKHealthStore.isHealthDataAvailable() else { return }
guard !hasStarted else { return }
hasStarted = true
registerQuantity(.heartRate, dataType: .heartRate,
unit: HKUnit.count().unitDivided(by: .minute()))
registerQuantity(.heartRateVariabilitySDNN, dataType: .hrv,
unit: .secondUnit(with: .milli))
registerDailyCumulative(.stepCount, dataType: .steps, unit: .count())
registerQuantity(.oxygenSaturation, dataType: .spo2, unit: .percent(), multiplier: 100)
registerDailyCumulative(.activeEnergyBurned, dataType: .move, unit: .kilocalorie(), uploadsTarget: true)
registerDailyCumulative(.appleExerciseTime, dataType: .exercise, unit: .second(), uploadsTarget: true)
registerStand()
registerQuantity(.walkingHeartRateAverage, dataType: .walkingHeartRate,
unit: HKUnit.count().unitDivided(by: .minute()))
registerQuantity(.restingHeartRate, dataType: .restingHeartRate,
unit: HKUnit.count().unitDivided(by: .minute()))
registerQuantity(.appleSleepingWristTemperature, dataType: .sleepingWristTemperature,
unit: .degreeCelsius())
registerQuantity(.respiratoryRate, dataType: .respiratoryRate,
unit: HKUnit.count().unitDivided(by: .minute()))
registerIrregularRhythm()
registerSleep()
}
func performFullUpload() {
Task { await uploadPendingChanges() }
}
private func registerQuantity(
_ identifier: HKQuantityTypeIdentifier,
dataType: HealthDataType,
unit: HKUnit,
multiplier: Double = 1
) {
guard let sampleType = HKObjectType.quantityType(forIdentifier: identifier) else { return }
register(sampleType, widgetDataType: dataType) { [weak self] in
guard let self else { return }
try await self.processQuantityChanges(type: sampleType, dataType: dataType.rawValue) { samples in
samples.forEach { sample in
self.debugLogCommonUpload(
dataType: dataType.rawValue,
time: sample.endDate,
value: sample.quantity.doubleValue(for: unit) * multiplier
)
}
let body = samples.map {
[
"data_type": dataType.rawValue,
"time": $0.endDate.timeIntervalSince1970,
"value": $0.quantity.doubleValue(for: unit) * multiplier,
] as [String: Any]
}
try await self.uploadCommonBatches(body)
}
await MainActor.run {
switch dataType {
case .hrv:
WatchDataManager.share.getLatestHealthData()
default:
break
}
}
}
}
private func registerDailyCumulative(
_ identifier: HKQuantityTypeIdentifier,
dataType: HealthDataType,
unit: HKUnit,
uploadsTarget: Bool = false
) {
guard let sampleType = HKObjectType.quantityType(forIdentifier: identifier) else { return }
register(sampleType, widgetDataType: dataType) { [weak self] in
guard let self else { return }
let days = try await self.cumulativeUploadDays(
type: sampleType,
dataType: dataType.rawValue
)
var body: [[String: Any]] = []
var uploadLogs: [(time: Date, value: Double)] = []
for day in days {
let value = try await self.dailyCumulative(type: sampleType, unit: unit, day: day.day)
uploadLogs.append((day.latestSampleTime, value))
body.append([
"data_type": dataType.rawValue,
"time": day.latestSampleTime.timeIntervalSince1970,
"value": value,
])
}
guard !body.isEmpty,
await self.allowUploadTrigger(for: dataType.rawValue) else { return }
uploadLogs.forEach {
self.debugLogCommonUpload(dataType: dataType.rawValue, time: $0.time, value: $0.value)
}
try await self.uploadCommonBatches(body)
if uploadsTarget {
try await self.uploadTodayActivityTarget()
}
}
}
private func registerStand() {
guard let type = HKObjectType.quantityType(forIdentifier: .appleStandTime) else { return }
register(type, widgetDataType: .stand) { [weak self] in
guard let self else { return }
let days = try await self.cumulativeUploadDays(
type: type,
dataType: HealthDataType.stand.rawValue
)
var body: [[String: Any]] = []
var uploadLogs: [(time: Date, value: Double)] = []
for day in days {
guard let summary = try await self.activitySummary(for: day.day) else { continue }
let value = summary.appleStandHours.doubleValue(for: .count())
uploadLogs.append((day.latestSampleTime, value))
body.append([
"data_type": HealthDataType.stand.rawValue,
"time": day.latestSampleTime.timeIntervalSince1970,
"value": value,
])
}
guard !body.isEmpty,
await self.allowUploadTrigger(for: HealthDataType.stand.rawValue) else { return }
uploadLogs.forEach {
self.debugLogCommonUpload(
dataType: HealthDataType.stand.rawValue,
time: $0.time,
value: $0.value
)
}
try await self.uploadCommonBatches(body)
try await self.uploadTodayActivityTarget()
}
}
private func registerIrregularRhythm() {
guard let type = HKObjectType.categoryType(forIdentifier: .irregularHeartRhythmEvent) else { return }
register(type) { [weak self] in
guard let self else { return }
let dataType = HealthDataType.irregularHeartRhythm.rawValue
try await self.processCategoryChanges(type: type, dataType: dataType) { samples in
samples.forEach { sample in
self.debugLogCommonUpload(
dataType: dataType,
time: sample.endDate,
value: Double(sample.value)
)
}
let body = samples.map {
[
"data_type": HealthDataType.irregularHeartRhythm.rawValue,
"time": $0.endDate.timeIntervalSince1970,
"value": $0.value,
] as [String: Any]
}
try await self.uploadCommonBatches(body)
}
}
}
private func registerSleep() {
guard let type = HKObjectType.categoryType(forIdentifier: .sleepAnalysis) else { return }
register(type) { [weak self] in
guard let self else { return }
try await self.processCategoryChanges(type: type, dataType: 100) { samples in
samples.forEach { sample in
self.debugLogSleepUpload(sample)
}
let body = samples.map {
[
"data_type": $0.value,
"from_time": $0.startDate.timeIntervalSince1970,
"to_time": $0.endDate.timeIntervalSince1970,
] as [String: Any]
}
try await self.uploadSleepBatches(body)
try await self.uploadSleepingHeartRates(for: samples)
}
await MainActor.run { WatchDataManager.share.fetchMyTodayData() }
}
}
private func register(
_ sampleType: HKSampleType,
widgetDataType: HealthDataType? = nil,
handler: @escaping () async throws -> Void
) {
uploadHandlers.append(handler)
let query = HKObserverQuery(sampleType: sampleType, predicate: nil) { _, completion, error in
guard error == nil else {
#if DEBUG
DebugLogger.debugLog("[WatchHealthObserver] \(sampleType.identifier) observer error: \(error!.localizedDescription)")
#endif
completion()
return
}
Task {
if let widgetDataType {
await MainActor.run {
WatchHealthWidgetRefreshCoordinator.shared.refresh(for: widgetDataType)
}
}
guard self.hasValidLogin else {
#if DEBUG
DebugLogger.debugLog("[WatchHealthObserver] \(sampleType.identifier) widget refreshed; upload skipped because user is not logged in")
#endif
completion()
return
}
do {
try await self.uploadQueue.run(handler)
#if DEBUG
DebugLogger.debugLog("[WatchHealthObserver] \(sampleType.identifier) upload completed")
#endif
} catch {
DebugLogger.debugLog("[WatchHealthObserver] \(sampleType.identifier) upload failed: \(error.localizedDescription)")
}
completion()
}
}
observers.append(query)
healthStore.execute(query)
healthStore.enableBackgroundDelivery(for: sampleType, frequency: .immediate) { success, error in
if let error {
DebugLogger.debugLog("[WatchHealthObserver] \(sampleType.identifier) background delivery failed: \(error.localizedDescription)")
} else if !success {
DebugLogger.debugLog("[WatchHealthObserver] \(sampleType.identifier) background delivery was not enabled")
}
}
}
private func uploadPendingChanges() async {
guard hasValidLogin else {
#if DEBUG
DebugLogger.debugLog("[WatchHealthObserver] pending upload skipped, user is not logged in")
#endif
return
}
for handler in uploadHandlers {
do {
try await uploadQueue.run(handler)
} catch {
DebugLogger.debugLog("[WatchHealthObserver] pending upload failed: \(error.localizedDescription)")
}
}
}
private func processQuantityChanges(
type: HKQuantityType,
dataType: Int,
upload: @escaping ([HKQuantitySample]) async throws -> Void
) async throws {
let startDate = try await serverUploadStartDate(for: dataType)
let predicate = uploadPredicate(startDate: startDate)
// The anchor is intentionally in-memory only and is used solely to
// page this query. The next upload boundary always comes from server.
var anchor: HKQueryAnchor?
var acquiredTrigger = false
while true {
let page: ([HKQuantitySample], HKQueryAnchor) = try await anchoredPage(
type: type,
predicate: predicate,
anchor: anchor
)
let newSamples = page.0.filter { $0.endDate > startDate }
debugLogTimeComparison(
dataType: dataType,
serverTime: startDate,
newestTime: newSamples.map(\.endDate).max()
)
if !newSamples.isEmpty {
if !acquiredTrigger {
guard await allowUploadTrigger(for: dataType) else { return }
acquiredTrigger = true
}
try await upload(newSamples)
}
anchor = page.1
if page.0.count < 200 { return }
}
}
private struct CumulativeUploadDay {
let day: Date
let latestSampleTime: Date
}
private func cumulativeUploadDays(
type: HKQuantityType,
dataType: Int
) async throws -> [CumulativeUploadDay] {
let calendar = Calendar.current
let serverStartDate = try await serverUploadStartDate(for: dataType)
let queryStartDate = calendar.startOfDay(for: serverStartDate)
let predicate = uploadPredicate(startDate: queryStartDate)
var latestSampleTimeByDay: [Date: Date] = [:]
var anchor: HKQueryAnchor?
while true {
let page: ([HKQuantitySample], HKQueryAnchor) = try await anchoredPage(
type: type,
predicate: predicate,
anchor: anchor
)
for sample in page.0 where sample.endDate > serverStartDate {
let day = calendar.startOfDay(for: sample.startDate)
latestSampleTimeByDay[day] = max(
latestSampleTimeByDay[day] ?? .distantPast,
sample.endDate
)
}
anchor = page.1
if page.0.count < 200 { break }
}
// Upload chronologically. If a later batch fails, the server cursor
// remains on the last successful historical day instead of jumping to
// today and permanently skipping the remaining backlog.
let result = latestSampleTimeByDay
.map { CumulativeUploadDay(day: $0.key, latestSampleTime: $0.value) }
.sorted { $0.latestSampleTime < $1.latestSampleTime }
debugLogTimeComparison(
dataType: dataType,
serverTime: serverStartDate,
newestTime: result.map(\.latestSampleTime).max()
)
return result
}
private func processCategoryChanges(
type: HKCategoryType,
dataType: Int,
upload: @escaping ([HKCategorySample]) async throws -> Void
) async throws {
let startDate = try await serverUploadStartDate(for: dataType)
let predicate = uploadPredicate(startDate: startDate)
// The anchor is intentionally in-memory only and is used solely to
// page this query. The next upload boundary always comes from server.
var anchor: HKQueryAnchor?
var acquiredTrigger = false
while true {
let page: ([HKCategorySample], HKQueryAnchor) = try await anchoredPage(
type: type,
predicate: predicate,
anchor: anchor
)
let newSamples = page.0.filter { $0.endDate > startDate }
debugLogTimeComparison(
dataType: dataType,
serverTime: startDate,
newestTime: newSamples.map(\.endDate).max()
)
if !newSamples.isEmpty {
if !acquiredTrigger {
guard await allowUploadTrigger(for: dataType) else { return }
acquiredTrigger = true
}
try await upload(newSamples)
}
anchor = page.1
if page.0.count < 200 { return }
}
}
private func anchoredPage<T: HKSample>(
type: HKSampleType,
predicate: NSPredicate,
anchor: HKQueryAnchor?
) async throws -> ([T], HKQueryAnchor) {
try await withCheckedThrowingContinuation { continuation in
let query = HKAnchoredObjectQuery(
type: type,
predicate: predicate,
anchor: anchor,
limit: 200
) { _, samples, _, newAnchor, error in
if let error { continuation.resume(throwing: error) }
else if let newAnchor {
continuation.resume(returning: (samples as? [T] ?? [], newAnchor))
} else {
continuation.resume(throwing: WatchHealthObserverError.missingAnchor)
}
}
healthStore.execute(query)
}
}
private func dailyCumulative(type: HKQuantityType, unit: HKUnit, day: Date) async throws -> Double {
let calendar = Calendar.current
let start = calendar.startOfDay(for: day)
let nextDay = calendar.date(byAdding: .day, value: 1, to: start) ?? Date()
let end = min(nextDay, Date())
let predicate = HKQuery.predicateForSamples(
withStart: start, end: end, options: .strictStartDate
)
return try await withCheckedThrowingContinuation { continuation in
let query = HKStatisticsQuery(quantityType: type, quantitySamplePredicate: predicate, options: .cumulativeSum) {
_, result, error in
if let error { continuation.resume(throwing: error) }
else { continuation.resume(returning: result?.sumQuantity()?.doubleValue(for: unit) ?? 0) }
}
healthStore.execute(query)
}
}
private func todayActivitySummary() async throws -> HKActivitySummary? {
try await activitySummary(for: Date())
}
private func activitySummary(for date: Date) async throws -> HKActivitySummary? {
let calendar = Calendar.current
var day = calendar.dateComponents([.era, .year, .month, .day], from: date)
day.calendar = calendar
let predicate = HKQuery.predicate(forActivitySummariesBetweenStart: day, end: day)
return try await withCheckedThrowingContinuation { continuation in
let query = HKActivitySummaryQuery(predicate: predicate) { _, summaries, error in
if let error { continuation.resume(throwing: error) }
else { continuation.resume(returning: summaries?.last) }
}
healthStore.execute(query)
}
}
private func uploadTodayActivityTarget() async throws {
guard let summary = try await todayActivitySummary() else { return }
try await uploadActivityTarget(summary)
}
private func uploadSleepingHeartRates(for sleepSamples: [HKCategorySample]) async throws {
let dataType = HealthDataType.sleepingHeartRate.rawValue
let serverStartDate = try await serverUploadStartDate(for: dataType)
let asleepIntervals = sleepSamples.filter {
guard let value = HKCategoryValueSleepAnalysis(rawValue: $0.value) else { return false }
switch value {
case .asleepUnspecified, .asleepCore, .asleepDeep, .asleepREM:
return true
default:
return false
}
}
guard let start = asleepIntervals.map(\.startDate).min(),
let end = asleepIntervals.map(\.endDate).max(),
let heartRateType = HKObjectType.quantityType(forIdentifier: .heartRate) else {
return
}
let predicate = HKQuery.predicateForSamples(withStart: start, end: end, options: [])
let heartRates: [HKQuantitySample] = try await withCheckedThrowingContinuation { continuation in
let query = HKSampleQuery(
sampleType: heartRateType,
predicate: predicate,
limit: HKObjectQueryNoLimit,
sortDescriptors: nil
) { _, samples, error in
if let error { continuation.resume(throwing: error) }
else { continuation.resume(returning: samples as? [HKQuantitySample] ?? []) }
}
healthStore.execute(query)
}
let sleepingHeartRates = heartRates.filter { sample in
sample.endDate > serverStartDate && asleepIntervals.contains { interval in
sample.startDate < interval.endDate && sample.endDate > interval.startDate
}
}.sorted { $0.endDate < $1.endDate }
guard !sleepingHeartRates.isEmpty else { return }
guard await allowUploadTrigger(for: dataType) else { return }
let unit = HKUnit.count().unitDivided(by: .minute())
let body = sleepingHeartRates.map {
[
"data_type": HealthDataType.sleepingHeartRate.rawValue,
"time": $0.endDate.timeIntervalSince1970,
"value": $0.quantity.doubleValue(for: unit),
] as [String: Any]
}
sleepingHeartRates.forEach { sample in
debugLogCommonUpload(
dataType: dataType,
time: sample.endDate,
value: sample.quantity.doubleValue(for: unit)
)
}
try await uploadCommonBatches(body)
}
private func uploadActivityTarget(_ summary: HKActivitySummary) async throws {
var body: [String: Any] = [
"move": Int(summary.activeEnergyBurnedGoal.doubleValue(for: .kilocalorie())),
"stand": Int(summary.appleStandHoursGoal.doubleValue(for: .count())),
"active_energy_burned": summary.activeEnergyBurned.doubleValue(for: .kilocalorie()),
"active_energy_burned_goal": summary.activeEnergyBurnedGoal.doubleValue(for: .kilocalorie()),
"apple_exercise_time": summary.appleExerciseTime.doubleValue(for: .second()),
"apple_exercise_time_goal": summary.appleExerciseTimeGoal.doubleValue(for: .second()),
"apple_stand_hours": summary.appleStandHours.doubleValue(for: .count()),
"apple_stand_hours_goal": summary.appleStandHoursGoal.doubleValue(for: .count()),
]
body["activity_move_mode"] = summary.activityMoveMode.rawValue
body["apple_move_time"] = summary.appleMoveTime.doubleValue(for: .second())
body["apple_move_time_goal"] = summary.appleMoveTimeGoal.doubleValue(for: .second())
body["exercise_time_goal"] = summary.exerciseTimeGoal?.doubleValue(for: .second())
body["stand_hours_goal"] = summary.standHoursGoal?.doubleValue(for: .count())
_ = try await healthService.uploadActivityTarget(data: body)
}
private func serverUploadStartDate(for dataType: Int) async throws -> Date {
let list = try await serverUploadTimes()
if let date = list.latestDate(for: dataType) {
#if DEBUG
DebugLogger.debugLog("[WatchHealthUpload][server-time] dataType=\(dataType), time=\(debugTimestamp(date)), unix=\(Int64(date.timeIntervalSince1970))")
#endif
return date
}
let fallback = Calendar.current.date(byAdding: .year, value: -2, to: Date())
?? Date(timeIntervalSinceNow: -2 * 365 * 24 * 60 * 60)
let date = Calendar.current.startOfDay(for: fallback)
#if DEBUG
DebugLogger.debugLog("[WatchHealthUpload][server-time] dataType=\(dataType), missing=true, fallback=\(debugTimestamp(date)), unix=\(Int64(date.timeIntervalSince1970))")
#endif
return date
}
private func serverUploadTimes() async throws -> WatchHealthUploadTimeList {
let userId = currentUserId
if let uploadTimeCache,
let uploadTimeCacheDate,
uploadTimeCacheUserId == userId,
Date().timeIntervalSince(uploadTimeCacheDate) < 5 {
return uploadTimeCache
}
let result = try await healthService.getUploadTimes()
uploadTimeCache = result
uploadTimeCacheDate = Date()
uploadTimeCacheUserId = userId
return result
}
private func uploadPredicate(startDate: Date) -> NSPredicate {
HKQuery.predicateForSamples(withStart: startDate, end: Date(), options: [])
}
private func uploadCommonBatches(_ data: [[String: Any]]) async throws {
for start in stride(from: 0, to: data.count, by: 200) {
_ = try await healthService.uploadBatch(data: Array(data[start..<min(start + 200, data.count)]))
}
}
private func allowUploadTrigger(for dataType: Int) async -> Bool {
let allowed = await uploadThrottle.acquire(dataType: dataType)
#if DEBUG
if !allowed {
DebugLogger.debugLog(
"[WatchHealthUpload][throttle] dataType=\(dataType), skipped=true, interval=\(Int(WatchHealthUploadConfiguration.minimumTriggerInterval))s, now=\(debugTimestamp(Date()))"
)
}
#endif
return allowed
}
private func uploadSleepBatches(_ data: [[String: Any]]) async throws {
for start in stride(from: 0, to: data.count, by: 200) {
_ = try await healthService.uploadSleep(data: Array(data[start..<min(start + 200, data.count)]))
}
}
private static let debugDateFormatter: DateFormatter = {
let formatter = DateFormatter()
formatter.locale = Locale(identifier: "en_US_POSIX")
formatter.dateFormat = "yyyy-MM-dd HH:mm:ss"
return formatter
}()
private static let debugDateFormatterLock = NSLock()
private func debugTimestamp(_ date: Date) -> String {
Self.debugDateFormatterLock.lock()
defer { Self.debugDateFormatterLock.unlock() }
return Self.debugDateFormatter.string(from: date)
}
private func debugLogTimeComparison(
dataType: Int,
serverTime: Date,
newestTime: Date?
) {
#if DEBUG || CI_ENV
let newestDescription = newestTime.map {
"\(debugTimestamp($0)) (unix=\(Int64($0.timeIntervalSince1970)))"
} ?? "<none>"
DebugLogger.debugLog(
"[WatchHealthUpload][time-check] dataType=\(dataType), server=\(debugTimestamp(serverTime)) (unix=\(Int64(serverTime.timeIntervalSince1970))), newest=\(newestDescription), shouldUpload=\(newestTime.map { $0 > serverTime } ?? false)"
)
#endif
}
private func debugLogCommonUpload(dataType: Int, time: Date, value: Double) {
#if DEBUG || CI_ENV
DebugLogger.debugLog(
"[WatchHealthUpload][upload] dataType=\(dataType), time=\(debugTimestamp(time)), unix=\(Int64(time.timeIntervalSince1970)), value=\(value)"
)
#endif
}
private func debugLogSleepUpload(_ sample: HKCategorySample) {
#if DEBUG || CI_ENV
DebugLogger.debugLog(
"[WatchHealthUpload][upload][sleep] dataType=\(sample.value), from=\(debugTimestamp(sample.startDate)), fromUnix=\(Int64(sample.startDate.timeIntervalSince1970)), to=\(debugTimestamp(sample.endDate)), toUnix=\(Int64(sample.endDate.timeIntervalSince1970))"
)
#endif
}
private var hasValidLogin: Bool {
guard let data = AppGroupConstants.defaults?.data(forKey: AppGroupConstants.Key.myUserInfo),
let login = try? JSONDecoder().decode(UserInfoModel.self, from: data),
let token = login.token else {
return false
}
return !token.isEmpty
}
private var currentUserId: Int {
guard let data = AppGroupConstants.defaults?.data(forKey: AppGroupConstants.Key.myUserInfo),
let login = try? JSONDecoder().decode(UserInfoModel.self, from: data) else {
return 0
}
return login.userId ?? 0
}
}
private enum WatchHealthObserverError: Error {
case missingAnchor
}
... ...
import Foundation
import HealthKit
/// Bridges HealthKit observer events to the three HealthKit-backed widgets.
/// This path is intentionally independent of login state and server uploads.
@MainActor
final class WatchHealthWidgetRefreshCoordinator {
static let shared = WatchHealthWidgetRefreshCoordinator()
private let healthStore = HKHealthStore()
private let defaultStepGoal = 10_000
private init() {}
func refresh(for dataType: HealthDataType) {
switch dataType {
case .heartRate:
WatchDataManager.share.refreshHeartRateWidgetData()
WatchDataManager.share.getMyLatestData()
case .steps:
WatchDataManager.share.refreshStepWidgetData()
refreshLocalActivityTarget()
case .move, .exercise, .stand:
WatchDataManager.share.refreshActivityWidgetData()
refreshLocalActivityTarget()
default:
break
}
}
func refreshAll() {
WatchDataManager.share.getMyLatestData()
WatchDataManager.share.refreshHeartRateWidgetData()
WatchDataManager.share.refreshStepWidgetData()
WatchDataManager.share.refreshActivityWidgetData()
refreshLocalActivityTarget()
}
func refreshActivityTargetData() {
refreshLocalActivityTarget()
}
private func refreshLocalActivityTarget() {
let defaultStepGoal = defaultStepGoal
let calendar = Calendar.current
var start = calendar.dateComponents([.era, .year, .month, .day], from: Date())
var end = start
start.calendar = calendar
end.calendar = calendar
let predicate = HKQuery.predicate(forActivitySummariesBetweenStart: start, end: end)
let query = HKActivitySummaryQuery(predicate: predicate) { _, summaries, error in
guard error == nil else { return }
guard let summary = summaries?.last else {
AppGroupConstants.defaults?.removeObject(
forKey: AppGroupConstants.Key.localHealthActivityTarget
)
WatchWidgetReloader.reloadActivityTarget()
return
}
let target = ActivityTarget(
id: nil,
create_time: nil,
update_time: nil,
user_id: nil,
move: Int(summary.activeEnergyBurnedGoal.doubleValue(for: .kilocalorie())),
step: defaultStepGoal,
stand: Int(summary.appleStandHoursGoal.doubleValue(for: .count())),
exercise: Int(summary.appleExerciseTimeGoal.doubleValue(for: .minute())),
activity_move_mode: summary.activityMoveMode.rawValue,
active_energy_burned: summary.activeEnergyBurned.doubleValue(for: .kilocalorie()),
active_energy_burned_goal: summary.activeEnergyBurnedGoal.doubleValue(for: .kilocalorie()),
apple_move_time: summary.appleMoveTime.doubleValue(for: .minute()),
apple_move_time_goal: summary.appleMoveTimeGoal.doubleValue(for: .minute()),
apple_exercise_time: summary.appleExerciseTime.doubleValue(for: .minute()),
exercise_time_goal: summary.exerciseTimeGoal?.doubleValue(for: .minute()),
apple_stand_hours: summary.appleStandHours.doubleValue(for: .count()),
stand_hours_goal: summary.standHoursGoal?.doubleValue(for: .count())
)
guard let data = try? JSONEncoder().encode(target) else { return }
AppGroupConstants.defaults?.set(data, forKey: AppGroupConstants.Key.localHealthActivityTarget)
WatchWidgetReloader.reloadActivityTarget()
}
healthStore.execute(query)
}
}
... ...
... ... @@ -11,85 +11,177 @@ import Combine
import ClockKit
class WatchSessionManager: NSObject, WCSessionDelegate, ObservableObject {
static let share = WatchSessionManager()
override init() {
super.init()
}
func addListen() {
if WCSession.isSupported() {
WCSession.default.delegate = self
WCSession.default.activate()
if WCSession.default.activationState == .notActivated {
WCSession.default.activate()
} else if WCSession.default.activationState == .activated {
requestLoginStateFromPhone()
}
}
}
func session(_ session: WCSession, didReceiveMessage message: [String : Any], replyHandler: @escaping ([String : Any]) -> Void) {
print("message-replyHandler: \(message)")
if let command = message["command"] as? String {
if command == "watchThemeChanged" {
NotificationCenter.default.post(name: NotificationType.watchThemeChanged, object: nil)
private func handleLoginData(_ data: Data) {
do {
let model = try JSONDecoder().decode(UserInfoModel.self, from: data)
Task { @MainActor in
applyLoginInfo(model, data: data)
}
} catch {
print("decode watch login info failed: \(error.localizedDescription)")
}
}
func session(_ session: WCSession, didReceiveUserInfo userInfo: [String : Any] = [:]) {
DispatchQueue.main.async {
print("userInfo: \(userInfo)")
if let envValue = userInfo["env"] as? Int,
let env = NetworkEnvironment(rawValue: envValue) {
NetworkEnvironment.current = env
@MainActor
private func applyLoginInfo(_ model: UserInfoModel, data: Data? = nil) {
guard model.token?.isEmpty == false else {
applyLogout()
return
}
let storeData = data ?? (try? JSONEncoder().encode(model))
if let storeData {
AppGroupConstants.defaults?.set(storeData, forKey: AppGroupConstants.Key.myUserInfo)
}
guard model != WatchUserinfoManager.share.myUserInfo else {
return
}
WatchUserinfoManager.share.login(userInfo: model)
WatchDataManager.share.fetchMyTodayData()
WatchDataManager.share.fetchCoupleLatestHRV()
WatchHealthObserverUploader.shared.performFullUpload()
}
@MainActor
private func applyLogout() {
WatchUserinfoManager.share.logout()
WatchDataManager.share.logout()
}
private func requestLoginStateFromPhone() {
guard WCSession.default.isReachable else { return }
WCSession.default.sendMessage(
["command": AppGroupMessageKey.requestLoginState],
replyHandler: { [weak self] reply in
self?.handleLoginStatePayload(reply)
},
errorHandler: { error in
print("❌ Failed to request login state: \(error.localizedDescription)")
}
)
}
private func handleLoginStatePayload(_ payload: [String: Any]) {
if let command = payload["command"] as? String,
command == AppGroupMessageKey.logout {
Task { @MainActor in
self.applyLogout()
}
return
}
if let json = payload["json"] as? String,
let data = json.data(using: .utf8) {
handleLoginData(data)
return
}
do {
let data = try JSONSerialization.data(withJSONObject: payload)
handleLoginData(data)
} catch {
print("serialize watch login state failed: \(error.localizedDescription)")
}
}
private func handleCommand(command: String, json: String?){
if command == AppGroupMessageKey.login{
// 登录, 刷新所有数据
guard let data = json?.data(using: .utf8) else{
return
}
if let token = userInfo["token"] as? String {
WatchUserinfoManager.share.token = token
handleLoginData(data)
}else if command == AppGroupMessageKey.reloadTheme {
// 刷新表盘等
Task { @MainActor in
WatchUserinfoManager.share.reloadTheme()
}
if let map = userInfo["myUserinfo"] as? [String: Any],
let data = try? JSONSerialization.data(withJSONObject: map),
let myUserinfo = try? JSONDecoder().decode(UserInfo.self, from: data) {
WatchUserinfoManager.share.myUserinfo = myUserinfo
if let id = myUserinfo.id {
ThinkSDKUtil.login(id)
}
}else if command == AppGroupMessageKey.reloadAll{
// 刷新数据
Task { @MainActor in
WatchUserinfoManager.share.reloadLogin()
}
if let map = userInfo["otherUserinfo"] as? [String: Any],
let data = try? JSONSerialization.data(withJSONObject: map),
let otherUserinfo = try? JSONDecoder().decode(UserInfo.self, from: data) {
WatchUserinfoManager.share.otherUserinfo = otherUserinfo
}else if command == AppGroupMessageKey.reloadVip{
// 刷新会员
Task { @MainActor in
WatchUserinfoManager.share.reloadVip()
}
if let interactionActionTypeValue = userInfo["interactionActionType"] as? Int,
let type = InteractionActionType(rawValue: interactionActionTypeValue) {
WatchUserinfoManager.share.interactionActionType = type
}else if command == AppGroupMessageKey.logout{
// 退出登录
Task { @MainActor in
self.applyLogout()
}
if let isMeVip = userInfo["isMeVip"] as? Bool {
WatchUserinfoManager.share.isMeVip = isMeVip
}else if command == AppGroupMessageKey.statusPulseRefresh{
Task { @MainActor in
WatchDataManager.share.fetchMyTodayData()
}
}
}
func session(_ session: WCSession, didReceiveMessage message: [String : Any], replyHandler: @escaping ([String : Any]) -> Void) {
print("message-replyHandler: \(message)")
if let command = message["command"] as? String {
handleCommand(command: command, json: message["json"] as? String)
}
replyHandler(["success": true])
}
func session(_ session: WCSession, didReceiveUserInfo userInfo: [String : Any] = [:]) {
print("received userInfo transform: \(userInfo)")
}
func session(_ session: WCSession, didReceiveMessage message: [String : Any]) {
print("message: \(message)")
if let command = message["command"] as? String {
handleCommand(command: command, json: message["json"] as? String)
}
}
func session(_ session: WCSession, didReceiveApplicationContext applicationContext: [String : Any]) {
print("applicationContext: \(applicationContext)")
print("received applicationContext: \(applicationContext)")
do{
let data = try JSONSerialization.data(withJSONObject: applicationContext)
let model = try JSONDecoder().decode(UserInfoModel.self, from: data)
Task { @MainActor in
self.applyLoginInfo(model, data: data)
}
}catch{
print("handle applicationContext failed: \(error.localizedDescription)")
}
}
func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: (any Error)?) {
print("Activation state: \(activationState.rawValue)")
if let error = error {
print("Activation error: \(error.localizedDescription)")
} else if activationState == .activated {
requestLoginStateFromPhone()
}
}
#if os(iOS)
func sessionDidBecomeInactive(_ session: WCSession) {
}
func sessionDidDeactivate(_ session: WCSession) {
... ... @@ -103,7 +195,7 @@ class WatchSessionManager: NSObject, WCSessionDelegate, ObservableObject {
extension WatchSessionManager {
func notifyPhoneToRefreshPulse() {
if WCSession.default.isReachable {
WCSession.default.sendMessage(["command": "statusPulseRefresh"],
WCSession.default.sendMessage(["command": AppGroupMessageKey.statusPulseRefresh],
replyHandler: nil,
errorHandler: { error in
print("❌ Failed to send refresh message: \(error)")
... ... @@ -112,4 +204,18 @@ extension WatchSessionManager {
print("📵 iPhone not reachable")
}
}
func sendWatchDeviceToken(token: String){
if WCSession.default.isReachable {
WCSession.default.sendMessage(["command": AppGroupMessageKey.watchDeviceToken,
"token": token
],
replyHandler: nil,
errorHandler: { error in
print("❌ Failed to send token message: \(error)")
})
} else {
print("📵 iPhone not reachable")
}
}
}
... ...
... ... @@ -6,71 +6,335 @@
//
import Foundation
import WidgetKit
import Combine
class WatchUserinfoManager: ObservableObject {
extension WatchThemeModel{
func cleanCache() {
if let energeticImageURL{
AppGroupConstants.defaults?.removeObject(forKey: energeticImageURL)
}
if let normalImageURL{
AppGroupConstants.defaults?.removeObject(forKey: normalImageURL)
}
if let slightStressImageURL{
AppGroupConstants.defaults?.removeObject(forKey: slightStressImageURL)
}
if let stressfulImageURL{
AppGroupConstants.defaults?.removeObject(forKey: stressfulImageURL)
}
}
}
@MainActor
final class WatchUserinfoManager: ObservableObject {
static let share = WatchUserinfoManager()
var token: String? {
didSet {
UserDefaults.standard.set(token, forKey: "access_token")
UserDefaults.standard.synchronize()
private let service = UserInfoService()
private let themeService = ThemeService()
var isLogin: Bool{
return token?.count ?? 0 > 0
}
var isMeVip: Bool? {
vipInfo?.isAvaiableVip
}
var baseUrl: String{
myUserInfo?.baseUrl ?? "https://api.doublefeel.cn"
}
var token: String?{
myUserInfo?.token
}
private(set) var myUserInfo: UserInfoModel?
@Published var displayFriend: HAppFriend?
@Published var vipInfo: HVipInfo?
@Published var showRealtimeHRV: Bool
@Published var interactionActionType: InteractionActionType = .stick
var myUserinfo: UserInfo? {
nil
}
// 表盘主题
@Published var myTheme: WatchThemeModel?
@Published var otherTheme: WatchThemeModel?
private var didSetup = false
var displayType: HealthType{
showRealtimeHRV ? .realtime_stress : .hrv
}
private init(){
showRealtimeHRV = AppGroupConstants.defaults?.bool(
forKey: AppGroupConstants.Key.showRealtimeHRV
) ?? false
guard let data = AppGroupConstants.defaults?.data(forKey: AppGroupConstants.Key.myUserInfo) else{
return
}
do{
self.myUserInfo = try JSONDecoder().decode(UserInfoModel.self, from: data)
}catch{}
}
func setup() {
guard !didSetup else { return }
didSetup = true
Task { @MainActor [weak self] in
await Task.yield()
self?.reloadLocalFriend()
self?.loadLocalWatchTheme()
self?.reloadLogin()
}
}
@Published var myUserinfo: UserInfo? {
didSet {
UserDefaults.standard.set(myUserinfo?.toWatchMap(), forKey: "myUserinfo")
UserDefaults.standard.synchronize()
self.saveMyUserCharacter(myUserinfo?.persona?.rawValue ?? 0)
func login(userInfo: UserInfoModel){
guard let _ = userInfo.token, let _ = userInfo.userId, let _ = userInfo.baseUrl else{
return
}
self.myUserInfo = userInfo
reloadLogin()
}
@Published var otherUserinfo: UserInfo? {
didSet {
UserDefaults.standard.set(otherUserinfo?.toWatchMap(), forKey: "otherUserinfo")
UserDefaults.standard.synchronize()
@MainActor
func reloadLogin(){
// 刷新所有数据
if isLogin{
reloadVip()
reloadFriend()
}
if let id = self.myUserInfo?.userId {
ThinkSDKUtil.login(id)
}
}
@MainActor
func logout(){
myTheme?.cleanCache()
otherTheme?.cleanCache()
myUserInfo = nil
displayFriend = nil
vipInfo = nil
showRealtimeHRV = false
myTheme = nil
otherTheme = nil
AppGroupConstants.clearUserSessionData()
ThinkSDKUtil.logout()
}
@Published var interactionActionType: InteractionActionType {
didSet {
UserDefaults.standard.set(interactionActionType.rawValue.string, forKey: "interactionActionType")
UserDefaults.standard.synchronize()
@MainActor
func switchHRVType(to realtime: Bool){
showRealtimeHRV = realtime
AppGroupConstants.defaults?.set(showRealtimeHRV, forKey: AppGroupConstants.Key.showRealtimeHRV)
// 刷新数据
WatchDataManager.share.getLatestHealthData()
WatchDataManager.share.getMyLatestData()
if let _ = displayFriend?.friend_user_id{
WatchDataManager.share.getFriendLatestData()
}
}
var isMeVip: Bool? {
didSet {
UserDefaults.standard.set(token, forKey: "isMeVip")
UserDefaults.standard.synchronize()
}
}
init() {
let token = UserDefaults.standard.string(forKey: "access_token")
if let token {
self.token = token
}
if let myUserinfo = UserDefaults.standard.dictionary(forKey: "myUserinfo"),
let data = try? JSONSerialization.data(withJSONObject: myUserinfo),
let myUserinfo = try? JSONDecoder().decode(UserInfo.self, from: data) {
self.myUserinfo = myUserinfo
}
if let otherUserinfo = UserDefaults.standard.dictionary(forKey: "otherUserinfo"),
let data = try? JSONSerialization.data(withJSONObject: otherUserinfo),
let otherUserinfo = try? JSONDecoder().decode(UserInfo.self, from: data) {
self.otherUserinfo = otherUserinfo
}
let interactionActionTypeString = UserDefaults.standard.string(forKey: "interactionActionType")
if let interactionActionType = interactionActionTypeString?.int {
self.interactionActionType = InteractionActionType(rawValue: interactionActionType) ?? .stick
}else {
self.interactionActionType = .stick
}
}
private func saveMyUserCharacter(_ character: Int) {
let defaults = AppGroupConstants.defaults
defaults?.set(character, forKey: AppGroupConstants.Key.myUserCharacter)
func reloadTheme(){
reloadFriend()
}
//MARK: - 表盘主题
@MainActor
private func loadLocalWatchTheme(){
if let data = AppGroupConstants.defaults?.data(forKey: AppGroupConstants.Key.myWatchTheme),
let model = try? JSONDecoder().decode(WatchThemeModel.self, from: data) {
self.myTheme = model
}else{
self.myTheme = nil
}
if let data = AppGroupConstants.defaults?.data(forKey: AppGroupConstants.Key.otherWatchTheme),
let model = try? JSONDecoder().decode(WatchThemeModel.self, from: data) {
self.otherTheme = model
}else{
self.otherTheme = nil
}
downloadThemeImageIfNeeded()
}
private func loadRemoteWatchTheme() async{
let myNewTheme = try? await themeService.getCurrentTheme(userId: nil)
var otherNewTheme: WatchThemeModel?
if let userId = displayFriend?.user_id{
otherNewTheme = try? await themeService.getCurrentTheme(userId: userId)
}
if let myNewTheme{
if myNewTheme != myTheme{
// 清除缓存
myTheme?.cleanCache()
myTheme = myNewTheme
do{
let data = try JSONEncoder().encode(myNewTheme)
AppGroupConstants.defaults?.set(data, forKey: AppGroupConstants.Key.myWatchTheme)
}catch{}
}
}else{
myTheme?.cleanCache()
myTheme = nil
AppGroupConstants.defaults?.removeObject(forKey: AppGroupConstants.Key.myWatchTheme)
}
if let otherNewTheme{
if otherNewTheme != otherTheme{
// 清除缓存
otherTheme?.cleanCache()
otherTheme = otherNewTheme
do{
let data = try JSONEncoder().encode(otherNewTheme)
AppGroupConstants.defaults?.set(data, forKey: AppGroupConstants.Key.otherWatchTheme)
}catch{}
}
}else{
otherTheme?.cleanCache()
otherTheme = nil
AppGroupConstants.defaults?.removeObject(forKey: AppGroupConstants.Key.otherWatchTheme)
}
downloadThemeImageIfNeeded()
}
@MainActor
private func downloadThemeImageIfNeeded(){
guard let defaults = AppGroupConstants.defaults else { return }
let imageURLs = [myTheme, otherTheme]
.compactMap { $0 }
.flatMap {
[
$0.energeticImageURL,
$0.normalImageURL,
$0.slightStressImageURL,
$0.stressfulImageURL
]
}
.compactMap { $0 }
let downloadUrls = imageURLs.filter({ defaults.data(forKey: $0) == nil })
let downloadGroup = DispatchGroup()
var hasDownloadTask = false
for urlString in Set(downloadUrls) {
guard let url = URL(string: urlString) else { continue }
hasDownloadTask = true
downloadGroup.enter()
URLSession.shared.dataTask(with: url) { data, _, error in
defer { downloadGroup.leave() }
guard error == nil, let data else { return }
defaults.set(data, forKey: urlString)
}.resume()
}
guard hasDownloadTask else {
WidgetCenter.shared.reloadTimelines(ofKind: HippoWidgetKind.hrv.rawValue)
return
}
downloadGroup.notify(queue: .main) { [weak self] in
// 通过published刷新了watch
self?.objectWillChange.send()
// 再刷新小组件
WidgetCenter.shared.reloadTimelines(ofKind: HippoWidgetKind.hrv.rawValue)
}
}
//MARK: - 我的信息
// private func reloadUserInfo(){
// guard isLogin else{
// return
// }
// Task{
// myUserInfo = await service.getMyUserInfo()
// guard let myUserInfo else{
// AppGroupConstants.defaults?.removeObject(forKey: AppGroupConstants.Key.myUserInfo)
// return
// }
// do{
// let data = try JSONEncoder().encode(myUserInfo)
// AppGroupConstants.defaults?.set(data, forKey: AppGroupConstants.Key.myUserInfo)
// }catch{}
// }
// }
private func reloadFriend(){
guard isLogin else{
return
}
Task{
let friendList = await service.getFriendInfo()
let friend = friendList?.list?.first(where: { $0.show_in_dial == 1 })
if let _ = friend?.friend_user_id{
WatchDataManager.share.getFriendLatestData()
}
self.saveOtherData(displayFriend: friend)
await loadRemoteWatchTheme()
}
}
func reloadVip(){
guard isLogin else{
return
}
Task{
let vipInfo = await service.getVipInfo()
self.saveVip(info: vipInfo)
}
}
//MARK: - OtherData
private func reloadLocalFriend(){
if let data = AppGroupConstants.defaults?.data(forKey: AppGroupConstants.Key.showOtherUserInfo),
let model = try? JSONDecoder().decode(HAppFriend.self, from: data) {
self.displayFriend = model
}else{
self.displayFriend = nil
}
}
private func saveOtherData(displayFriend: HAppFriend?){
self.displayFriend = displayFriend
guard let displayFriend else{
AppGroupConstants.defaults?.removeObject(forKey: AppGroupConstants.Key.showOtherUserInfo)
return
}
do{
let data = try JSONEncoder().encode(displayFriend)
AppGroupConstants.defaults?.set(data, forKey: AppGroupConstants.Key.showOtherUserInfo)
}catch{}
}
//MARK: - VIP
private func reloadVipInfo(){
if let data = AppGroupConstants.defaults?.data(forKey: AppGroupConstants.Key.vipInfo),
let model = try? JSONDecoder().decode(HVipInfo.self, from: data) {
self.vipInfo = model
}else{
self.vipInfo = nil
}
reloadVip()
}
private func saveVip(info: HVipInfo?){
self.vipInfo = info
if let info{
do{
let data = try JSONEncoder().encode(info)
AppGroupConstants.defaults?.set(data, forKey: AppGroupConstants.Key.vipInfo)
}catch{}
}else{
AppGroupConstants.defaults?.removeObject(forKey: AppGroupConstants.Key.vipInfo)
}
}
}
... ...
//
// WidgetKind.swift
// hippo-watch Watch App
//
// Created by 权海 on 2026/6/22.
//
import WidgetKit
enum HippoWidgetKind: String{
case hrv = "HRVWidget"
case mySummary = "MyStatusSummaryWidget"
case heartBeatCircle = "HeartBeatCircleWidget"
case activityCircle = "ActivityCircleWidget"
case stepCountCircle = "StepCountCircleWidget"
}
/// Keeps HealthKit-to-widget refresh mappings in one place so a data change
/// only invalidates the timelines that actually consume it.
enum WatchWidgetReloader {
static func reloadHRV() {
reload(.hrv, .mySummary, .heartBeatCircle)
}
static func reloadHeartRate() {
reload(.mySummary, .heartBeatCircle)
}
static func reloadSteps() {
reload(.mySummary, .stepCountCircle)
}
static func reloadActivity() {
reload(.mySummary, .activityCircle)
}
static func reloadActivityTarget() {
reload(.mySummary, .activityCircle, .stepCountCircle)
}
static func reloadAllDynamicWidgets() {
reload(.hrv, .mySummary, .heartBeatCircle, .activityCircle, .stepCountCircle)
}
private static func reload(_ kinds: HippoWidgetKind...) {
kinds.forEach {
WidgetCenter.shared.reloadTimelines(ofKind: $0.rawValue)
}
}
}
... ...
... ... @@ -358,12 +358,8 @@
);
inputFileListPaths = (
);
inputPaths = (
);
outputFileListPaths = (
);
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
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 {
enum FlutterBridgeMethodName: String{
case handleFlutterUrl
case uploadHealthData
}
private var methods: [String: FlutterBridgeMethod] = [:]
var flutterEngine: FlutterEngine?
var channel: FlutterMethodChannel?
private var isFlutterEngineReady = false
private var healthDataUploadObserver: NSObjectProtocol?
func application(
_ application: UIApplication,
... ... @@ -73,6 +75,7 @@ class AppDelegate: NSObject, UIApplicationDelegate {
requestIDFAAuthorization()
WatchConnectivityService.shared.activate()
observeHealthDataUploadsIfNeeded()
HealthKitService.shared.startBackgroundObserversIfNeeded()
}
... ... @@ -126,6 +129,36 @@ extension AppDelegate{
channel?.invokeMethod(method.rawValue, arguments: arguments, result: result)
}
private func observeHealthDataUploadsIfNeeded() {
guard healthDataUploadObserver == nil else { return }
healthDataUploadObserver = NotificationCenter.default.addObserver(
forName: .nativeHealthDataDidUpload,
object: nil,
queue: .main
) { [weak self] notification in
guard let dataType = notification.userInfo?["dataType"] as? Int,
let timestamp = notification.userInfo?["timestamp"] as? Int64 else {
return
}
self?.notifyFlutterHealthDataUploaded(dataType: dataType, timestamp: timestamp)
}
}
private func notifyFlutterHealthDataUploaded(dataType: Int, timestamp: Int64) {
guard channel != nil else {
DebugLogger.log(desc: "uploadHealthData notification skipped because Flutter channel is not ready")
return
}
invoke(
method: .uploadHealthData,
arguments: ["dataType": dataType, "timestamp": timestamp]
) { result in
if let error = result as? FlutterError {
DebugLogger.log(desc: "uploadHealthData callback error: \(error.message ?? error.code)")
}
}
}
private func cacheLaunchURLIfNeeded(_ launchOptions: [UIApplication.LaunchOptionsKey: Any]?) {
if let url = launchOptions?[.url] as? URL {
AppShared.shared.unhandedUrl = url.absoluteString
... ... @@ -184,6 +217,10 @@ extension AppDelegate{
}
}
extension Notification.Name {
static let nativeHealthDataDidUpload = Notification.Name("nativeHealthDataDidUpload")
}
extension AppDelegate: UNUserNotificationCenterDelegate{
func userNotificationCenter(_ center: UNUserNotificationCenter,
willPresent notification: UNNotification,
... ...
... ... @@ -289,7 +289,7 @@ final class HealthDataReader {
activeEnergyBurnedGoal: summary.activeEnergyBurnedGoal.doubleValue(for: .kilocalorie()),
appleMoveTime: Self.appleMoveTimeValue(from: summary),
appleMoveTimeGoal: Self.appleMoveTimeGoalValue(from: summary),
appleExerciseTime: summary.appleExerciseTime.doubleValue(for: .minute()),
appleExerciseTime: summary.appleExerciseTime.doubleValue(for: .second()),
exerciseTimeGoal: Self.exerciseTimeGoalValue(from: summary),
appleStandHours: summary.appleStandHours.doubleValue(for: .count()),
standHoursGoal: Self.standHoursGoalValue(from: summary)
... ... @@ -309,21 +309,21 @@ final class HealthDataReader {
private static func appleMoveTimeValue(from summary: HKActivitySummary) -> Double? {
if #available(iOS 14.0, *) {
return summary.appleMoveTime.doubleValue(for: .minute())
return summary.appleMoveTime.doubleValue(for: .second())
}
return nil
}
private static func appleMoveTimeGoalValue(from summary: HKActivitySummary) -> Double? {
if #available(iOS 14.0, *) {
return summary.appleMoveTimeGoal.doubleValue(for: .minute())
return summary.appleMoveTimeGoal.doubleValue(for: .second())
}
return nil
}
private static func exerciseTimeGoalValue(from summary: HKActivitySummary) -> Double? {
if #available(iOS 16.0, *) {
return summary.exerciseTimeGoal?.doubleValue(for: .minute())
return summary.exerciseTimeGoal?.doubleValue(for: .second())
}
return nil
}
... ... @@ -371,34 +371,23 @@ final class HealthDataReader {
throw NativeHealthKitError.invalidType(identifier.rawValue)
}
// HealthKit can represent derived values such as resting heart rate and
// walking heart rate average as a day-long sample, then replace that
// sample as the estimate improves. Requiring the sample's start date to
// be inside the upload range would drop the replacement whenever the
// server cursor is later than midnight. Query by interval overlap instead.
let predicate = HKQuery.predicateForSamples(
withStart: startDate,
end: endDate,
options: .strictStartDate
options: []
)
let sort = NSSortDescriptor(key: HKSampleSortIdentifierStartDate, ascending: true)
return try await withCheckedThrowingContinuation { continuation in
let query = HKSampleQuery(
sampleType: type,
predicate: predicate,
limit: HKObjectQueryNoLimit,
sortDescriptors: [sort]
) { _, samples, error in
if let error {
continuation.resume(throwing: error)
return
}
let points = (samples as? [HKQuantitySample] ?? []).map { sample in
NativeHealthDataPoint(
dataType: dataType,
time: sample.startDate.timeIntervalSince1970,
value: sample.quantity.doubleValue(for: unit)
)
}
continuation.resume(returning: points)
}
healthStore.execute(query)
let samples = try await fetchQuantitySampleObjects(type: type, predicate: predicate)
return samples.map { sample in
NativeHealthDataPoint(
dataType: dataType,
time: sample.endDate.timeIntervalSince1970,
value: sample.quantity.doubleValue(for: unit)
)
}
}
... ... @@ -412,52 +401,56 @@ final class HealthDataReader {
guard let type = NativeHealthTypeCatalog.quantity(identifier) else {
throw NativeHealthKitError.invalidType(identifier.rawValue)
}
var interval = DateComponents()
interval.day = 1
let anchorDate = Calendar.current.startOfDay(for: startDate)
let predicate = HKQuery.predicateForSamples(withStart: startDate, end: endDate)
let calendar = Calendar.current
let queryStartDate = calendar.startOfDay(for: startDate)
let predicate = HKQuery.predicateForSamples(withStart: queryStartDate, end: endDate)
let samples = try await fetchQuantitySampleObjects(type: type, predicate: predicate)
let latestEndByDay = samples
.filter { $0.endDate > startDate }
.reduce(into: [Date: Date]()) { result, sample in
let day = calendar.startOfDay(for: sample.startDate)
result[day] = max(result[day] ?? .distantPast, sample.endDate)
}
return try await withCheckedThrowingContinuation { continuation in
let query = HKStatisticsCollectionQuery(
quantityType: type,
quantitySamplePredicate: predicate,
options: .cumulativeSum,
anchorDate: anchorDate,
intervalComponents: interval
var points: [NativeHealthDataPoint] = []
for day in latestEndByDay.keys.sorted() {
guard let latestEnd = latestEndByDay[day] else { continue }
let value = try await dailyCumulative(type: type, unit: unit, day: day, endDate: endDate)
points.append(
NativeHealthDataPoint(
dataType: dataType,
time: latestEnd.timeIntervalSince1970,
value: value
)
)
query.initialResultsHandler = { _, collection, error in
if let error {
continuation.resume(throwing: error)
return
}
var points: [NativeHealthDataPoint] = []
collection?.enumerateStatistics(from: startDate, to: endDate) { statistics, _ in
guard let value = statistics.sumQuantity()?.doubleValue(for: unit) else { return }
points.append(
NativeHealthDataPoint(
dataType: dataType,
time: statistics.startDate.timeIntervalSince1970,
value: value
)
)
}
continuation.resume(returning: points)
}
healthStore.execute(query)
}
return points
}
private func fetchDailyStandHours(startDate: Date, endDate: Date) async throws -> [NativeHealthDataPoint] {
let calendar = Calendar.current
var start = calendar.dateComponents([.era, .year, .month, .day], from: startDate)
var end = calendar.dateComponents([.era, .year, .month, .day], from: endDate)
guard let standType = NativeHealthTypeCatalog.quantity(.appleStandTime) else {
throw NativeHealthKitError.invalidType("appleStandTime")
}
let queryStartDate = calendar.startOfDay(for: startDate)
let samplePredicate = HKQuery.predicateForSamples(withStart: queryStartDate, end: endDate)
let samples = try await fetchQuantitySampleObjects(type: standType, predicate: samplePredicate)
let latestEndByDay = samples
.filter { $0.endDate > startDate }
.reduce(into: [Date: Date]()) { result, sample in
let day = calendar.startOfDay(for: sample.startDate)
result[day] = max(result[day] ?? .distantPast, sample.endDate)
}
guard !latestEndByDay.isEmpty else { return [] }
var start = calendar.dateComponents([.era, .year, .month, .day], from: latestEndByDay.keys.min()!)
var end = calendar.dateComponents([.era, .year, .month, .day], from: latestEndByDay.keys.max()!)
start.calendar = calendar
end.calendar = calendar
let predicate = HKQuery.predicate(forActivitySummariesBetweenStart: start, end: end)
let summaryPredicate = HKQuery.predicate(forActivitySummariesBetweenStart: start, end: end)
return try await withCheckedThrowingContinuation { continuation in
let query = HKActivitySummaryQuery(predicate: predicate) { _, summaries, error in
let query = HKActivitySummaryQuery(predicate: summaryPredicate) { _, summaries, error in
if let error {
continuation.resume(throwing: error)
return
... ... @@ -465,12 +458,13 @@ final class HealthDataReader {
let points = (summaries ?? [])
.compactMap { summary -> NativeHealthDataPoint? in
guard let date = calendar.date(from: summary.dateComponents(for: calendar)) else {
guard let date = calendar.date(from: summary.dateComponents(for: calendar)),
let latestEnd = latestEndByDay[calendar.startOfDay(for: date)] else {
return nil
}
return NativeHealthDataPoint(
dataType: .stand,
time: date.timeIntervalSince1970,
time: latestEnd.timeIntervalSince1970,
value: summary.appleStandHours.doubleValue(for: .count())
)
}
... ... @@ -486,10 +480,12 @@ final class HealthDataReader {
guard let type = NativeHealthTypeCatalog.category(.sleepAnalysis) else {
throw NativeHealthKitError.invalidType("sleepAnalysis")
}
// Sleep stages are intervals and can begin before the requested boundary.
// Include any stage that overlaps the range so overnight data is not lost.
let predicate = HKQuery.predicateForSamples(
withStart: startDate,
end: endDate,
options: .strictStartDate
options: []
)
let sort = NSSortDescriptor(key: HKSampleSortIdentifierStartDate, ascending: true)
... ... @@ -527,7 +523,7 @@ final class HealthDataReader {
let predicate = HKQuery.predicateForSamples(
withStart: startDate,
end: endDate,
options: .strictStartDate
options: []
)
let sort = NSSortDescriptor(key: HKSampleSortIdentifierStartDate, ascending: true)
... ... @@ -545,7 +541,7 @@ final class HealthDataReader {
let points = (samples as? [HKCategorySample] ?? []).map { sample in
NativeHealthDataPoint(
dataType: .irregularHeartRhythm,
time: sample.startDate.timeIntervalSince1970,
time: sample.endDate.timeIntervalSince1970,
value: Double(sample.value)
)
}
... ... @@ -573,4 +569,57 @@ final class HealthDataReader {
healthStore.execute(query)
}
}
private func fetchQuantitySampleObjects(
type: HKQuantityType,
predicate: NSPredicate
) async throws -> [HKQuantitySample] {
let sort = NSSortDescriptor(key: HKSampleSortIdentifierEndDate, ascending: true)
return try await withCheckedThrowingContinuation { continuation in
let query = HKSampleQuery(
sampleType: type,
predicate: predicate,
limit: HKObjectQueryNoLimit,
sortDescriptors: [sort]
) { _, samples, error in
if let error {
continuation.resume(throwing: error)
} else {
continuation.resume(returning: samples as? [HKQuantitySample] ?? [])
}
}
healthStore.execute(query)
}
}
private func dailyCumulative(
type: HKQuantityType,
unit: HKUnit,
day: Date,
endDate: Date
) async throws -> Double {
let calendar = Calendar.current
let start = calendar.startOfDay(for: day)
let nextDay = calendar.date(byAdding: .day, value: 1, to: start) ?? endDate
let end = min(nextDay, endDate)
let predicate = HKQuery.predicateForSamples(
withStart: start,
end: end,
options: .strictStartDate
)
return try await withCheckedThrowingContinuation { continuation in
let query = HKStatisticsQuery(
quantityType: type,
quantitySamplePredicate: predicate,
options: .cumulativeSum
) { _, statistics, error in
if let error {
continuation.resume(throwing: error)
} else {
continuation.resume(returning: statistics?.sumQuantity()?.doubleValue(for: unit) ?? 0)
}
}
healthStore.execute(query)
}
}
}
... ...
... ... @@ -15,6 +15,7 @@ final class HealthKitService {
private let syncStore = HealthSyncStateStore()
private lazy var reader = HealthDataReader(healthStore: healthStore)
private var observersStarted = false
private var observerQueries: [HKObserverQuery] = []
private init() {}
... ... @@ -52,45 +53,21 @@ final class HealthKitService {
return await authorizationRequestStatus() != .unnecessary
}
func hasAnyReadableData() async -> Bool {
func hasAnyReadableData(startingAt requestedStartDate: Date? = nil) async -> Bool {
guard isHealthDataAvailable else { return false }
let endDate = Date()
let startDate = Calendar.current.date(byAdding: .year, value: -2, to: endDate)
let startDate = requestedStartDate
?? Calendar.current.date(byAdding: .year, value: -2, to: endDate)
?? Date(timeInterval: -2 * 365 * 24 * 60 * 60, since: endDate)
let commonFetches: [(Date, Date) async throws -> [NativeHealthDataPoint]] = [
reader.fetchHrvData,
reader.fetchHeartRateData,
reader.fetchWalkingHeartRateData,
reader.fetchRestingHeartRateData,
reader.fetchSleepingHeartRateData,
reader.fetchOxygenSaturationData,
reader.fetchActiveEnergyData,
reader.fetchExerciseData,
reader.fetchStandData,
reader.fetchStepCountData,
reader.fetchSleepingWristTemperatureData,
reader.fetchRespiratoryRateData,
reader.fetchIrregularHeartRhythmData,
]
for fetch in commonFetches {
do {
if try await !fetch(startDate, endDate).isEmpty {
return true
}
} catch {
// Keep probing other data types; HealthKit may deny or lack a single type.
}
}
do {
if try await !reader.fetchSleepData(startDate: startDate, endDate: endDate).isEmpty {
return true
}
} catch {
// Keep probing activity summaries.
let sampleTypes = NativeHealthTypeCatalog.readTypes.compactMap { $0 as? HKSampleType }
if await hasAnyReadableSample(
of: sampleTypes,
startDate: startDate,
endDate: endDate
) {
return true
}
do {
... ... @@ -100,12 +77,53 @@ final class HealthKitService {
}
}
private func hasAnyReadableSample(
of sampleTypes: [HKSampleType],
startDate: Date,
endDate: Date
) async -> Bool {
guard !sampleTypes.isEmpty else { return false }
let predicate = HKQuery.predicateForSamples(
withStart: startDate,
end: endDate,
options: []
)
let state = HealthReadableDataProbeState()
return await withCheckedContinuation { continuation in
let group = DispatchGroup()
for sampleType in sampleTypes {
group.enter()
let query = HKSampleQuery(
sampleType: sampleType,
predicate: predicate,
limit: 1,
sortDescriptors: nil
) { _, samples, _ in
if samples?.isEmpty == false {
state.markDataFound()
}
group.leave()
}
healthStore.execute(query)
}
group.notify(queue: .global(qos: .utility)) {
continuation.resume(returning: state.hasData)
}
}
}
func startBackgroundObserversIfNeeded() {
guard isHealthDataAvailable, !observersStarted else { return }
guard isHealthDataAvailable else { return }
// Enabling background delivery is safe to repeat and must be retried after
// authorization or a transient system failure.
NativeHealthTypeCatalog.observedTypes.forEach(enableBackgroundDelivery)
guard !observersStarted else { return }
observersStarted = true
for sampleType in NativeHealthTypeCatalog.observedTypes {
enableBackgroundDelivery(for: sampleType)
let query = HKObserverQuery(sampleType: sampleType, predicate: nil) { [weak self] _, completion, error in
guard error == nil else {
completion()
... ... @@ -116,6 +134,7 @@ final class HealthKitService {
completion()
}
}
observerQueries.append(query)
healthStore.execute(query)
}
}
... ... @@ -236,9 +255,59 @@ final class HealthKitService {
break
}
if let type = NativeHealthDataType(sampleTypeIdentifier: sampleType.identifier) {
syncStore.save(date: Date(), for: type)
let uploadTypes: [NativeHealthDataType]
let includeActivityTarget: Bool
switch sampleType.identifier {
case HKQuantityTypeIdentifier.heartRate.rawValue:
uploadTypes = [.heartRate, .sleepingHeartRate]
includeActivityTarget = false
case HKCategoryTypeIdentifier.sleepAnalysis.rawValue:
uploadTypes = [.sleep, .sleepingHeartRate]
includeActivityTarget = false
default:
uploadTypes = NativeHealthDataType(sampleTypeIdentifier: sampleType.identifier).map { [$0] } ?? []
includeActivityTarget = [
HKQuantityTypeIdentifier.activeEnergyBurned.rawValue,
HKQuantityTypeIdentifier.appleExerciseTime.rawValue,
HKQuantityTypeIdentifier.appleStandTime.rawValue,
].contains(sampleType.identifier)
}
guard !uploadTypes.isEmpty || includeActivityTarget else { return }
let success = await NativeHealthDataUploader.shared.uploadObservedChange(
types: uploadTypes,
includeActivityTarget: includeActivityTarget,
service: self
)
if success {
uploadTypes.forEach { syncStore.save(date: Date(), for: $0) }
}
}
func uploadActivityTargetAfterForeground() async {
guard AppShared.shared.token?.isEmpty == false else { return }
_ = await NativeHealthDataUploader.shared.uploadObservedChange(
types: [],
includeActivityTarget: true,
service: self
)
}
}
private final class HealthReadableDataProbeState: @unchecked Sendable {
private let lock = NSLock()
private var dataFound = false
func markDataFound() {
lock.lock()
dataFound = true
lock.unlock()
}
var hasData: Bool {
lock.lock()
defer { lock.unlock() }
return dataFound
}
}
... ... @@ -259,6 +328,10 @@ private extension NativeHealthDataType {
self = .exercise
case HKQuantityTypeIdentifier.appleStandTime.rawValue:
self = .stand
case HKQuantityTypeIdentifier.walkingHeartRateAverage.rawValue:
self = .walkingHeartRate
case HKQuantityTypeIdentifier.restingHeartRate.rawValue:
self = .restingHeartRate
case HKQuantityTypeIdentifier.appleSleepingWristTemperature.rawValue:
self = .sleepingWristTemperature
case HKQuantityTypeIdentifier.respiratoryRate.rawValue:
... ...
... ... @@ -169,6 +169,8 @@ enum NativeHealthTypeCatalog {
quantity(.activeEnergyBurned),
quantity(.appleExerciseTime),
quantity(.appleStandTime),
quantity(.walkingHeartRateAverage),
quantity(.restingHeartRate),
quantity(.appleSleepingWristTemperature),
quantity(.respiratoryRate),
category(.sleepAnalysis),
... ...
import Foundation
enum NativeHealthUploadConfiguration {
/// Process-lifetime throttle. Each health data type can start at most one
/// upload request during this interval.
static let minimumTriggerInterval: TimeInterval = 60
}
struct NativeHealthUploadSummary {
let commonUploadSuccess: Bool
let sleepUploadSuccess: Bool
... ... @@ -31,39 +37,50 @@ enum NativeHealthUploadError: LocalizedError {
/// Uploads Apple Health data to DoubleFeel.
///
/// Data reading stays in `HealthDataReader` / `HealthKitService`; this type only
/// owns upload requests and the per-user last-upload-time cache migrated from
/// the original SwiftUI app.
final class NativeHealthDataUploader {
/// owns upload requests. Upload boundaries always come from the server's
/// `/data_upload/common/` response.
actor NativeHealthDataUploader {
static let shared = NativeHealthDataUploader()
private let session: URLSession
private var uploadTimeRecorder: NativeHealthUploadTimeRecorder
private let maxUploadTimeWeek = 4
private let defaultUploadTimeWeek = 1
private let firstUploadYear = 2
private let uploadBatchSize = 500
private var lastUploadTriggerDates: [NativeHealthDataType: Date] = [:]
init(
session: URLSession = .shared,
uploadTimeRecorder: NativeHealthUploadTimeRecorder = NativeHealthUploadTimeRecorder()
) {
init(session: URLSession = .shared) {
self.session = session
self.uploadTimeRecorder = uploadTimeRecorder
}
func uploadAll(service: HealthKitService = .shared) async -> NativeHealthUploadSummary {
guard AppShared.shared.token?.isEmpty == false else {
let message = NativeHealthUploadError.missingAccessToken.localizedDescription
DebugLogger.debugLog("uploadAll skipped, user is not logged in")
return NativeHealthUploadSummary(
commonUploadSuccess: false,
sleepUploadSuccess: false,
errorMessage: message,
commonCount: 0,
sleepCount: 0
)
}
var commonCount = 0
var sleepCount = 0
var commonUploadSuccess = true
var sleepUploadSuccess = true
var errorMessages: [String] = []
debugLog("uploadAll started ")
DebugLogger.debugLog("uploadAll started ")
do {
let uploadTimeList = try await processLastUploadTime()
for type in Self.commonUploadTypes {
let result = await upload(type: type, uploadTimeList: uploadTimeList, service: service)
let result = await upload(
type: type,
uploadTimeList: uploadTimeList,
trigger: .full,
service: service
)
commonCount += result.uploadedCount
if !result.success {
commonUploadSuccess = false
... ... @@ -73,7 +90,12 @@ final class NativeHealthDataUploader {
}
}
let sleepResult = await upload(type: .sleep, uploadTimeList: uploadTimeList, service: service)
let sleepResult = await upload(
type: .sleep,
uploadTimeList: uploadTimeList,
trigger: .full,
service: service
)
sleepCount = sleepResult.uploadedCount
sleepUploadSuccess = sleepResult.success
if let errorMessage = sleepResult.errorMessage {
... ... @@ -94,10 +116,10 @@ final class NativeHealthDataUploader {
commonUploadSuccess = false
sleepUploadSuccess = false
errorMessages.append(error.localizedDescription)
debugLog("uploadAll failed before per-type upload, error=\(error.localizedDescription)")
DebugLogger.debugLog("uploadAll failed before per-type upload, error=\(error.localizedDescription)")
}
debugLog("uploadAll finished, commonSuccess=\(commonUploadSuccess), sleepSuccess=\(sleepUploadSuccess), commonCount=\(commonCount), sleepCount=\(sleepCount), error=\(errorMessages.isEmpty ? "<nil>" : errorMessages.joined(separator: " | "))")
DebugLogger.debugLog("uploadAll finished, commonSuccess=\(commonUploadSuccess), sleepSuccess=\(sleepUploadSuccess), commonCount=\(commonCount), sleepCount=\(sleepCount), error=\(errorMessages.isEmpty ? "<nil>" : errorMessages.joined(separator: " | "))")
return NativeHealthUploadSummary(
commonUploadSuccess: commonUploadSuccess,
... ... @@ -109,12 +131,59 @@ final class NativeHealthDataUploader {
}
func upload(type: NativeHealthDataType, service: HealthKitService = .shared) async -> Bool {
guard AppShared.shared.token?.isEmpty == false else {
DebugLogger.debugLog("upload(\(type.debugName)) skipped, user is not logged in")
return false
}
do {
let uploadTimeList = try await processLastUploadTime()
return await upload(
type: type,
uploadTimeList: uploadTimeList,
trigger: .manual,
service: service
).success
} catch {
DebugLogger.debugLog("upload(\(type.debugName)) failed to process last upload time, error=\(error.localizedDescription)")
DebugLogger.debugLog("Health upload failed to process last upload time: \(error.localizedDescription)")
return false
}
}
func uploadObservedChange(
types: [NativeHealthDataType],
includeActivityTarget: Bool,
service: HealthKitService = .shared
) async -> Bool {
guard AppShared.shared.token?.isEmpty == false else {
DebugLogger.debugLog("observed change skipped, user is not logged in")
return false
}
do {
let uploadTimeList = try await processLastUploadTime()
return await upload(type: type, uploadTimeList: uploadTimeList, service: service).success
var success = true
var uploadedNewData = false
for type in types {
let result = await upload(
type: type,
uploadTimeList: uploadTimeList,
trigger: .observer,
service: service
)
success = success && result.success
uploadedNewData = uploadedNewData || result.uploadedCount > 0
}
if includeActivityTarget && uploadedNewData {
let result = await uploadActivityTargetIfNeeded(
uploadTimeList: uploadTimeList,
service: service
)
success = success && result.success
}
return success
} catch {
debugLog("upload(\(type.debugName)) failed to process last upload time, error=\(error.localizedDescription)")
print("Health upload failed to process last upload time: \(error.localizedDescription)")
DebugLogger.debugLog("observed change upload failed, error=\(error.localizedDescription)")
return false
}
}
... ... @@ -127,6 +196,12 @@ private extension NativeHealthDataUploader {
let errorMessage: String?
}
enum UploadTrigger: String {
case full
case observer
case manual
}
static let commonUploadTypes: [NativeHealthDataType] = [
.hrv,
.heartRate,
... ... @@ -146,20 +221,21 @@ private extension NativeHealthDataUploader {
func upload(
type: NativeHealthDataType,
uploadTimeList: NativeHealthUploadTimeList,
trigger: UploadTrigger,
service: HealthKitService
) async -> UploadTaskResult {
guard type != .unknown,
let startUploadDate = uploadTimeList.latestDataTime(for: type) else {
debugLog("[\(type.debugName)] skipped, no upload start date")
DebugLogger.debugLog("[\(type.debugName)] skipped, no upload start date")
return UploadTaskResult(success: true, uploadedCount: 0, errorMessage: nil)
}
let endUploadDate = Date()
debugLog(
"[\(type.debugName)] upload started, range=\(Self.debugDateFormatter.string(from: startUploadDate)) -> \(Self.debugDateFormatter.string(from: endUploadDate))"
DebugLogger.debugLog(
"[\(type.debugName)] upload started, trigger=\(trigger.rawValue), range=\(Self.debugDateFormatter.string(from: startUploadDate)) -> \(Self.debugDateFormatter.string(from: endUploadDate))"
)
guard endUploadDate >= startUploadDate else {
debugLog("[\(type.debugName)] upload failed, start date is later than end date")
DebugLogger.debugLog("[\(type.debugName)] upload failed, start date is later than end date")
return UploadTaskResult(
success: false,
uploadedCount: 0,
... ... @@ -170,41 +246,65 @@ private extension NativeHealthDataUploader {
do {
switch type {
case .sleep:
debugLog("[\(type.debugName)] reading sleep data")
DebugLogger.debugLog("[\(type.debugName)] reading sleep data")
let data = try await service.fetchSleepData(startDate: startUploadDate, endDate: endUploadDate)
debugLog("[\(type.debugName)] read finished, count=\(data.count)")
.filter { $0.toTime > startUploadDate.timeIntervalSince1970 }
.sorted { $0.toTime < $1.toTime }
debugLogTimeComparison(
type: type,
serverTime: startUploadDate.timeIntervalSince1970,
newestTime: data.map(\.toTime).max()
)
DebugLogger.debugLog("[\(type.debugName)] read finished, count=\(data.count)")
guard !data.isEmpty else {
uploadTimeRecorder.save(date: endUploadDate, for: type)
debugLog("[\(type.debugName)] no data, saved upload time=\(Self.debugDateFormatter.string(from: endUploadDate))")
DebugLogger.debugLog("[\(type.debugName)] no data; server upload time remains unchanged")
return UploadTaskResult(success: true, uploadedCount: 0, errorMessage: nil)
}
guard allowUploadTrigger(for: type) else {
return UploadTaskResult(success: true, uploadedCount: 0, errorMessage: nil)
}
debugLog("[\(type.debugName)] uploading, count=\(data.count)")
DebugLogger.debugLog("[\(type.debugName)] uploading, count=\(data.count)")
try await uploadSleep(data)
uploadTimeRecorder.save(date: endUploadDate, for: type)
debugLog("[\(type.debugName)] upload success, count=\(data.count), saved upload time=\(Self.debugDateFormatter.string(from: endUploadDate))")
notifyFlutterUpload(
type: type,
timestamp: data.map(\.toTime).max() ?? endUploadDate.timeIntervalSince1970
)
DebugLogger.debugLog("[\(type.debugName)] upload success, count=\(data.count); next boundary will come from server")
return UploadTaskResult(success: true, uploadedCount: data.count, errorMessage: nil)
default:
debugLog("[\(type.debugName)] reading common data")
DebugLogger.debugLog("[\(type.debugName)] reading common data")
let data = try await fetchCommonData(
type: type,
startDate: startUploadDate,
endDate: endUploadDate,
service: service
)
debugLog("[\(type.debugName)] read finished, count=\(data.count)")
.filter { $0.time > startUploadDate.timeIntervalSince1970 }
.sorted { $0.time < $1.time }
debugLogTimeComparison(
type: type,
serverTime: startUploadDate.timeIntervalSince1970,
newestTime: data.map(\.time).max()
)
DebugLogger.debugLog("[\(type.debugName)] read finished, count=\(data.count)")
guard !data.isEmpty else {
uploadTimeRecorder.save(date: endUploadDate, for: type)
debugLog("[\(type.debugName)] no data, saved upload time=\(Self.debugDateFormatter.string(from: endUploadDate))")
DebugLogger.debugLog("[\(type.debugName)] no data; server upload time remains unchanged")
return UploadTaskResult(success: true, uploadedCount: 0, errorMessage: nil)
}
guard allowUploadTrigger(for: type) else {
return UploadTaskResult(success: true, uploadedCount: 0, errorMessage: nil)
}
debugLog("[\(type.debugName)] uploading, count=\(data.count)")
DebugLogger.debugLog("[\(type.debugName)] uploading, count=\(data.count)")
try await uploadCommon(data)
uploadTimeRecorder.save(date: endUploadDate, for: type)
debugLog("[\(type.debugName)] upload success, count=\(data.count), saved upload time=\(Self.debugDateFormatter.string(from: endUploadDate))")
notifyFlutterUpload(
type: type,
timestamp: data.map(\.time).max() ?? endUploadDate.timeIntervalSince1970
)
DebugLogger.debugLog("[\(type.debugName)] upload success, count=\(data.count); next boundary will come from server")
return UploadTaskResult(success: true, uploadedCount: data.count, errorMessage: nil)
}
} catch {
debugLog("[\(type.debugName)] upload failed, error=\(error.localizedDescription)")
DebugLogger.debugLog("[\(type.debugName)] upload failed, error=\(error.localizedDescription)")
return UploadTaskResult(
success: false,
uploadedCount: 0,
... ... @@ -260,21 +360,21 @@ private extension NativeHealthDataUploader {
?? Date(timeIntervalSinceNow: -7 * 24 * 60 * 60)
let endDate = Date()
debugLog(
DebugLogger.debugLog(
"[activityTarget] upload started, range=\(Self.debugDateFormatter.string(from: startDate)) -> \(Self.debugDateFormatter.string(from: endDate))"
)
do {
guard let target = try await service.fetchActivityTargetData(startDate: startDate, endDate: endDate),
target.move != nil || target.stand != nil else {
debugLog("[activityTarget] no target data")
DebugLogger.debugLog("[activityTarget] no target data")
return UploadTaskResult(success: true, uploadedCount: 0, errorMessage: nil)
}
debugLog("[activityTarget] uploading, body=\(target.uploadBodyForDebug)")
DebugLogger.debugLog("[activityTarget] uploading, body=\(target.uploadBodyForDebug)")
try await uploadActivityTarget(target)
debugLog("[activityTarget] upload success")
DebugLogger.debugLog("[activityTarget] upload success")
return UploadTaskResult(success: true, uploadedCount: 1, errorMessage: nil)
} catch {
debugLog("[activityTarget] upload failed, error=\(error.localizedDescription)")
DebugLogger.debugLog("[activityTarget] upload failed, error=\(error.localizedDescription)")
return UploadTaskResult(
success: false,
uploadedCount: 0,
... ... @@ -284,40 +384,20 @@ private extension NativeHealthDataUploader {
}
func processLastUploadTime() async throws -> NativeHealthUploadTimeList {
let localTimeList = uploadTimeRecorder.records
let serverTimeList = try? await fetchLastUploadTime()
let serverTimeList = try await fetchLastUploadTime()
var resultTimeList: [NativeHealthUploadTimeList.HealthUploadTime] = []
debugLog("processLastUploadTime started, hasServerTimeList=\(serverTimeList != nil)")
let fourWeeksAgo = Calendar.current.date(byAdding: .weekOfYear, value: -maxUploadTimeWeek, to: Date())
?? Date(timeIntervalSinceNow: -TimeInterval(maxUploadTimeWeek * 7 * 24 * 60 * 60))
let fourWeeksAgoMidnight = Calendar.current.startOfDay(for: fourWeeksAgo).timeIntervalSince1970
let oneWeekAgo = Calendar.current.date(byAdding: .weekOfYear, value: -defaultUploadTimeWeek, to: Date())
?? Date(timeIntervalSinceNow: -TimeInterval(defaultUploadTimeWeek * 7 * 24 * 60 * 60))
let oneWeekAgoMidnight = Calendar.current.startOfDay(for: oneWeekAgo).timeIntervalSince1970
DebugLogger.debugLog("processLastUploadTime started, source=server")
let twoYearsAgo = Calendar.current.date(byAdding: .year, value: -firstUploadYear, to: Date())
?? Date(timeIntervalSinceNow: -TimeInterval(firstUploadYear * 365 * 24 * 60 * 60))
let twoYearsAgoMidnight = Calendar.current.startOfDay(for: twoYearsAgo).timeIntervalSince1970
for type in NativeHealthDataType.allCases where type != .unknown {
let localTime = localTimeList.latestTimeInterval(for: type)
let serverTime = serverTimeList?.latestTimeInterval(for: type)
let finalTime: TimeInterval
if let localTime, localTime > fourWeeksAgoMidnight {
finalTime = localTime
} else if let serverTime, serverTime > fourWeeksAgoMidnight {
finalTime = serverTime
} else if localTime != nil || serverTime != nil {
finalTime = oneWeekAgoMidnight
} else {
finalTime = twoYearsAgoMidnight
}
let serverTime = serverTimeList.latestTimeInterval(for: type)
let finalTime = serverTime ?? twoYearsAgoMidnight
debugLog(
"[\(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>")"
DebugLogger.debugLog(
"[\(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)))"
)
resultTimeList.append(
NativeHealthUploadTimeList.HealthUploadTime(
... ... @@ -331,25 +411,64 @@ private extension NativeHealthDataUploader {
}
func uploadCommon(_ data: [NativeHealthDataPoint]) async throws {
let list = data.map {
[
"data_type": $0.dataType.rawValue,
"time": $0.time,
"value": $0.value,
] as [String: Any]
for batch in data.chunked(into: uploadBatchSize) {
batch.forEach { point in
DebugLogger.debugLog(
"[iOS][upload] dataType=\(point.dataType.rawValue)(\(point.dataType.debugName)), time=\(debugTimestamp(point.time)), unix=\(Int64(point.time)), value=\(point.value)"
)
}
let list = batch.map {
[
"data_type": $0.dataType.rawValue,
"time": $0.time,
"value": $0.value,
] as [String: Any]
}
try await request(path: "/client/doublefeel/health/v2/data_upload/common/", method: "POST", body: ["data_list": list])
}
}
func allowUploadTrigger(for type: NativeHealthDataType, now: Date = Date()) -> Bool {
if let lastDate = lastUploadTriggerDates[type],
now.timeIntervalSince(lastDate) < NativeHealthUploadConfiguration.minimumTriggerInterval {
DebugLogger.debugLog(
"[\(type.debugName)] skipped by \(Int(NativeHealthUploadConfiguration.minimumTriggerInterval))s process throttle"
)
return false
}
try await request(path: "/client/doublefeel/health/v2/data_upload/common/", method: "POST", body: ["data_list": list])
lastUploadTriggerDates[type] = now
return true
}
func notifyFlutterUpload(type: NativeHealthDataType, timestamp: TimeInterval) {
let seconds = Int64(timestamp)
DebugLogger.debugLog("[\(type.debugName)] notifying Flutter, dataType=\(type.rawValue), timestamp=\(seconds)")
NotificationCenter.default.post(
name: .nativeHealthDataDidUpload,
object: nil,
userInfo: [
"dataType": type.rawValue,
"timestamp": seconds,
]
)
}
func uploadSleep(_ data: [NativeSleepInterval]) async throws {
let list = data.map {
[
"data_type": $0.dataType,
"from_time": $0.fromTime,
"to_time": $0.toTime,
] as [String: Any]
for batch in data.chunked(into: uploadBatchSize) {
batch.forEach { interval in
DebugLogger.debugLog(
"[iOS][upload][sleep] dataType=\(interval.dataType), from=\(debugTimestamp(interval.fromTime)), fromUnix=\(Int64(interval.fromTime)), to=\(debugTimestamp(interval.toTime)), toUnix=\(Int64(interval.toTime))"
)
}
let list = batch.map {
[
"data_type": $0.dataType,
"from_time": $0.fromTime,
"to_time": $0.toTime,
] as [String: Any]
}
try await request(path: "/client/doublefeel/health/v2/data_upload/sleep/", method: "POST", body: ["data_list": list])
}
try await request(path: "/client/doublefeel/health/v2/data_upload/sleep/", method: "POST", body: ["data_list": list])
}
func uploadActivityTarget(_ target: NativeActivityTarget) async throws {
... ... @@ -404,10 +523,21 @@ private extension NativeHealthDataUploader {
return formatter
}()
func debugLog(_ message: String) {
#if DEBUG || CI_ENV
print("[NativeHealthDataUploader] \(message)")
#endif
func debugTimestamp(_ timeInterval: TimeInterval) -> String {
Self.debugDateFormatter.string(from: Date(timeIntervalSince1970: timeInterval))
}
func debugLogTimeComparison(
type: NativeHealthDataType,
serverTime: TimeInterval,
newestTime: TimeInterval?
) {
let newestDescription = newestTime.map {
"\(debugTimestamp($0)) (unix=\(Int64($0)))"
} ?? "<none>"
DebugLogger.debugLog(
"[iOS][time-check] dataType=\(type.rawValue)(\(type.debugName)), server=\(debugTimestamp(serverTime)) (unix=\(Int64(serverTime))), newest=\(newestDescription), shouldUpload=\(newestTime.map { $0 > serverTime } ?? false)"
)
}
}
... ... @@ -451,69 +581,20 @@ struct NativeHealthUploadTimeList: Codable {
}
}
private struct HealthUploadTimeListResponse: Decodable {
let data: NativeHealthUploadTimeList?
}
struct NativeHealthUploadTimeRecorder {
private var cachedRecords: NativeHealthUploadTimeList?
private static let cacheKey = "HealthUploadlocalRecord_records"
var records: NativeHealthUploadTimeList {
mutating get {
if let cachedRecords {
return cachedRecords
}
let records = Self.loadFromCache() ?? NativeHealthUploadTimeList(latestDataTimeList: [])
cachedRecords = records
return records
private extension Array {
func chunked(into size: Int) -> [[Element]] {
guard size > 0 else { return [self] }
return stride(from: 0, to: count, by: size).map {
Array(self[$0..<Swift.min($0 + size, count)])
}
set {
cachedRecords = newValue
Self.saveToCache(newValue)
}
}
init(records: NativeHealthUploadTimeList? = nil) {
cachedRecords = records
}
mutating func save(date: Date, for type: NativeHealthDataType) {
var records = records
records.latestDataTimeList.removeAll { $0.dataType == type }
records.latestDataTimeList.append(
NativeHealthUploadTimeList.HealthUploadTime(
dataType: type,
latestDataTime: date.timeIntervalSince1970
)
)
self.records = records
}
static func clearCache() {
UserDefaults.standard.removeObject(forKey: cacheKeyForCurrentUser())
}
private static func loadFromCache() -> NativeHealthUploadTimeList? {
guard let data = UserDefaults.standard.data(forKey: cacheKeyForCurrentUser()) else {
return nil
}
return try? JSONDecoder().decode(NativeHealthUploadTimeList.self, from: data)
}
private static func saveToCache(_ records: NativeHealthUploadTimeList) {
guard let data = try? JSONEncoder().encode(records) else {
return
}
UserDefaults.standard.set(data, forKey: cacheKeyForCurrentUser())
}
}
private static func cacheKeyForCurrentUser() -> String {
let userId = "\(AppShared.shared.userId ?? 0)"
return cacheKey + userId
}
private struct HealthUploadTimeListResponse: Decodable {
let data: NativeHealthUploadTimeList?
}
private extension NativeHealthDataType {
var debugName: String {
switch self {
... ...
... ... @@ -21,22 +21,30 @@ final class HealthKitHostApiImpl: HealthKitHostApi {
func checkHealthAppAuthorization(completion: @escaping (Result<HealthAuthorization, any Error>) -> Void) {
Task {
guard service.isHealthDataAvailable else {
completion(.success(HealthAuthorization(status: -1, hasData: false)))
return
}
let requestStatus = await service.authorizationRequestStatus()
let status: Int64
var hasData = false
if requestStatus == .shouldRequest {
status = 0
let authorization: HealthAuthorization
let requestStatus: HKAuthorizationRequestStatus?
if !service.isHealthDataAvailable {
requestStatus = nil
authorization = HealthAuthorization(status: -1, hasData: false)
} else {
hasData = await service.hasAnyReadableData()
status = hasData ? 1 : 2
requestStatus = await service.authorizationRequestStatus()
if requestStatus == .shouldRequest {
authorization = HealthAuthorization(status: 0, hasData: false)
} else {
let oneMonthAgo = Calendar.current.date(byAdding: .month, value: -1, to: Date())
?? Date(timeIntervalSinceNow: -30 * 24 * 60 * 60)
let hasData = await service.hasAnyReadableData(startingAt: oneMonthAgo)
authorization = HealthAuthorization(status: hasData ? 1 : 2, hasData: hasData)
}
}
DebugLogger.log(
desc: "checkHealthAppAuthorization result: requestStatus=\(String(describing: requestStatus)) status=\(authorization.status) hasData=\(authorization.hasData)"
)
await MainActor.run {
completion(.success(authorization))
}
DebugLogger.log(desc: "checkHealthAppAuthorization result: requestStatus=\(status) hasData=\(hasData)")
completion(.success(HealthAuthorization(status: status, hasData: hasData)))
}
}
... ... @@ -50,21 +58,12 @@ final class HealthKitHostApiImpl: HealthKitHostApi {
}
Task {
let requestStatus = await service.authorizationRequestStatus()
let status: Int64
if requestStatus == .shouldRequest {
status = 0
} else {
let hasData = await service.hasAnyReadableData()
status = hasData ? 1 : 2
}
let granted = status == 1
let granted = success
DebugLogger.log(desc:"requestHealthClientAuthorization granted: \(granted)")
if granted {
service.startBackgroundObserversIfNeeded()
await service.refreshSharedWatchValues()
_ = await NativeHealthDataUploader.shared.uploadAll(service: service)
}
completion(.success(granted))
}
... ...
... ... @@ -160,6 +160,9 @@ final class PlatformHostApiImpl: PlatformHostApi {
Task{
await AppShared.shared.reportDeviceInfo()
if await HealthKitService.shared.hasAnyReadableData() {
_ = await NativeHealthDataUploader.shared.uploadAll()
}
}
}
}catch{
... ...
... ... @@ -11,11 +11,20 @@ import UIKit
@main
struct RunnerApp: App {
@UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
@Environment(\.scenePhase) private var scenePhase
var body: some Scene {
WindowGroup {
RunnerLaunchView(appDelegate: appDelegate)
}
.onChange(of: scenePhase, initial: true) { _, phase in
guard phase == .active else { return }
appDelegate.setup()
HealthKitService.shared.startBackgroundObserversIfNeeded()
Task {
await HealthKitService.shared.uploadActivityTargetAfterForeground()
}
}
}
}
... ...
... ... @@ -11,6 +11,7 @@ enum AppGroupConstants {
static let watchDeviceToken = "watchDeviceToken"
static let latestHealthData = "latestHealthData"
static let localHealthActivityTarget = "localHealthActivityTarget"
static let acitivyTarget = "acitivyTarget"
static let myWatchTheme = "myWatchTheme"
static let otherWatchTheme = "otherWatchTheme"
... ... @@ -42,6 +43,7 @@ enum AppGroupConstants {
static func clearUserSessionData() {
[
Key.latestHealthData,
Key.localHealthActivityTarget,
Key.acitivyTarget,
Key.myWatchTheme,
Key.otherWatchTheme,
... ...
... ... @@ -11,6 +11,12 @@ import Foundation
class DebugLogger{
// let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "App", category: "HealthKit")
static func debugLog(_ message: String) {
#if DEBUG || CI_ENV
print(message)
#endif
}
static func log(desc: String){
print(desc)
... ...