Files
kangkang/康康/Features/Indicator/IndicatorQuickSheet.swift
link2026 836f3d4234 ```
feat(AI): 统一多模态模型架构,整合文本和视觉推理路径

- 将文本生成和VL(图→文)功能合并到单一的Qwen3.5-4B多模态MNN模型
- 移除独立的Qwen3-VL-4B模型依赖,MLX VL改为使用.llm的多模态模型
- 更新ModelKind枚举,新增userFacing集合用于面向用户展示
- MNN后端现在同时支持文本和视觉任务,模拟器回退到MLX

refactor(models): 模型管理和界面调整以适应新的多模态架构

- 更新模型管理界面,只显示统一的Qwen3.5-4B(MNN)模型给用户
- 修改就绪状态检查逻辑,使用ModelKind.userFacing替代allCases
- 更新模型文件清单,从Qwen3.5-2B升级到Qwen3.5-4B-4bit
- 调整模型管理页面UI,突出MNN+SME2端侧加速功能

feat(camera): 添加拍照识别引擎切换功能

- 实现双路径拍照识别:Apple Vision OCR + 文本模型 和 Qwen3-VL直接识别
- 添加预处理逻辑,优化Qwen3-VL对窄长区域图片的识别效果
- 在模型管理页面添加拍照识别引擎选择组件
- 提供用户界面选项,在两种识别方式间切换

style(ui): 优化输入框样式和颜色主题一致性

- 为指标快速表单添加浅色主题偏好
- 统一所有文本输入框的颜色样式(theme)
- 创建EntryInputField组件,替换原有的单行输入+按钮模式
- 实现聊天框风格的条目输入,支持多行自适应和圆形发送按钮

fix(build): 修正Xcode项目配置中的重复框架搜索路径

- 清理project.pbxproj中重复的FRAMEWORK_SEARCH_PATHS配置
- 重新排列Swift桥接头文件配置确保正确引用
- 修复因路径配置重复导致的编译警告问题

test: 增加区域图片预处理和模型清单测试覆盖

- 添加RegionImageCropper.prepareForQwenVL的单元测试
- 验证宽而矮图片的放大和填充逻辑
- 更新ModelManifestTests中的字节数预期值以匹配新模型
- 修正OCRService中VNRecognizedTextObservation类型的处理
```
2026-06-08 23:25:31 +08:00

1263 lines
46 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
@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) {
cameraEntrySection
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)
)
.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 {
HStack {
Text("记录指标")
.font(.tjH2())
.foregroundStyle(Tj.Palette.text)
Spacer()
Text("本地处理 · 永不上传")
.font(.tjScaled( 12))
.foregroundStyle(Tj.Palette.text3)
}
.padding(.horizontal, 20)
.padding(.bottom, 16)
}
/// : 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(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(.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")
}
}
}
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(.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
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(.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)
}