Files
kangkang/康康/Features/Indicator/IndicatorQuickSheet.swift
link2026 bff7cfd4b6 fix(core): 代码审查修复 AI 并发/隐私/解析等多处缺陷
- AIRuntime 加 actor 内串行推理闸门,封死 LLM/VL in-flight 并发解码窄口(jetsam OOM 根因)
- prepare 的 .loading 改轮询等待消除假就绪竞态;就绪判据 isReady→isComplete 防半下载崩溃
- applyReanalyzed 重新解读时 unlink 旧 Asset,消除 Vault 孤儿图片(§6 隐私承诺)
- parseReportJSON 改 extractBalancedJSON + 裸数组兜底,防 VL 多项输出被静默截断丢指标
- 临时文件改 completeUnlessOpen 修锁屏写失败;parseDate 支持多格式防归档年份错位
- TimelineEntry/DayDetailSheet 修「偏高」文案与血压箭头方向(偏低指标不再显示相反结论)
- FileVault.wipe 容错;HealthExportSheet 异常关键词排除否定句;modelTag 取实际枚举值
- 删除 B1-B5 + ArchiveFlow 死代码(含违反 §6 的 AES 加密文案)
- 补 3 个回归测试,编译 + 测试全部通过
2026-06-01 08:16:14 +08:00

1189 lines
44 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 SwiftUI
import SwiftData
/// free-form ()
/// MonitorMetric , Trends, seriesKey
struct IndicatorPreset: Identifiable, Hashable {
let name: String
let unit: String
let range: String
var id: String { name }
}
private let labPresets: [IndicatorPreset] = [
.init(name: "LDL-C", unit: "mmol/L", range: "< 3.40"),
.init(name: "HDL-C", unit: "mmol/L", range: "> 1.04"),
.init(name: "总胆固醇", unit: "mmol/L", range: "< 5.18"),
.init(name: "甘油三酯", unit: "mmol/L", range: "< 1.70"),
.init(name: "ALT", unit: "U/L", range: "9 - 50"),
.init(name: "尿酸", unit: "μmol/L", range: "208 - 428"),
.init(name: "血红蛋白", unit: "g/L", range: "130 - 175"),
]
/// sheet, 3 :
/// 1. ****(MonitorMetric) , seriesKey,
/// Trends 2 Indicator
/// 2. ****(labPresets) a name/unit/range ,
/// seriesKey, Trends
/// 3. **** name/value/unit/range ,status
struct IndicatorQuickSheet: View {
@Environment(\.modelContext) private var ctx
@Environment(\.dismiss) private var dismiss
@Query private var profiles: [UserProfile]
//
@State private var selectedMonitor: MonitorMetric?
@State private var selectedLabPreset: IndicatorPreset?
// (monitor lab)
@State private var name: String = ""
@State private var value: String = ""
@State private var unit: String = ""
@State private var range: String = ""
@State private var manualStatus: IndicatorStatus = .normal
@State private var capturedAt: Date = .now
@State private var note: String = ""
//
@State private var systolic: String = ""
@State private var diastolic: String = ""
// ()
@Query private var allReminders: [MetricReminder]
@State private var reminderEnabled: Bool = false
@State private var reminderTime: Date = Self.defaultReminderTime
@State private var reminderWeekdays: Set<Int> = Set(1...7)
@State private var reminderHydratedFor: String? = nil
@State private var notifAuthBlocked: Bool = false
//
@Query(sort: \CustomMonitorMetric.createdAt, order: .reverse)
private var customMetrics: [CustomMonitorMetric]
@State private var selectedCustom: CustomMonitorMetric?
@State private var editingCustom: CustomMetricEditTarget?
// sheet
@State private var showHiddenSheet: Bool = false
private static var defaultReminderTime: Date {
Calendar.current.date(bySettingHour: 8, minute: 0, second: 0, of: .now) ?? .now
}
private var profile: UserProfile? { profiles.first }
private var isBP: Bool { selectedMonitor == .bloodPressure }
private var isLongTermMetric: Bool { selectedMonitor != nil || selectedCustom != nil }
private var isCustomMonitor: Bool { selectedCustom != nil }
/// key, reminder .task(id:) hydrate
/// metric.rawValue;custom seriesKey; monitor rawValue; nil
private var longTermKey: String? {
if let m = selectedMonitor { return m.rawValue }
if let cm = selectedCustom { return cm.seriesKey }
return nil
}
private var longTermDisplayName: String? {
selectedMonitor?.displayName ?? selectedCustom?.name
}
private var canSubmit: Bool {
if isBP {
return !systolic.trimmingCharacters(in: .whitespaces).isEmpty &&
!diastolic.trimmingCharacters(in: .whitespaces).isEmpty
}
return !name.trimmingCharacters(in: .whitespaces).isEmpty &&
!value.trimmingCharacters(in: .whitespaces).isEmpty
}
var body: some View {
VStack(spacing: 0) {
handle
header
ScrollView(showsIndicators: false) {
VStack(alignment: .leading, spacing: 20) {
monitorGridSection
labPresetSection
Divider().padding(.vertical, 4)
if isBP {
bpFieldSection
} else {
nameSection
valueRow
rangeSection
if isLongTermMetric {
autoStatusHint
} else {
statusSection
}
}
timeSection
noteSection
if isLongTermMetric {
reminderSection
}
}
.padding(.horizontal, 20)
.padding(.bottom, 20)
}
footer
}
.task(id: longTermKey) { hydrateReminder() }
.background(
Tj.Palette.sand
.clipShape(RoundedRectangle(cornerRadius: Tj.Radius.xl, style: .continuous))
.ignoresSafeArea(edges: .bottom)
)
.presentationDetents([.large])
.presentationDragIndicator(.hidden)
.presentationBackground(Tj.Palette.sand)
.presentationCornerRadius(Tj.Radius.xl)
}
// MARK: - Sections
private var handle: some View {
Capsule()
.fill(Tj.Palette.line)
.frame(width: 40, height: 4)
.padding(.top, 10)
.padding(.bottom, 14)
}
private var header: some View {
HStack {
Text("记录指标")
.font(.tjH2())
.foregroundStyle(Tj.Palette.text)
Spacer()
Text("本地处理 · 永不上传")
.font(.system(size: 12))
.foregroundStyle(Tj.Palette.text3)
}
.padding(.horizontal, 20)
.padding(.bottom, 16)
}
private var monitorGridSection: some View {
VStack(alignment: .leading, spacing: 8) {
HStack {
sectionLabel(String(appLoc: "长期监测(进趋势)"))
Spacer()
if !hiddenSet.isEmpty {
hiddenCountChip
}
}
let columns = [GridItem(.flexible()), GridItem(.flexible())]
LazyVGrid(columns: columns, spacing: 8) {
ForEach(visibleMonitorMetrics) { m in
monitorTile(m)
}
ForEach(customMetrics) { cm in
customTile(cm)
}
addCustomTile
}
}
.sheet(isPresented: $showHiddenSheet) {
HiddenMonitorRestoreSheet(
hiddenMetrics: hiddenMonitorMetrics,
onRestore: { unhideMonitor($0) }
)
}
.sheet(item: $editingCustom) { target in
CustomMetricEditor(existing: target.metric) { saved in
// ,
if let saved {
selectedCustom = saved
selectedMonitor = nil
selectedLabPreset = nil
fillFromCustom(saved)
} else if selectedCustom?.seriesKey == target.metric?.seriesKey {
selectedCustom = nil
clearAllFields()
}
}
}
}
private func customTile(_ cm: CustomMonitorMetric) -> some View {
let selected = selectedCustom?.seriesKey == cm.seriesKey
return Button {
applyCustom(cm)
} label: {
HStack(spacing: 10) {
Image(systemName: cm.icon)
.font(.system(size: 18, weight: .medium))
.foregroundStyle(selected ? Tj.Palette.paper : Tj.Palette.ink)
.frame(width: 32, height: 32)
.background(Circle().fill(selected ? Tj.Palette.ink : Tj.Palette.leafSoft))
VStack(alignment: .leading, spacing: 1) {
Text(cm.name)
.font(.system(size: 14, weight: selected ? .semibold : .medium))
.foregroundStyle(selected ? Tj.Palette.paper : Tj.Palette.text)
.lineLimit(1)
Text("自定义")
.font(.system(size: 9, design: .monospaced))
.foregroundStyle(selected ? Tj.Palette.paper.opacity(0.7) : Tj.Palette.text3)
}
Spacer()
}
.padding(.horizontal, 10)
.padding(.vertical, 10)
.background(
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
.fill(selected ? Tj.Palette.ink : Tj.Palette.paper)
)
.overlay(
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
.strokeBorder(Tj.Palette.line, lineWidth: selected ? 0 : 1)
)
}
.buttonStyle(.plain)
.contextMenu {
// :()
// action , trash/,,
Button { editingCustom = CustomMetricEditTarget(metric: cm) } label: {
Label("编辑 / 删除", systemImage: "pencil")
}
}
}
private var addCustomTile: some View {
Button {
editingCustom = CustomMetricEditTarget(metric: nil)
} label: {
HStack(spacing: 10) {
Image(systemName: "plus")
.font(.system(size: 18, weight: .semibold))
.foregroundStyle(Tj.Palette.text2)
.frame(width: 32, height: 32)
.background(
Circle().strokeBorder(Tj.Palette.line, lineWidth: 1, antialiased: true)
)
Text("自定义")
.font(.system(size: 14, weight: .medium))
.foregroundStyle(Tj.Palette.text2)
Spacer()
}
.padding(.horizontal, 10)
.padding(.vertical, 10)
.background(
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
.fill(Tj.Palette.sand2.opacity(0.5))
)
.overlay(
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
.strokeBorder(Tj.Palette.line.opacity(0.6),
style: StrokeStyle(lineWidth: 1, dash: [4, 3]))
)
}
.buttonStyle(.plain)
}
private func monitorTile(_ m: MonitorMetric) -> some View {
let selected = selectedMonitor == m
return Button {
applyMonitor(m)
} label: {
HStack(spacing: 10) {
Image(systemName: m.icon)
.font(.system(size: 18, weight: .medium))
.foregroundStyle(selected ? Tj.Palette.paper : Tj.Palette.ink)
.frame(width: 32, height: 32)
.background(Circle().fill(selected ? Tj.Palette.ink : Tj.Palette.amber.opacity(0.25)))
Text(m.displayName)
.font(.system(size: 14, weight: selected ? .semibold : .medium))
.foregroundStyle(selected ? Tj.Palette.paper : Tj.Palette.text)
Spacer()
}
.padding(.horizontal, 10)
.padding(.vertical, 10)
.background(
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
.fill(selected ? Tj.Palette.ink : Tj.Palette.paper)
)
.overlay(
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
.strokeBorder(Tj.Palette.line, lineWidth: selected ? 0 : 1)
)
}
.buttonStyle(.plain)
.contextMenu {
Button(role: .destructive) {
hideMonitor(m)
} label: {
Label("隐藏", systemImage: "eye.slash")
}
}
}
private var labPresetSection: some View {
VStack(alignment: .leading, spacing: 8) {
sectionLabel(String(appLoc: "化验项快捷(不进趋势)"))
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
ForEach(labPresets) { p in
chip(p.name, selected: selectedLabPreset == p) {
applyLab(p)
}
}
}
}
}
}
private var bpFieldSection: some View {
VStack(alignment: .leading, spacing: 12) {
HStack {
sectionLabel(String(appLoc: "收缩 / 舒张"))
Spacer()
bpRangeHint
}
HStack(spacing: 12) {
bpField(label: String(appLoc: "收缩压"), value: $systolic, placeholder: "120")
Text("/").font(.system(size: 22, weight: .light)).foregroundStyle(Tj.Palette.text3)
bpField(label: String(appLoc: "舒张压"), value: $diastolic, placeholder: "80")
Text("mmHg").foregroundStyle(Tj.Palette.text3)
}
bpStatusChips
}
}
private func bpField(label: String, value: Binding<String>, placeholder: String) -> some View {
VStack(alignment: .leading, spacing: 4) {
Text(label).font(.system(size: 11)).foregroundStyle(Tj.Palette.text3)
TextField(placeholder, text: value)
.keyboardType(.decimalPad)
.font(.system(size: 20, weight: .semibold, design: .monospaced))
.multilineTextAlignment(.center)
.padding(.vertical, 10)
.frame(width: 90)
.background(fieldBg)
.overlay(fieldBorder)
}
}
private var bpRangeHint: some View {
let sysRange = MonitorMetric.bloodPressure.effectiveRange(
for: MonitorMetric.bloodPressure.fields[0], profile: profile)
let diasRange = MonitorMetric.bloodPressure.effectiveRange(
for: MonitorMetric.bloodPressure.fields[1], profile: profile)
let personalized = MonitorMetric.bloodPressure.isRangePersonalized(
for: MonitorMetric.bloodPressure.fields[0], profile: profile)
let rangeText = "\(formatRange(sysRange)) / \(formatRange(diasRange))"
return HStack(spacing: 4) {
Text(rangeText)
.font(.system(size: 11, design: .monospaced))
.foregroundStyle(Tj.Palette.text3)
if personalized, let age = profile?.age {
Text("· 按\(age)岁调整")
.font(.system(size: 10))
.foregroundStyle(Tj.Palette.amber)
}
}
}
private var bpStatusChips: some View {
HStack(spacing: 8) {
if let s = computedBPStatus(.systolic) {
statusBadge(String(appLoc: "收缩 ") + s.label, color: s.color)
}
if let s = computedBPStatus(.diastolic) {
statusBadge(String(appLoc: "舒张 ") + s.label, color: s.color)
}
Spacer()
}
}
private var nameSection: some View {
VStack(alignment: .leading, spacing: 8) {
sectionLabel(String(appLoc: "指标名"))
TextField("例如:血红蛋白", text: $name)
.textInputAutocapitalization(.never)
.padding(.horizontal, 14)
.padding(.vertical, 12)
.background(fieldBg)
.overlay(fieldBorder)
.onChange(of: name) { _, _ in
if let p = selectedLabPreset, p.name != name {
selectedLabPreset = nil
}
}
.disabled(isLongTermMetric)
.opacity(isLongTermMetric ? 0.6 : 1)
}
}
private var valueRow: some View {
HStack(alignment: .top, spacing: 12) {
VStack(alignment: .leading, spacing: 8) {
sectionLabel(String(appLoc: "数值"))
TextField(monitorFieldPlaceholder, text: $value)
.keyboardType(.decimalPad)
.font(.system(size: 18, weight: .semibold, design: .monospaced))
.padding(.horizontal, 14)
.padding(.vertical, 12)
.background(fieldBg)
.overlay(fieldBorder)
}
VStack(alignment: .leading, spacing: 8) {
sectionLabel(String(appLoc: "单位"))
TextField("mmol/L", text: $unit)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.padding(.horizontal, 14)
.padding(.vertical, 12)
.background(fieldBg)
.overlay(fieldBorder)
.disabled(isLongTermMetric)
.opacity(isLongTermMetric ? 0.6 : 1)
}
.frame(maxWidth: 130)
}
}
private var rangeSection: some View {
VStack(alignment: .leading, spacing: 8) {
HStack {
sectionLabel(String(appLoc: "参考范围"))
Spacer()
if let m = selectedMonitor, m != .bloodPressure {
monitorRangeHint(m)
}
}
TextField("例如:< 3.40 或 3.9 - 6.1", text: $range)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.padding(.horizontal, 14)
.padding(.vertical, 12)
.background(fieldBg)
.overlay(fieldBorder)
.disabled(isLongTermMetric)
.opacity(isLongTermMetric ? 0.6 : 1)
}
}
private func monitorRangeHint(_ m: MonitorMetric) -> some View {
let personalized = m.isRangePersonalized(for: m.fields[0], profile: profile)
return HStack(spacing: 4) {
if personalized, let age = profile?.age {
Text("\(age)岁调整")
.font(.system(size: 10))
.foregroundStyle(Tj.Palette.amber)
}
}
}
private var statusSection: some View {
VStack(alignment: .leading, spacing: 8) {
sectionLabel(String(appLoc: "状态"))
HStack(spacing: 8) {
statusChip(.normal, label: String(appLoc: "正常"), color: Tj.Palette.leaf)
statusChip(.high, label: String(appLoc: "偏高 ↑"), color: Tj.Palette.brick)
statusChip(.low, label: String(appLoc: "偏低 ↓"), color: Tj.Palette.amber)
}
}
}
private var autoStatusHint: some View {
let auto = computedSingleStatus
return HStack(spacing: 8) {
sectionLabel(String(appLoc: "状态(按数值自动判)"))
if let s = auto {
statusBadge(s.label, color: s.color)
} else {
Text("待输入")
.font(.system(size: 12))
.foregroundStyle(Tj.Palette.text3)
}
}
}
private var timeSection: some View {
VStack(alignment: .leading, spacing: 8) {
sectionLabel(String(appLoc: "测量时间"))
DatePicker("", selection: $capturedAt, in: ...Date.now)
.datePickerStyle(.compact)
.labelsHidden()
}
}
private var noteSection: some View {
VStack(alignment: .leading, spacing: 8) {
sectionLabel(String(appLoc: "备注(可选)"))
TextField("例如:空腹采血", text: $note, axis: .vertical)
.lineLimit(1...3)
.padding(.horizontal, 14)
.padding(.vertical, 12)
.background(fieldBg)
.overlay(fieldBorder)
}
}
// MARK: -
private var reminderSection: some View {
VStack(alignment: .leading, spacing: 10) {
HStack {
sectionLabel(String(appLoc: "周期提醒"))
Spacer()
Toggle("", isOn: $reminderEnabled)
.labelsHidden()
.tint(Tj.Palette.ink)
.onChange(of: reminderEnabled) { _, on in
if on { Task { await requestNotifAuthIfNeeded() } }
}
}
if reminderEnabled {
VStack(alignment: .leading, spacing: 12) {
HStack {
Text("时间")
.font(.system(size: 13))
.foregroundStyle(Tj.Palette.text2)
Spacer()
DatePicker("", selection: $reminderTime,
displayedComponents: .hourAndMinute)
.datePickerStyle(.compact)
.labelsHidden()
}
VStack(alignment: .leading, spacing: 6) {
HStack {
Text("频率")
.font(.system(size: 13))
.foregroundStyle(Tj.Palette.text2)
Spacer()
Text(reminderFrequencyLabel)
.font(.system(size: 12))
.foregroundStyle(Tj.Palette.text3)
}
weekdayPickerRow
HStack(spacing: 8) {
quickFreqChip(String(appLoc: "每天")) {
reminderWeekdays = Set(1...7)
}
quickFreqChip(String(appLoc: "工作日")) {
reminderWeekdays = Set([2, 3, 4, 5, 6])
}
quickFreqChip(String(appLoc: "周末")) {
reminderWeekdays = Set([1, 7])
}
}
}
if notifAuthBlocked {
Text("⚠️ 通知权限已关闭,去「设置 → 康康 → 通知」打开")
.font(.system(size: 11))
.foregroundStyle(Tj.Palette.brick)
} else {
Text("本机提醒 · 不发任何数据")
.font(.system(size: 11))
.foregroundStyle(Tj.Palette.text3)
}
}
.padding(12)
.background(fieldBg)
.overlay(fieldBorder)
}
}
}
private var reminderFrequencyLabel: String {
if reminderWeekdays.count == 7 { return String(appLoc: "每天") }
if reminderWeekdays.isEmpty { return String(appLoc: "未选") }
let names = [
String(appLoc: ""), String(appLoc: ""), String(appLoc: ""),
String(appLoc: ""), String(appLoc: ""), String(appLoc: ""),
String(appLoc: ""),
]
let sorted = reminderWeekdays.sorted()
return String(appLoc: "每周 ") + sorted.map { names[$0 - 1] }.joined()
}
private var weekdayPickerRow: some View {
let names = [
String(appLoc: ""), String(appLoc: ""), String(appLoc: ""),
String(appLoc: ""), String(appLoc: ""), String(appLoc: ""),
String(appLoc: ""),
]
let weekdayValues = [2, 3, 4, 5, 6, 7, 1] // (Apple Calendar )
return HStack(spacing: 6) {
ForEach(Array(weekdayValues.enumerated()), id: \.offset) { idx, w in
Button {
if reminderWeekdays.contains(w) {
reminderWeekdays.remove(w)
} else {
reminderWeekdays.insert(w)
}
} label: {
Text(names[idx])
.font(.system(size: 13,
weight: reminderWeekdays.contains(w) ? .semibold : .regular))
.foregroundStyle(reminderWeekdays.contains(w) ? Tj.Palette.paper : Tj.Palette.text)
.frame(maxWidth: .infinity, minHeight: 32)
.background(
RoundedRectangle(cornerRadius: 8, style: .continuous)
.fill(reminderWeekdays.contains(w) ? Tj.Palette.ink : Tj.Palette.paper)
)
.overlay(
RoundedRectangle(cornerRadius: 8, style: .continuous)
.strokeBorder(Tj.Palette.line,
lineWidth: reminderWeekdays.contains(w) ? 0 : 1)
)
}
.buttonStyle(.plain)
}
}
}
private func quickFreqChip(_ label: String, action: @escaping () -> Void) -> some View {
Button(action: action) {
Text(label)
.font(.system(size: 11))
.foregroundStyle(Tj.Palette.text2)
.padding(.horizontal, 10)
.padding(.vertical, 4)
.background(Capsule().fill(Tj.Palette.sand2))
}
.buttonStyle(.plain)
}
private func hydrateReminder() {
guard let key = longTermKey else { return }
if reminderHydratedFor == key { return }
reminderHydratedFor = key
if let existing = allReminders.first(where: { $0.metricId == key }) {
reminderEnabled = existing.enabled
reminderTime = Calendar.current.date(
bySettingHour: existing.hour, minute: existing.minute, second: 0, of: .now
) ?? Self.defaultReminderTime
reminderWeekdays = Set(existing.weekdays)
} else {
reminderEnabled = false
reminderTime = Self.defaultReminderTime
reminderWeekdays = Set(1...7)
}
}
private func requestNotifAuthIfNeeded() async {
let state = await ReminderService.requestAuthorization()
notifAuthBlocked = (state == .denied)
if notifAuthBlocked {
reminderEnabled = false
}
}
/// submit() ,:enabled upsert SwiftData + ;disabled reminder +
private func persistReminderIfNeeded() async {
guard let key = longTermKey, let displayName = longTermDisplayName else { return }
let existing = allReminders.first(where: { $0.metricId == key })
let cal = Calendar.current
let hour = cal.component(.hour, from: reminderTime)
let minute = cal.component(.minute, from: reminderTime)
if reminderEnabled && !reminderWeekdays.isEmpty {
let reminder: MetricReminder
if let existing {
existing.enabled = true
existing.hour = hour
existing.minute = minute
existing.weekdays = reminderWeekdays.sorted()
existing.displayName = displayName
existing.updatedAt = .now
reminder = existing
} else {
let new = MetricReminder(
metricId: key,
displayName: displayName,
hour: hour,
minute: minute,
weekdays: reminderWeekdays.sorted(),
enabled: true
)
ctx.insert(new)
reminder = new
}
try? ctx.save()
await ReminderService.sync(reminder)
} else if let existing {
// : SwiftData , enabled = false,
existing.enabled = false
existing.updatedAt = .now
try? ctx.save()
ReminderService.cancel(metricId: key)
}
}
private var footer: some View {
HStack(spacing: 12) {
Button("取消") { dismiss() }
.buttonStyle(TjGhostButton(height: 44, fontSize: 15, horizontalPadding: 18))
Button("保存到记录") { submit() }
.buttonStyle(TjPrimaryButton(height: 44, fontSize: 15, horizontalPadding: 18))
.disabled(!canSubmit)
.opacity(canSubmit ? 1 : 0.4)
}
.padding(.horizontal, 20)
.padding(.vertical, 14)
.background(
Tj.Palette.sand
.overlay(alignment: .top) {
Rectangle().fill(Tj.Palette.lineSoft).frame(height: 1)
}
)
}
// MARK: - helpers
private var fieldBg: some View {
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
.fill(Tj.Palette.paper)
}
private var fieldBorder: some View {
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
.strokeBorder(Tj.Palette.line, lineWidth: 1)
}
private func sectionLabel(_ text: String) -> some View {
Text(text)
.font(.system(size: 12, weight: .semibold))
.tracking(0.3)
.foregroundStyle(Tj.Palette.text2)
}
private func chip(_ label: String, selected: Bool, action: @escaping () -> Void) -> some View {
Button(action: action) {
Text(label)
.font(.system(size: 13, weight: selected ? .semibold : .regular))
.foregroundStyle(selected ? Tj.Palette.paper : Tj.Palette.text)
.padding(.horizontal, 14)
.padding(.vertical, 8)
.background(Capsule().fill(selected ? Tj.Palette.ink : Tj.Palette.paper))
.overlay(Capsule().strokeBorder(Tj.Palette.line, lineWidth: selected ? 0 : 1))
}
.buttonStyle(.plain)
}
private func statusChip(_ value: IndicatorStatus, label: String, color: Color) -> some View {
let selected = manualStatus == value
return Button {
manualStatus = value
} label: {
Text(label)
.font(.system(size: 13, weight: selected ? .semibold : .regular))
.foregroundStyle(selected ? Tj.Palette.paper : color)
.padding(.horizontal, 14)
.padding(.vertical, 8)
.frame(maxWidth: .infinity)
.background(Capsule().fill(selected ? color : Tj.Palette.paper))
.overlay(Capsule().strokeBorder(color.opacity(selected ? 0 : 0.5), lineWidth: 1))
}
.buttonStyle(.plain)
}
private func statusBadge(_ label: String, color: Color) -> some View {
Text(label)
.font(.system(size: 11, weight: .semibold))
.foregroundStyle(color)
.padding(.horizontal, 10)
.padding(.vertical, 4)
.background(Capsule().fill(color.opacity(0.18)))
}
private var monitorFieldPlaceholder: String {
selectedMonitor?.fields.first?.placeholder ?? "3.84"
}
private func formatRange(_ r: ClosedRange<Double>?) -> String {
guard let r = r else { return "" }
let fmt = { (v: Double) in
v.truncatingRemainder(dividingBy: 1) == 0
? String(format: "%.0f", v) : String(format: "%.1f", v)
}
return "\(fmt(r.lowerBound))\(fmt(r.upperBound))"
}
// MARK: - hidden preset
private var hiddenSet: Set<String> {
Set(profile?.hiddenPresetMetrics ?? [])
}
private var visibleMonitorMetrics: [MonitorMetric] {
MonitorMetric.allCases.filter { !hiddenSet.contains($0.rawValue) }
}
private var hiddenMonitorMetrics: [MonitorMetric] {
MonitorMetric.allCases.filter { hiddenSet.contains($0.rawValue) }
}
private var hiddenCountChip: some View {
Button {
showHiddenSheet = true
} label: {
HStack(spacing: 3) {
Text("已隐藏 \(hiddenSet.count)")
.font(.system(size: 11, weight: .medium))
Image(systemName: "chevron.right")
.font(.system(size: 9, weight: .semibold))
}
.foregroundStyle(Tj.Palette.text2)
.padding(.horizontal, 10)
.padding(.vertical, 4)
.background(Capsule().fill(Tj.Palette.sand2))
}
.buttonStyle(.plain)
}
private func hideMonitor(_ m: MonitorMetric) {
let profile = UserProfileStore.loadOrCreate(in: ctx)
guard !profile.hiddenPresetMetrics.contains(m.rawValue) else { return }
profile.hiddenPresetMetrics.append(m.rawValue)
profile.updatedAt = .now
try? ctx.save()
if selectedMonitor == m {
clearMonitor()
}
}
private func unhideMonitor(_ m: MonitorMetric) {
guard let profile = profile else { return }
profile.hiddenPresetMetrics.removeAll { $0 == m.rawValue }
profile.updatedAt = .now
try? ctx.save()
if profile.hiddenPresetMetrics.isEmpty {
showHiddenSheet = false
}
}
// MARK: - apply preset
private func applyMonitor(_ m: MonitorMetric) {
if selectedMonitor == m {
//
clearMonitor()
return
}
selectedMonitor = m
selectedLabPreset = nil
selectedCustom = nil
if m == .bloodPressure {
// bp , name/value/unit
name = m.displayName
unit = "mmHg"
} else {
let f = m.fields[0]
name = m.displayName
value = ""
unit = f.unit
let r = m.effectiveRange(for: f, profile: profile)
range = f.rangeText(r)
}
}
private func clearMonitor() {
selectedMonitor = nil
name = ""; value = ""; unit = ""; range = ""
systolic = ""; diastolic = ""
}
private func applyLab(_ p: IndicatorPreset) {
selectedLabPreset = p
selectedMonitor = nil
selectedCustom = nil
systolic = ""; diastolic = ""
name = p.name
if unit.trimmingCharacters(in: .whitespaces).isEmpty { unit = p.unit }
if range.trimmingCharacters(in: .whitespaces).isEmpty { range = p.range }
}
private func applyCustom(_ cm: CustomMonitorMetric) {
if selectedCustom?.seriesKey == cm.seriesKey {
selectedCustom = nil
clearAllFields()
return
}
selectedCustom = cm
selectedMonitor = nil
selectedLabPreset = nil
fillFromCustom(cm)
}
private func fillFromCustom(_ cm: CustomMonitorMetric) {
name = cm.name
value = ""
unit = cm.unit
range = cm.rangeText
systolic = ""; diastolic = ""
}
private func clearAllFields() {
name = ""; value = ""; unit = ""; range = ""
systolic = ""; diastolic = ""
}
// MARK: - auto status
private var computedSingleStatus: (label: String, color: Color)? {
guard let v = Double(value.trimmingCharacters(in: .whitespaces)) else { return nil }
if let m = selectedMonitor, m != .bloodPressure {
let f = m.fields[0]
let r = m.effectiveRange(for: f, profile: profile)
let s = MonitorMetric.status(value: v, in: r)
return (s.label, s.color)
}
if let cm = selectedCustom {
let s = MonitorMetric.status(value: v, in: cm.referenceRange)
return (s.label, s.color)
}
return nil
}
private enum BPSide { case systolic, diastolic }
private func computedBPStatus(_ side: BPSide) -> (label: String, color: Color)? {
let text = side == .systolic ? systolic : diastolic
guard let v = Double(text.trimmingCharacters(in: .whitespaces)) else { return nil }
let m = MonitorMetric.bloodPressure
let f = m.fields[side == .systolic ? 0 : 1]
let r = m.effectiveRange(for: f, profile: profile)
let s = MonitorMetric.status(value: v, in: r)
return (s.label, s.color)
}
// MARK: - submit
private func submit() {
guard canSubmit else { return }
if isBP {
saveBP()
} else if let m = selectedMonitor {
saveSingleMonitor(m)
} else if let cm = selectedCustom {
saveCustom(cm)
} else {
saveFreeform()
}
Task {
await persistReminderIfNeeded()
await MainActor.run { dismiss() }
}
}
private func saveBP() {
let m = MonitorMetric.bloodPressure
let sys = Double(systolic.trimmingCharacters(in: .whitespaces)) ?? 0
let dia = Double(diastolic.trimmingCharacters(in: .whitespaces)) ?? 0
let sysField = m.fields[0]
let diaField = m.fields[1]
let sysRange = m.effectiveRange(for: sysField, profile: profile)
let diaRange = m.effectiveRange(for: diaField, profile: profile)
let sysStatus = MonitorMetric.status(value: sys, in: sysRange)
let diaStatus = MonitorMetric.status(value: dia, in: diaRange)
let timestamp = capturedAt
let systolicI = Indicator(
name: sysField.label,
value: systolic,
unit: sysField.unit,
range: sysField.rangeText(sysRange),
status: sysStatus,
note: note.isEmpty ? nil : note,
capturedAt: timestamp,
pinned: true,
seriesKey: sysField.seriesKey
)
let diastolicI = Indicator(
name: diaField.label,
value: diastolic,
unit: diaField.unit,
range: diaField.rangeText(diaRange),
status: diaStatus,
note: nil,
capturedAt: timestamp,
pinned: true,
seriesKey: diaField.seriesKey
)
ctx.insert(systolicI)
ctx.insert(diastolicI)
try? ctx.save()
}
private func saveSingleMonitor(_ m: MonitorMetric) {
let f = m.fields[0]
let v = Double(value.trimmingCharacters(in: .whitespaces)) ?? 0
let r = m.effectiveRange(for: f, profile: profile)
let status = MonitorMetric.status(value: v, in: r)
let indicator = Indicator(
name: m.displayName,
value: value.trimmingCharacters(in: .whitespaces),
unit: f.unit,
range: f.rangeText(r),
status: status,
note: note.isEmpty ? nil : note,
capturedAt: capturedAt,
pinned: true,
seriesKey: f.seriesKey
)
ctx.insert(indicator)
try? ctx.save()
}
private func saveCustom(_ cm: CustomMonitorMetric) {
let v = Double(value.trimmingCharacters(in: .whitespaces)) ?? 0
let status = MonitorMetric.status(value: v, in: cm.referenceRange)
let indicator = Indicator(
name: cm.name,
value: value.trimmingCharacters(in: .whitespaces),
unit: cm.unit,
range: cm.rangeText,
status: status,
note: note.isEmpty ? nil : note,
capturedAt: capturedAt,
pinned: true,
seriesKey: cm.seriesKey
)
ctx.insert(indicator)
try? ctx.save()
}
private func saveFreeform() {
let indicator = Indicator(
name: name.trimmingCharacters(in: .whitespaces),
value: value.trimmingCharacters(in: .whitespaces),
unit: unit.trimmingCharacters(in: .whitespaces),
range: range.trimmingCharacters(in: .whitespaces),
status: manualStatus,
note: note.trimmingCharacters(in: .whitespaces).isEmpty ? nil : note,
capturedAt: capturedAt
)
ctx.insert(indicator)
try? ctx.save()
}
}
// MARK: - Status display helpers
private extension IndicatorStatus {
var label: String {
switch self {
case .normal: return String(appLoc: "正常")
case .high: return String(appLoc: "偏高 ↑")
case .low: return String(appLoc: "偏低 ↓")
}
}
var color: Color {
switch self {
case .normal: return Tj.Palette.leaf
case .high: return Tj.Palette.brick
case .low: return Tj.Palette.amber
}
}
}
/// `.sheet(item:)` Identifiable; CustomMonitorMetric? binding
struct CustomMetricEditTarget: Identifiable {
let metric: CustomMonitorMetric?
var id: String { metric?.seriesKey ?? "_new_" }
}
/// "" metric hiddenPresetMetrics
private struct HiddenMonitorRestoreSheet: View {
let hiddenMetrics: [MonitorMetric]
let onRestore: (MonitorMetric) -> Void
@Environment(\.dismiss) private var dismiss
var body: some View {
VStack(spacing: 0) {
Capsule()
.fill(Tj.Palette.line)
.frame(width: 40, height: 4)
.padding(.top, 10)
.padding(.bottom, 14)
HStack {
Text("已隐藏的长期监测")
.font(.tjH2())
.foregroundStyle(Tj.Palette.text)
Spacer()
Button("完成") { dismiss() }
.font(.system(size: 14))
.foregroundStyle(Tj.Palette.ink)
}
.padding(.horizontal, 20)
.padding(.bottom, 12)
ScrollView {
VStack(spacing: 8) {
ForEach(hiddenMetrics) { m in
row(m)
}
}
.padding(.horizontal, 20)
.padding(.bottom, 24)
}
}
.background(Tj.Palette.sand)
.presentationDetents([.medium])
.presentationBackground(Tj.Palette.sand)
.presentationCornerRadius(Tj.Radius.xl)
}
private func row(_ m: MonitorMetric) -> some View {
HStack(spacing: 12) {
Image(systemName: m.icon)
.font(.system(size: 16, weight: .medium))
.foregroundStyle(Tj.Palette.ink)
.frame(width: 32, height: 32)
.background(Circle().fill(Tj.Palette.amber.opacity(0.25)))
Text(m.displayName)
.font(.system(size: 15, weight: .medium))
.foregroundStyle(Tj.Palette.text)
Spacer()
Button("显示") {
onRestore(m)
}
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(Tj.Palette.paper)
.padding(.horizontal, 14)
.padding(.vertical, 6)
.background(Capsule().fill(Tj.Palette.ink))
}
.padding(.horizontal, 12)
.padding(.vertical, 10)
.background(
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
.fill(Tj.Palette.paper)
)
.overlay(
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
.strokeBorder(Tj.Palette.line, lineWidth: 1)
)
}
}
#Preview {
IndicatorQuickSheet()
.modelContainer(for: [
Indicator.self, UserProfile.self,
MetricReminder.self, CustomMonitorMetric.self
], inMemory: true)
}