feat(capture): 统一报告捕获流程并集成视觉语言模型识别

- 替换 QuickCaptureFlow 和 ArchiveFlow 为 UnifiedCaptureFlow 统一流程
- 新增 VLSession 封装 Qwen2.5-VL 模型进行图像文本推理
- 实现 AIRuntime 中 VL 模型的准备和分析功能
- 添加 VLPrompts 定义体检化验单识别的 JSON 输出模板
- 创建 CaptureReviewForm 提供 VL 解析结果的可编辑表单界面
- 集成 VisionKit 文档扫描器支持真机多页文档扫描
- 为模拟器实现 PhotosPicker 回退方案选择已有照片
- 在 RootView 中统一使用 UnifiedCaptureFlow 处理快速和归档流程
- 添加 CustomMetricEditor 支持自定义监测指标的创建编辑删除
- 扩展 KangkangApp 模型配置以支持新数据类型
- 实现档案列表中症状结束功能通过时间线行点击触发
This commit is contained in:
link2026
2026-05-26 11:18:00 +08:00
parent 39edc25dc1
commit 1b01923c8e
27 changed files with 3128 additions and 29 deletions

View File

@@ -0,0 +1,329 @@
import SwiftUI
import SwiftData
let customMetricIconChoices: [String] = [
"circle.fill",
"drop.fill",
"flame.fill",
"bolt.fill",
"leaf.fill",
"pills.fill",
"gauge.high",
"moon.fill",
]
/// `detectNameConflict` UI
enum CustomMetricNameConflict: Equatable {
case none
case builtin(String) // MonitorMetric.displayName
case existingCustom(String) // CustomMonitorMetric.name
var warningText: String {
switch self {
case .none: return ""
case .builtin(let n): return "\(n)」是内置指标的名字 — 录入 grid 里会出现两个同名块"
case .existingCustom(let n):return "已经有一个叫「\(n)」的自定义指标"
}
}
}
/// : candidate name + customs + seriesKey,
/// 便, SwiftData
func detectNameConflict(
candidate: String,
customs: [CustomMonitorMetric],
excludingSeriesKey: String? = nil
) -> CustomMetricNameConflict {
let trimmed = candidate.trimmingCharacters(in: .whitespaces)
guard !trimmed.isEmpty else { return .none }
if MonitorMetric.allCases.contains(where: { $0.displayName == trimmed }) {
return .builtin(trimmed)
}
for c in customs where c.seriesKey != excludingSeriesKey && c.name == trimmed {
return .existingCustom(trimmed)
}
return .none
}
/// create / edit / delete sheet
struct CustomMetricEditor: View {
@Environment(\.modelContext) private var ctx
@Environment(\.dismiss) private var dismiss
/// nil = ; nil =
let existing: CustomMonitorMetric?
/// ,parent setSelectedCustom(metric? ) UI
var onSaved: (CustomMonitorMetric?) -> Void
@Query private var allCustoms: [CustomMonitorMetric]
@State private var name: String = ""
@State private var unit: String = ""
@State private var lower: String = ""
@State private var upper: String = ""
@State private var icon: String = "circle.fill"
@State private var hydrated = false
private var trimmedName: String { name.trimmingCharacters(in: .whitespaces) }
private var trimmedUnit: String { unit.trimmingCharacters(in: .whitespaces) }
private var canSubmit: Bool { !trimmedName.isEmpty }
private var nameConflict: CustomMetricNameConflict {
detectNameConflict(
candidate: name,
customs: allCustoms,
excludingSeriesKey: existing?.seriesKey
)
}
var body: some View {
VStack(spacing: 0) {
Capsule()
.fill(Tj.Palette.line)
.frame(width: 40, height: 4)
.padding(.top, 10)
.padding(.bottom, 14)
header
.padding(.horizontal, 20)
.padding(.bottom, 16)
ScrollView(showsIndicators: false) {
VStack(alignment: .leading, spacing: 18) {
nameSection
unitSection
rangeRow
iconSection
if existing != nil {
deleteButton
}
}
.padding(.horizontal, 20)
.padding(.bottom, 20)
}
footer
}
.background(
Tj.Palette.sand
.clipShape(RoundedRectangle(cornerRadius: Tj.Radius.xl, style: .continuous))
.ignoresSafeArea(edges: .bottom)
)
.presentationDetents([.medium, .large])
.presentationDragIndicator(.hidden)
.presentationBackground(Tj.Palette.sand)
.presentationCornerRadius(Tj.Radius.xl)
.onAppear { hydrate() }
}
private var header: some View {
HStack {
Text(existing == nil ? "新建自定义指标" : "编辑「\(existing!.name)")
.font(.tjH2())
.foregroundStyle(Tj.Palette.text)
Spacer()
if existing == nil {
Text("保存后会出现在录入选项里")
.font(.system(size: 11))
.foregroundStyle(Tj.Palette.text3)
}
}
}
private var nameSection: some View {
VStack(alignment: .leading, spacing: 8) {
sectionLabel("名称")
TextField("例如:腰围 / 步数 / 睡眠时长", text: $name)
.padding(.horizontal, 14).padding(.vertical, 12)
.background(fieldBg)
.overlay(
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
.strokeBorder(
nameConflict == .none ? Tj.Palette.line : Tj.Palette.amber,
lineWidth: 1
)
)
if nameConflict != .none {
HStack(spacing: 6) {
Image(systemName: "exclamationmark.triangle.fill")
.font(.system(size: 11))
.foregroundStyle(Tj.Palette.amber)
Text(nameConflict.warningText)
.font(.system(size: 11))
.foregroundStyle(Tj.Palette.amber)
.fixedSize(horizontal: false, vertical: true)
Spacer(minLength: 0)
}
}
}
}
private var unitSection: some View {
VStack(alignment: .leading, spacing: 8) {
sectionLabel("单位(可选)")
TextField("例如:cm / 步 / 小时", text: $unit)
.autocorrectionDisabled()
.padding(.horizontal, 14).padding(.vertical, 12)
.background(fieldBg).overlay(fieldBorder)
}
}
private var rangeRow: some View {
VStack(alignment: .leading, spacing: 8) {
HStack {
sectionLabel("参考范围(可选)")
Spacer()
Text("用于自动判定 正常/偏高/偏低")
.font(.system(size: 10))
.foregroundStyle(Tj.Palette.text3)
}
HStack(spacing: 12) {
rangeField(label: "下限", value: $lower, placeholder: "70")
Text("").foregroundStyle(Tj.Palette.text3)
rangeField(label: "上限", value: $upper, placeholder: "90")
}
}
}
private func rangeField(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: 16, weight: .medium, design: .monospaced))
.padding(.horizontal, 12).padding(.vertical, 10)
.background(fieldBg).overlay(fieldBorder)
}
}
private var iconSection: some View {
VStack(alignment: .leading, spacing: 8) {
sectionLabel("图标")
LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 8), count: 4),
spacing: 8) {
ForEach(customMetricIconChoices, id: \.self) { sf in
Button {
icon = sf
} label: {
Image(systemName: sf)
.font(.system(size: 20, weight: .medium))
.foregroundStyle(icon == sf ? Tj.Palette.paper : Tj.Palette.ink)
.frame(maxWidth: .infinity, minHeight: 44)
.background(
RoundedRectangle(cornerRadius: 10, style: .continuous)
.fill(icon == sf ? Tj.Palette.ink : Tj.Palette.paper)
)
.overlay(
RoundedRectangle(cornerRadius: 10, style: .continuous)
.strokeBorder(Tj.Palette.line, lineWidth: icon == sf ? 0 : 1)
)
}
.buttonStyle(.plain)
}
}
}
}
private var deleteButton: some View {
Button(role: .destructive) {
if let m = existing {
ReminderService.cancel(metricId: m.seriesKey)
ctx.delete(m)
try? ctx.save()
onSaved(nil)
dismiss()
}
} label: {
HStack {
Image(systemName: "trash")
Text("删除这项自定义指标")
}
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(Tj.Palette.brick)
.frame(maxWidth: .infinity)
.padding(.vertical, 12)
.background(
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
.fill(Tj.Palette.brickSoft.opacity(0.5))
)
}
.buttonStyle(.plain)
.padding(.top, 8)
}
private var footer: some View {
HStack(spacing: 12) {
Button("取消") { dismiss() }
.buttonStyle(TjGhostButton(height: 44, fontSize: 15, horizontalPadding: 18))
Button(existing == nil ? "新建" : "保存") { 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(_ t: String) -> some View {
Text(t).font(.system(size: 12, weight: .semibold)).tracking(0.3)
.foregroundStyle(Tj.Palette.text2)
}
private func hydrate() {
guard !hydrated, let m = existing else { hydrated = true; return }
name = m.name; unit = m.unit; icon = m.icon
lower = m.lowerBound.map { fmt($0) } ?? ""
upper = m.upperBound.map { fmt($0) } ?? ""
hydrated = true
}
private func submit() {
guard canSubmit else { return }
let lo = Double(lower.trimmingCharacters(in: .whitespaces))
let hi = Double(upper.trimmingCharacters(in: .whitespaces))
if let m = existing {
m.name = trimmedName
m.unit = trimmedUnit
m.lowerBound = lo
m.upperBound = hi
m.icon = icon
try? ctx.save()
onSaved(m)
} else {
let m = CustomMonitorMetric(
name: trimmedName,
unit: trimmedUnit,
lowerBound: lo,
upperBound: hi,
icon: icon
)
ctx.insert(m)
try? ctx.save()
onSaved(m)
}
dismiss()
}
private func fmt(_ v: Double) -> String {
v.truncatingRemainder(dividingBy: 1) == 0
? String(format: "%.0f", v)
: String(format: "%.1f", v)
}
}

View File

@@ -48,9 +48,41 @@ struct IndicatorQuickSheet: View {
@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?
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 {
@@ -78,16 +110,18 @@ struct IndicatorQuickSheet: View {
nameSection
valueRow
rangeSection
if selectedMonitor == nil {
// lab preset status ;monitor
statusSection
} else {
if isLongTermMetric {
autoStatusHint
} else {
statusSection
}
}
timeSection
noteSection
if isLongTermMetric {
reminderSection
}
}
.padding(.horizontal, 20)
.padding(.bottom, 20)
@@ -95,6 +129,7 @@ struct IndicatorQuickSheet: View {
footer
}
.task(id: longTermKey) { hydrateReminder() }
.background(
Tj.Palette.sand
.clipShape(RoundedRectangle(cornerRadius: Tj.Radius.xl, style: .continuous))
@@ -138,8 +173,105 @@ struct IndicatorQuickSheet: View {
ForEach(MonitorMetric.allCases) { m in
monitorTile(m)
}
ForEach(customMetrics) { cm in
customTile(cm)
}
addCustomTile
}
}
.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 {
Button { editingCustom = CustomMetricEditTarget(metric: cm) } label: {
Label("编辑", systemImage: "pencil")
}
Button(role: .destructive) {
editingCustom = CustomMetricEditTarget(metric: cm)
} label: {
Label("编辑/删除", systemImage: "trash")
}
}
}
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 {
@@ -265,8 +397,8 @@ struct IndicatorQuickSheet: View {
selectedLabPreset = nil
}
}
.disabled(selectedMonitor != nil)
.opacity(selectedMonitor != nil ? 0.6 : 1)
.disabled(isLongTermMetric)
.opacity(isLongTermMetric ? 0.6 : 1)
}
}
@@ -291,8 +423,8 @@ struct IndicatorQuickSheet: View {
.padding(.vertical, 12)
.background(fieldBg)
.overlay(fieldBorder)
.disabled(selectedMonitor != nil)
.opacity(selectedMonitor != nil ? 0.6 : 1)
.disabled(isLongTermMetric)
.opacity(isLongTermMetric ? 0.6 : 1)
}
.frame(maxWidth: 130)
}
@@ -314,8 +446,8 @@ struct IndicatorQuickSheet: View {
.padding(.vertical, 12)
.background(fieldBg)
.overlay(fieldBorder)
.disabled(selectedMonitor != nil)
.opacity(selectedMonitor != nil ? 0.6 : 1)
.disabled(isLongTermMetric)
.opacity(isLongTermMetric ? 0.6 : 1)
}
}
@@ -376,6 +508,193 @@ struct IndicatorQuickSheet: View {
}
}
// MARK: -
private var reminderSection: some View {
VStack(alignment: .leading, spacing: 10) {
HStack {
sectionLabel("周期提醒")
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("每天") {
reminderWeekdays = Set(1...7)
}
quickFreqChip("工作日") {
reminderWeekdays = Set([2, 3, 4, 5, 6])
}
quickFreqChip("周末") {
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 "每天" }
if reminderWeekdays.isEmpty { return "未选" }
let names = ["", "", "", "", "", "", ""]
let sorted = reminderWeekdays.sorted()
return "每周 " + sorted.map { names[$0 - 1] }.joined()
}
private var weekdayPickerRow: some View {
let names = ["", "", "", "", "", "", ""]
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() }
@@ -476,6 +795,7 @@ struct IndicatorQuickSheet: View {
}
selectedMonitor = m
selectedLabPreset = nil
selectedCustom = nil
if m == .bloodPressure {
// bp , name/value/unit
@@ -500,21 +820,53 @@ struct IndicatorQuickSheet: View {
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 m = selectedMonitor, m != .bloodPressure,
let v = Double(value.trimmingCharacters(in: .whitespaces)) else { return nil }
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)
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 }
@@ -538,10 +890,16 @@ struct IndicatorQuickSheet: View {
saveBP()
} else if let m = selectedMonitor {
saveSingleMonitor(m)
} else if let cm = selectedCustom {
saveCustom(cm)
} else {
saveFreeform()
}
dismiss()
Task {
await persistReminderIfNeeded()
await MainActor.run { dismiss() }
}
}
private func saveBP() {
@@ -603,6 +961,24 @@ struct IndicatorQuickSheet: View {
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),
@@ -638,7 +1014,16 @@ private extension IndicatorStatus {
}
}
/// `.sheet(item:)` Identifiable; CustomMonitorMetric? binding
struct CustomMetricEditTarget: Identifiable {
let metric: CustomMonitorMetric?
var id: String { metric?.seriesKey ?? "_new_" }
}
#Preview {
IndicatorQuickSheet()
.modelContainer(for: [Indicator.self, UserProfile.self], inMemory: true)
.modelContainer(for: [
Indicator.self, UserProfile.self,
MetricReminder.self, CustomMonitorMetric.self
], inMemory: true)
}