import Foundation import HealthKit struct HealthProfileImportDraft: Identifiable, Equatable { let id = UUID() var birthYear: Int? var biologicalSexRaw: String? var heightCM: Int? var bloodTypeRaw: String? var hasAnyImportableField: Bool { birthYear != nil || biologicalSexRaw != nil || heightCM != nil || bloodTypeRaw != nil } func apply(to profile: UserProfile, now: Date = .now) { if let birthYear { profile.birthYear = birthYear } if let biologicalSexRaw { profile.biologicalSexRaw = biologicalSexRaw } if let heightCM { profile.heightCM = heightCM } if let bloodTypeRaw { profile.bloodTypeRaw = bloodTypeRaw } profile.updatedAt = now } } struct HealthProfileImportPreview { struct Field: Equatable { let title: String let current: String let imported: String? var willUpdate: Bool { guard let imported else { return false } return imported != current } } let birthYear: Field let sex: Field let height: Field let bloodType: Field var fields: [Field] { [birthYear, sex, height, bloodType] } init(draft: HealthProfileImportDraft, current profile: UserProfile) { birthYear = Field( title: String(appLoc: "出生年份"), current: profile.birthYear.map(String.init) ?? String(appLoc: "未设置"), imported: draft.birthYear.map(String.init) ) sex = Field( title: String(appLoc: "性别"), current: profile.sex.label, imported: draft.biologicalSexRaw.map { Self.sexLabel(raw: $0) } ) height = Field( title: String(appLoc: "身高"), current: profile.heightCM.map { "\($0)cm" } ?? String(appLoc: "未设置"), imported: draft.heightCM.map { "\($0)cm" } ) bloodType = Field( title: String(appLoc: "血型"), current: profile.bloodTypeRaw.isEmpty ? String(appLoc: "未设置") : "\(profile.bloodTypeRaw)型", imported: draft.bloodTypeRaw.map { "\($0)型" } ) } private static func sexLabel(raw: String) -> String { (UserProfile.Sex(rawValue: raw) ?? .undisclosed).label } } enum HealthProfileImportError: LocalizedError { case unavailable case noReadableFields var errorDescription: String? { switch self { case .unavailable: return String(appLoc: "这台设备暂不支持读取 Apple 健康资料。") case .noReadableFields: return String(appLoc: "Apple 健康里没有可导入的生日、性别、身高或血型。") } } } struct HealthProfileImportService { static let shared = HealthProfileImportService() private let store = HKHealthStore() func fetchDraft() async throws -> HealthProfileImportDraft { guard HKHealthStore.isHealthDataAvailable() else { throw HealthProfileImportError.unavailable } let readTypes = readObjectTypes() try await requestReadAuthorization(for: readTypes) async let birthYear = readBirthYear() async let sex = readBiologicalSexRaw() async let height = readLatestHeightCM() async let bloodType = readBloodTypeRaw() let draft = HealthProfileImportDraft( birthYear: try await birthYear, biologicalSexRaw: try await sex, heightCM: try await height, bloodTypeRaw: try await bloodType ) guard draft.hasAnyImportableField else { throw HealthProfileImportError.noReadableFields } return draft } private func readObjectTypes() -> Set { var types: Set = [ HKObjectType.characteristicType(forIdentifier: .dateOfBirth)!, HKObjectType.characteristicType(forIdentifier: .biologicalSex)!, HKObjectType.characteristicType(forIdentifier: .bloodType)!, ] if let height = HKObjectType.quantityType(forIdentifier: .height) { types.insert(height) } return types } private func requestReadAuthorization(for readTypes: Set) async throws { try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in store.requestAuthorization(toShare: [], read: readTypes) { _, error in if let error { continuation.resume(throwing: error) } else { continuation.resume() } } } } private func readBirthYear() throws -> Int? { try store.dateOfBirthComponents().year } private func readBiologicalSexRaw() throws -> String? { switch try store.biologicalSex().biologicalSex { case .female: return "female" case .male: return "male" default: return nil } } private func readBloodTypeRaw() throws -> String? { switch try store.bloodType().bloodType { case .aPositive, .aNegative: return "A" case .bPositive, .bNegative: return "B" case .abPositive, .abNegative: return "AB" case .oPositive, .oNegative: return "O" default: return nil } } private func readLatestHeightCM() async throws -> Int? { guard let heightType = HKObjectType.quantityType(forIdentifier: .height) else { return nil } let sort = NSSortDescriptor(key: HKSampleSortIdentifierEndDate, ascending: false) return try await withCheckedThrowingContinuation { continuation in let query = HKSampleQuery( sampleType: heightType, predicate: nil, limit: 1, sortDescriptors: [sort] ) { _, samples, error in if let error { continuation.resume(throwing: error) return } let sample = samples?.first as? HKQuantitySample let cm = sample?.quantity.doubleValue(for: .meterUnit(with: .centi)) continuation.resume(returning: cm.map { Int($0.rounded()) }) } store.execute(query) } } }