Files
kangkang/康康/Features/Monitor/MonitorMetric.swift
link2026 39edc25dc1 refactor(profile,monitor): move height/weight from MonitorMetric to UserProfile
身高/体重对成人变化慢,作为 Profile 静态字段比每次录入 Indicator 更合适。

- MonitorMetric:6 case(从 8 减),删 .height / .weight
- UserProfile:加 weightKG: Double?(支持小数),加 bmi computed
- summaryLine 加体重段:'175cm · 68.5kg'(整数省小数)
- ProfileEditView basics 加 weight 行 + footer 显示 BMI + 分类(偏瘦/正常/超重/肥胖)
- IndicatorQuickSheet:删 .height 回写 Profile 的特殊逻辑
- UserProfileTests:+5 个(weight 字段、summaryLine 含 weight、BMI 计算)

兼容性:老 Indicator 里的 seriesKey 'weight' / 'height' 数据保留(SwiftData String?
不变),只是新录入路径走 Profile 不走 Indicator;Trends 仍能用 String seriesKey
查询历史(如果将来要展示老数据)。

测试:60 case pass / 0 fail / 0 warning。
2026-05-26 07:58:47 +08:00

147 lines
5.7 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import Foundation
/// `IndicatorRecordSheet` grid `MonitorMetric.allCases`
///
/// metric 1 2 Field; 2 Indicator( capturedAt + seriesKey),
/// 1 `effectiveRange(for:profile:)` Profile ( 1 :
/// 140150)
enum MonitorMetric: String, CaseIterable, Identifiable {
case bloodPressure // bp.systolic + bp.diastolic
case fastingGlucose // glucose.fasting
case postprandialGlucose // glucose.postprandial
case temperature // temperature
case heartRate // heart_rate
case spo2 // spo2
// : / UserProfile (,)
var id: String { rawValue }
var displayName: String {
switch self {
case .bloodPressure: return "血压"
case .fastingGlucose: return "空腹血糖"
case .postprandialGlucose: return "餐后血糖"
case .temperature: return "体温"
case .heartRate: return "心率"
case .spo2: return "血氧"
}
}
/// SF Symbolgrid
var icon: String {
switch self {
case .bloodPressure: return "heart.fill"
case .fastingGlucose: return "drop.fill"
case .postprandialGlucose: return "drop.circle.fill"
case .temperature: return "thermometer.medium"
case .heartRate: return "waveform.path.ecg"
case .spo2: return "lungs.fill"
}
}
var fields: [Field] {
switch self {
case .bloodPressure:
return [
Field(seriesKey: "bp.systolic",
label: "收缩压",
unit: "mmHg",
placeholder: "120",
baseRange: 90...140),
Field(seriesKey: "bp.diastolic",
label: "舒张压",
unit: "mmHg",
placeholder: "80",
baseRange: 60...90),
]
case .fastingGlucose:
return [Field(seriesKey: "glucose.fasting",
label: "空腹血糖",
unit: "mmol/L",
placeholder: "5.0",
baseRange: 3.9...6.1)]
case .postprandialGlucose:
return [Field(seriesKey: "glucose.postprandial",
label: "餐后 2h",
unit: "mmol/L",
placeholder: "6.5",
baseRange: 0...7.8)]
case .temperature:
return [Field(seriesKey: "temperature",
label: "体温",
unit: "°C",
placeholder: "36.5",
baseRange: 36.0...37.2)]
case .heartRate:
return [Field(seriesKey: "heart_rate",
label: "心率",
unit: "bpm",
placeholder: "72",
baseRange: 60...100)]
case .spo2:
return [Field(seriesKey: "spo2",
label: "血氧",
unit: "%",
placeholder: "98",
baseRange: 95...100)]
}
}
}
extension MonitorMetric {
struct Field: Identifiable, Hashable {
let seriesKey: String
let label: String
let unit: String
let placeholder: String
let baseRange: ClosedRange<Double>?
var id: String { seriesKey }
/// IndicatorRecordSheet 90-140 mmHg
func rangeText(_ range: ClosedRange<Double>?) -> String {
guard let r = range else { return "无参考范围" }
let lower = format(r.lowerBound)
let upper = format(r.upperBound)
// baseRange 0...7.8,<7.8
if r.lowerBound == 0 { return "<\(upper) \(unit)" }
return "\(lower)\(upper) \(unit)"
}
private func format(_ v: Double) -> String {
v.truncatingRemainder(dividingBy: 1) == 0
? String(format: "%.0f", v)
: String(format: "%.1f", v)
}
}
/// field profile
/// 1 :age 65 bp.systolic 140 150
/// profile nil() baseRange
func effectiveRange(for field: Field, profile: UserProfile?) -> ClosedRange<Double>? {
if let age = profile?.age, age >= 65, field.seriesKey == "bp.systolic" {
return 90...150
}
return field.baseRange
}
/// effectiveRange , value status
/// value high; low; normal; normal
static func status(value: Double, in range: ClosedRange<Double>?) -> IndicatorStatus {
guard let r = range else { return .normal }
if value > r.upperBound { return .high }
if value < r.lowerBound { return .low }
return .normal
}
/// IndicatorRecordSheet (67):
/// effectiveRange baseRange true
func isRangePersonalized(for field: Field, profile: UserProfile?) -> Bool {
guard let p = profile else { return false }
let base = field.baseRange
let eff = effectiveRange(for: field, profile: p)
return base != eff
}
}