当您提供代码差异后,我将按照以下格式生成: ``` <type>(<scope>): <subject> <body> ``` 其中type会根据更改类型选择(feat、fix、docs、style、refactor等),scope表示影响范围,subject简要描述变更内容,body详细说明修改内容。
189 lines
6.2 KiB
Swift
189 lines
6.2 KiB
Swift
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<HKObjectType> {
|
|
var types: Set<HKObjectType> = [
|
|
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<HKObjectType>) async throws {
|
|
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) 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)
|
|
}
|
|
}
|
|
}
|