Files
kangkang/康康/Features/Indicator/IndicatorQuickSheet.swift
link2026 de19d7abcd 根据提供的code differences信息,由于没有具体的代码变更内容,我将生成一个通用的commit message模板:
```
docs(readme): 更新文档说明

- 添加了项目使用指南
- 完善了API接口说明
- 修正了一些文字错误
```

注:由于未提供具体的代码差异信息,以上为示例格式。请提供具体的代码变更内容以便生成准确的commit message。
2026-06-17 08:35:59 +08:00

1420 lines
53 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 {
/// RootView : QuickRegionCaptureFlow(VL)
/// nil ( Preview)
var onRequestCamera: (() -> Void)? = nil
/// nil =
/// seriesKey MonitorMetric / CustomMonitorMetric ( + );
/// name/unit/range ,
var prefill: Prefill? = nil
struct Prefill: Equatable {
var seriesKey: String?
var name: String = ""
var unit: String = ""
var range: String = ""
}
@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
//
@State private var didApplyPrefill = false
// :, / /
@State private var searchingMetrics = false
@State private var metricQuery = ""
private var isSearchingMetrics: Bool {
!metricQuery.trimmingCharacters(in: .whitespaces).isEmpty
}
private var filteredMonitorMetrics: [MonitorMetric] {
let q = metricQuery.trimmingCharacters(in: .whitespaces)
guard !q.isEmpty else { return visibleMonitorMetrics }
return visibleMonitorMetrics.filter { $0.displayName.localizedCaseInsensitiveContains(q) }
}
private var filteredCustomMetrics: [CustomMonitorMetric] {
let q = metricQuery.trimmingCharacters(in: .whitespaces)
guard !q.isEmpty else { return customMetrics }
return customMetrics.filter { $0.name.localizedCaseInsensitiveContains(q) }
}
private var filteredLabPresets: [IndicatorPreset] {
let q = metricQuery.trimmingCharacters(in: .whitespaces)
guard !q.isEmpty else { return labPresets }
return labPresets.filter { $0.name.localizedCaseInsensitiveContains(q) }
}
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
}
/// ( / / );
/// / (++)
/// nil = ;,
private var numericValidationError: String? {
func check(_ s: String, min: Double, max: Double, field: String) -> String? {
let t = s.trimmingCharacters(in: .whitespaces)
guard !t.isEmpty else { return nil } //
guard let v = Double(t), v.isFinite else { return String(appLoc: "\(field)请填数字") }
guard v >= min, v <= max else { return String(appLoc: "\(field)数值超出合理范围") }
return nil
}
if isBP {
return check(systolic, min: 30, max: 350, field: String(appLoc: "收缩压"))
?? check(diastolic, min: 20, max: 250, field: String(appLoc: "舒张压"))
}
if isLongTermMetric { // / :
return check(value, min: 0.0001, max: 1_000_000, field: String(appLoc: "数值"))
}
return nil // ( / )
}
private var canSubmit: Bool {
guard numericValidationError == nil else { return false }
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) {
cameraEntrySection
monitorGridSection
labPresetSection
Divider().padding(.vertical, 4)
if isBP {
bpFieldSection
} else {
nameSection
valueRow
rangeSection
if isLongTermMetric {
autoStatusHint
} else {
statusSection
}
}
if let validationError = numericValidationError {
validationHint(validationError)
}
timeSection
noteSection
if isLongTermMetric {
reminderSection
}
}
.padding(.horizontal, 20)
.padding(.bottom, 20)
}
footer
}
.onAppear { applyPrefillIfNeeded() }
.task(id: longTermKey) { hydrateReminder() }
.background(
Tj.Palette.sand
.clipShape(RoundedRectangle(cornerRadius: Tj.Radius.xl, style: .continuous))
.ignoresSafeArea(edges: .bottom)
)
.preferredColorScheme(.light)
.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 {
VStack(spacing: 12) {
HStack(spacing: 10) {
Text("记录指标")
.font(.tjH2())
.foregroundStyle(Tj.Palette.text)
Spacer()
Text("本地处理 · 永不上传")
.font(.tjScaled( 12))
.foregroundStyle(Tj.Palette.text3)
searchToggle
}
if searchingMetrics { searchField }
}
.padding(.horizontal, 20)
.padding(.bottom, 16)
}
private var searchToggle: some View {
Button {
withAnimation(.easeInOut(duration: 0.18)) {
searchingMetrics.toggle()
if !searchingMetrics { metricQuery = "" }
}
} label: {
Image(systemName: searchingMetrics ? "xmark" : "magnifyingglass")
.font(.tjScaled( 14, weight: .semibold))
.foregroundStyle(Tj.Palette.text)
.frame(width: 32, height: 32)
.background(Circle().fill(Tj.Palette.sand2))
}
.buttonStyle(.plain)
.accessibilityLabel(searchingMetrics ? String(appLoc: "关闭搜索") : String(appLoc: "搜索指标"))
}
private var searchField: some View {
HStack(spacing: 8) {
Image(systemName: "magnifyingglass")
.font(.tjScaled( 13))
.foregroundStyle(Tj.Palette.text3)
TextField(String(appLoc: "搜索指标名"), text: $metricQuery)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.foregroundStyle(Tj.Palette.text)
.tint(Tj.Palette.ink)
if !metricQuery.isEmpty {
Button { metricQuery = "" } label: {
Image(systemName: "xmark.circle.fill")
.foregroundStyle(Tj.Palette.text3)
}
.buttonStyle(.plain)
}
}
.padding(.horizontal, 12)
.padding(.vertical, 10)
.background(fieldBg)
.overlay(fieldBorder)
}
/// : RootView VL
@ViewBuilder
private var cameraEntrySection: some View {
if let onRequestCamera {
VStack(alignment: .leading, spacing: 10) {
Button {
onRequestCamera()
} label: {
HStack(spacing: 12) {
ZStack {
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
.fill(Tj.Palette.brick)
Image(systemName: "camera.fill")
.font(.tjScaled(18, weight: .medium))
.foregroundStyle(Tj.Palette.paper)
}
.frame(width: 44, height: 44)
VStack(alignment: .leading, spacing: 2) {
Text("拍照识别")
.font(.tjScaled(15, weight: .semibold))
.foregroundStyle(Tj.Palette.text)
Text("拍化验单,VL 自动读出数值")
.font(.tjScaled(12))
.foregroundStyle(Tj.Palette.text3)
}
Spacer()
Image(systemName: "chevron.right")
.font(.tjScaled(14, weight: .medium))
.foregroundStyle(Tj.Palette.text3)
}
.padding(14)
.frame(maxWidth: .infinity)
.tjCard(bordered: true)
}
.buttonStyle(.plain)
HStack(spacing: 8) {
line
Text("或手动填写")
.font(.tjScaled(11))
.foregroundStyle(Tj.Palette.text3)
.fixedSize()
line
}
}
}
}
private var line: some View {
Rectangle()
.fill(Tj.Palette.lineSoft)
.frame(height: 1)
.frame(maxWidth: .infinity)
}
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(filteredMonitorMetrics) { m in
monitorTile(m)
}
ForEach(filteredCustomMetrics) { cm in
customTile(cm)
}
// (),
if !isSearchingMetrics { addCustomTile }
}
if isSearchingMetrics, filteredMonitorMetrics.isEmpty, filteredCustomMetrics.isEmpty {
Text("没有匹配的长期监测指标")
.font(.tjScaled( 12))
.foregroundStyle(Tj.Palette.text3)
}
}
.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(.tjScaled( 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(.tjScaled( 14, weight: selected ? .semibold : .medium))
.foregroundStyle(selected ? Tj.Palette.paper : Tj.Palette.text)
.lineLimit(1)
Text("自定义")
.font(.tjScaled( 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(.tjScaled( 18, weight: .semibold))
.foregroundStyle(Tj.Palette.text2)
.frame(width: 32, height: 32)
.background(
Circle().strokeBorder(Tj.Palette.line, lineWidth: 1, antialiased: true)
)
Text("自定义")
.font(.tjScaled( 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(.tjScaled( 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(.tjScaled( 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")
}
}
}
@ViewBuilder
private var labPresetSection: some View {
// :()
if !(isSearchingMetrics && filteredLabPresets.isEmpty) {
VStack(alignment: .leading, spacing: 8) {
sectionLabel(String(appLoc: "化验项快捷(不进趋势)"))
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
ForEach(filteredLabPresets) { 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(.tjScaled( 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(.tjScaled( 11)).foregroundStyle(Tj.Palette.text3)
TextField(placeholder, text: value)
.keyboardType(.decimalPad)
.font(.tjScaled( 20, weight: .semibold, design: .monospaced))
.foregroundStyle(Tj.Palette.text)
.tint(Tj.Palette.ink)
.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(.tjScaled( 11, design: .monospaced))
.foregroundStyle(Tj.Palette.text3)
if personalized, let age = profile?.age {
Text("· 按\(age)岁调整")
.font(.tjScaled( 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)
.foregroundStyle(Tj.Palette.text)
.tint(Tj.Palette.ink)
.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(.tjScaled( 18, weight: .semibold, design: .monospaced))
.foregroundStyle(Tj.Palette.text)
.tint(Tj.Palette.ink)
.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()
.foregroundStyle(Tj.Palette.text)
.tint(Tj.Palette.ink)
.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()
.foregroundStyle(Tj.Palette.text)
.tint(Tj.Palette.ink)
.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(.tjScaled( 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(.tjScaled( 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)
.foregroundStyle(Tj.Palette.text)
.tint(Tj.Palette.ink)
.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(.tjScaled( 13))
.foregroundStyle(Tj.Palette.text2)
Spacer()
DatePicker("", selection: $reminderTime,
displayedComponents: .hourAndMinute)
.datePickerStyle(.compact)
.labelsHidden()
}
VStack(alignment: .leading, spacing: 6) {
HStack {
Text("频率")
.font(.tjScaled( 13))
.foregroundStyle(Tj.Palette.text2)
Spacer()
Text(reminderFrequencyLabel)
.font(.tjScaled( 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(.tjScaled( 11))
.foregroundStyle(Tj.Palette.brick)
} else {
Text("本机提醒 · 不发任何数据")
.font(.tjScaled( 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(.tjScaled( 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(.tjScaled( 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(.tjScaled( 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(.tjScaled( 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(.tjScaled( 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(.tjScaled( 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(.tjScaled( 11, weight: .medium))
Image(systemName: "chevron.right")
.font(.tjScaled( 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
/// :seriesKey / ( + ),
/// name/unit/range
private func applyPrefillIfNeeded() {
guard !didApplyPrefill, let p = prefill else { return }
didApplyPrefill = true
if let key = p.seriesKey {
if let m = MonitorMetric.allCases.first(where: { metric in
metric.fields.contains { $0.seriesKey == key }
}) {
applyMonitor(m)
return
}
if let cm = customMetrics.first(where: { $0.seriesKey == key }) {
applyCustom(cm)
return
}
}
// seriesKey ( / / ):, seriesKey,
name = p.name
unit = p.unit
range = p.range
}
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: -
private func validationHint(_ text: String) -> some View {
HStack(spacing: 6) {
Image(systemName: "exclamationmark.circle.fill")
.font(.tjScaled(11))
.foregroundStyle(Tj.Palette.brick)
Text(text)
.font(.tjScaled(12))
.foregroundStyle(Tj.Palette.brick)
Spacer(minLength: 0)
}
.transition(.opacity)
}
// 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(.tjScaled( 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(.tjScaled( 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(.tjScaled( 15, weight: .medium))
.foregroundStyle(Tj.Palette.text)
Spacer()
Button("显示") {
onRestore(m)
}
.font(.tjScaled( 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)
}