Files
kangkang/康康/Features/Indicator/IndicatorQuickSheet.swift
link2026 9d856fcfc4 ```
feat(AI): 集成MNN推理引擎替换MLX作为主AI运行时

- 引入MNN(alibaba) + Arm SME2 + CPU作为主AI运行时,支持A19/iPhone17的
  SME2和A17的NEON加速
- 添加MLX Swift作为兜底GPU推理方案,实现双后端切换机制
- 使用单一Qwen3.5-2B多模态模型(1.2GB),替代原有的LLM+VL分离架构
- 实现InferenceEngine.current引擎选择逻辑,真机默认MNN,模拟器回退MLX
- 更新AIAgent架构,通过MNNLLMBridge(ObjC++) → MNNBackend进行推理
- 修改队列机制防止并发推理导致OOM,使用信号量闸门控制显存占用
- 更新文档中的技术栈说明、模块边界和周次交付计划
```
2026-06-15 09:24:59 +08:00

1380 lines
52 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
}
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
}
.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: - 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)
}