- AIRuntime 加 actor 内串行推理闸门,封死 LLM/VL in-flight 并发解码窄口(jetsam OOM 根因) - prepare 的 .loading 改轮询等待消除假就绪竞态;就绪判据 isReady→isComplete 防半下载崩溃 - applyReanalyzed 重新解读时 unlink 旧 Asset,消除 Vault 孤儿图片(§6 隐私承诺) - parseReportJSON 改 extractBalancedJSON + 裸数组兜底,防 VL 多项输出被静默截断丢指标 - 临时文件改 completeUnlessOpen 修锁屏写失败;parseDate 支持多格式防归档年份错位 - TimelineEntry/DayDetailSheet 修「偏高」文案与血压箭头方向(偏低指标不再显示相反结论) - FileVault.wipe 容错;HealthExportSheet 异常关键词排除否定句;modelTag 取实际枚举值 - 删除 B1-B5 + ArchiveFlow 死代码(含违反 §6 的 AES 加密文案) - 补 3 个回归测试,编译 + 测试全部通过
1189 lines
44 KiB
Swift
1189 lines
44 KiB
Swift
import SwiftUI
|
||
import SwiftData
|
||
|
||
/// 化验项 free-form 预设(用户当时手动加的辅助列表)。
|
||
/// 跟 MonitorMetric 不同——这些是「一次性化验单数值」,不进 Trends,无 seriesKey。
|
||
struct IndicatorPreset: Identifiable, Hashable {
|
||
let name: String
|
||
let unit: String
|
||
let range: String
|
||
var id: String { name }
|
||
}
|
||
|
||
private let labPresets: [IndicatorPreset] = [
|
||
.init(name: "LDL-C", unit: "mmol/L", range: "< 3.40"),
|
||
.init(name: "HDL-C", unit: "mmol/L", range: "> 1.04"),
|
||
.init(name: "总胆固醇", unit: "mmol/L", range: "< 5.18"),
|
||
.init(name: "甘油三酯", unit: "mmol/L", range: "< 1.70"),
|
||
.init(name: "ALT", unit: "U/L", range: "9 - 50"),
|
||
.init(name: "尿酸", unit: "μmol/L", range: "208 - 428"),
|
||
.init(name: "血红蛋白", unit: "g/L", range: "130 - 175"),
|
||
]
|
||
|
||
/// 指标录入 sheet,支持 3 种路径:
|
||
/// 1. **长期监测预设**(MonitorMetric)— 选预设后字段自动填,带 seriesKey,
|
||
/// 保存进 Trends。血压拆 2 条 Indicator。
|
||
/// 2. **化验项快捷**(labPresets)— 跟 a 类似但只填 name/unit/range 辅助,
|
||
/// 无 seriesKey,不进 Trends。
|
||
/// 3. **自由输入** — name/value/unit/range 全自己填,status 手动选。
|
||
struct IndicatorQuickSheet: View {
|
||
@Environment(\.modelContext) private var ctx
|
||
@Environment(\.dismiss) private var dismiss
|
||
@Query private var profiles: [UserProfile]
|
||
|
||
// 状态
|
||
@State private var selectedMonitor: MonitorMetric?
|
||
@State private var selectedLabPreset: IndicatorPreset?
|
||
|
||
// 单字段(monitor 单字段、lab、自由)
|
||
@State private var name: String = ""
|
||
@State private var value: String = ""
|
||
@State private var unit: String = ""
|
||
@State private var range: String = ""
|
||
@State private var manualStatus: IndicatorStatus = .normal
|
||
@State private var capturedAt: Date = .now
|
||
@State private var note: String = ""
|
||
|
||
// 血压双字段
|
||
@State private var systolic: String = ""
|
||
@State private var diastolic: String = ""
|
||
|
||
// 周期性提醒(仅长期监测可用)
|
||
@Query private var allReminders: [MetricReminder]
|
||
@State private var reminderEnabled: Bool = false
|
||
@State private var reminderTime: Date = Self.defaultReminderTime
|
||
@State private var reminderWeekdays: Set<Int> = Set(1...7)
|
||
@State private var reminderHydratedFor: String? = nil
|
||
@State private var notifAuthBlocked: Bool = false
|
||
|
||
// 自定义指标
|
||
@Query(sort: \CustomMonitorMetric.createdAt, order: .reverse)
|
||
private var customMetrics: [CustomMonitorMetric]
|
||
@State private var selectedCustom: CustomMonitorMetric?
|
||
@State private var editingCustom: CustomMetricEditTarget?
|
||
|
||
// 隐藏管理 sheet 触发态
|
||
@State private var showHiddenSheet: Bool = false
|
||
|
||
private static var defaultReminderTime: Date {
|
||
Calendar.current.date(bySettingHour: 8, minute: 0, second: 0, of: .now) ?? .now
|
||
}
|
||
|
||
private var profile: UserProfile? { profiles.first }
|
||
|
||
private var isBP: Bool { selectedMonitor == .bloodPressure }
|
||
private var isLongTermMetric: Bool { selectedMonitor != nil || selectedCustom != nil }
|
||
private var isCustomMonitor: Bool { selectedCustom != nil }
|
||
|
||
/// 当前长期监测的稳定 key,用于 reminder 关联和 .task(id:) hydrate 触发。
|
||
/// 血压用 metric.rawValue;custom 用 seriesKey;其他单字段 monitor 用 rawValue;非长期 nil。
|
||
private var longTermKey: String? {
|
||
if let m = selectedMonitor { return m.rawValue }
|
||
if let cm = selectedCustom { return cm.seriesKey }
|
||
return nil
|
||
}
|
||
|
||
private var longTermDisplayName: String? {
|
||
selectedMonitor?.displayName ?? selectedCustom?.name
|
||
}
|
||
|
||
private var canSubmit: Bool {
|
||
if isBP {
|
||
return !systolic.trimmingCharacters(in: .whitespaces).isEmpty &&
|
||
!diastolic.trimmingCharacters(in: .whitespaces).isEmpty
|
||
}
|
||
return !name.trimmingCharacters(in: .whitespaces).isEmpty &&
|
||
!value.trimmingCharacters(in: .whitespaces).isEmpty
|
||
}
|
||
|
||
var body: some View {
|
||
VStack(spacing: 0) {
|
||
handle
|
||
header
|
||
|
||
ScrollView(showsIndicators: false) {
|
||
VStack(alignment: .leading, spacing: 20) {
|
||
monitorGridSection
|
||
labPresetSection
|
||
Divider().padding(.vertical, 4)
|
||
|
||
if isBP {
|
||
bpFieldSection
|
||
} else {
|
||
nameSection
|
||
valueRow
|
||
rangeSection
|
||
if isLongTermMetric {
|
||
autoStatusHint
|
||
} else {
|
||
statusSection
|
||
}
|
||
}
|
||
|
||
timeSection
|
||
noteSection
|
||
if isLongTermMetric {
|
||
reminderSection
|
||
}
|
||
}
|
||
.padding(.horizontal, 20)
|
||
.padding(.bottom, 20)
|
||
}
|
||
|
||
footer
|
||
}
|
||
.task(id: longTermKey) { hydrateReminder() }
|
||
.background(
|
||
Tj.Palette.sand
|
||
.clipShape(RoundedRectangle(cornerRadius: Tj.Radius.xl, style: .continuous))
|
||
.ignoresSafeArea(edges: .bottom)
|
||
)
|
||
.presentationDetents([.large])
|
||
.presentationDragIndicator(.hidden)
|
||
.presentationBackground(Tj.Palette.sand)
|
||
.presentationCornerRadius(Tj.Radius.xl)
|
||
}
|
||
|
||
// MARK: - Sections
|
||
|
||
private var handle: some View {
|
||
Capsule()
|
||
.fill(Tj.Palette.line)
|
||
.frame(width: 40, height: 4)
|
||
.padding(.top, 10)
|
||
.padding(.bottom, 14)
|
||
}
|
||
|
||
private var header: some View {
|
||
HStack {
|
||
Text("记录指标")
|
||
.font(.tjH2())
|
||
.foregroundStyle(Tj.Palette.text)
|
||
Spacer()
|
||
Text("本地处理 · 永不上传")
|
||
.font(.system(size: 12))
|
||
.foregroundStyle(Tj.Palette.text3)
|
||
}
|
||
.padding(.horizontal, 20)
|
||
.padding(.bottom, 16)
|
||
}
|
||
|
||
private var monitorGridSection: some View {
|
||
VStack(alignment: .leading, spacing: 8) {
|
||
HStack {
|
||
sectionLabel(String(appLoc: "长期监测(进趋势)"))
|
||
Spacer()
|
||
if !hiddenSet.isEmpty {
|
||
hiddenCountChip
|
||
}
|
||
}
|
||
let columns = [GridItem(.flexible()), GridItem(.flexible())]
|
||
LazyVGrid(columns: columns, spacing: 8) {
|
||
ForEach(visibleMonitorMetrics) { m in
|
||
monitorTile(m)
|
||
}
|
||
ForEach(customMetrics) { cm in
|
||
customTile(cm)
|
||
}
|
||
addCustomTile
|
||
}
|
||
}
|
||
.sheet(isPresented: $showHiddenSheet) {
|
||
HiddenMonitorRestoreSheet(
|
||
hiddenMetrics: hiddenMonitorMetrics,
|
||
onRestore: { unhideMonitor($0) }
|
||
)
|
||
}
|
||
.sheet(item: $editingCustom) { target in
|
||
CustomMetricEditor(existing: target.metric) { saved in
|
||
// 新建后自动选中,删除后清空选择
|
||
if let saved {
|
||
selectedCustom = saved
|
||
selectedMonitor = nil
|
||
selectedLabPreset = nil
|
||
fillFromCustom(saved)
|
||
} else if selectedCustom?.seriesKey == target.metric?.seriesKey {
|
||
selectedCustom = nil
|
||
clearAllFields()
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
private func customTile(_ cm: CustomMonitorMetric) -> some View {
|
||
let selected = selectedCustom?.seriesKey == cm.seriesKey
|
||
return Button {
|
||
applyCustom(cm)
|
||
} label: {
|
||
HStack(spacing: 10) {
|
||
Image(systemName: cm.icon)
|
||
.font(.system(size: 18, weight: .medium))
|
||
.foregroundStyle(selected ? Tj.Palette.paper : Tj.Palette.ink)
|
||
.frame(width: 32, height: 32)
|
||
.background(Circle().fill(selected ? Tj.Palette.ink : Tj.Palette.leafSoft))
|
||
|
||
VStack(alignment: .leading, spacing: 1) {
|
||
Text(cm.name)
|
||
.font(.system(size: 14, weight: selected ? .semibold : .medium))
|
||
.foregroundStyle(selected ? Tj.Palette.paper : Tj.Palette.text)
|
||
.lineLimit(1)
|
||
Text("自定义")
|
||
.font(.system(size: 9, design: .monospaced))
|
||
.foregroundStyle(selected ? Tj.Palette.paper.opacity(0.7) : Tj.Palette.text3)
|
||
}
|
||
Spacer()
|
||
}
|
||
.padding(.horizontal, 10)
|
||
.padding(.vertical, 10)
|
||
.background(
|
||
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
|
||
.fill(selected ? Tj.Palette.ink : Tj.Palette.paper)
|
||
)
|
||
.overlay(
|
||
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
|
||
.strokeBorder(Tj.Palette.line, lineWidth: selected ? 0 : 1)
|
||
)
|
||
}
|
||
.buttonStyle(.plain)
|
||
.contextMenu {
|
||
// 单一入口:进编辑器既能改也能删(编辑器内含删除按钮)。
|
||
// 旧实现两项 action 完全相同,第二项却标红 trash「编辑/删除」,看似直接删除实则打开编辑器,误导。
|
||
Button { editingCustom = CustomMetricEditTarget(metric: cm) } label: {
|
||
Label("编辑 / 删除", systemImage: "pencil")
|
||
}
|
||
}
|
||
}
|
||
|
||
private var addCustomTile: some View {
|
||
Button {
|
||
editingCustom = CustomMetricEditTarget(metric: nil)
|
||
} label: {
|
||
HStack(spacing: 10) {
|
||
Image(systemName: "plus")
|
||
.font(.system(size: 18, weight: .semibold))
|
||
.foregroundStyle(Tj.Palette.text2)
|
||
.frame(width: 32, height: 32)
|
||
.background(
|
||
Circle().strokeBorder(Tj.Palette.line, lineWidth: 1, antialiased: true)
|
||
)
|
||
Text("自定义")
|
||
.font(.system(size: 14, weight: .medium))
|
||
.foregroundStyle(Tj.Palette.text2)
|
||
Spacer()
|
||
}
|
||
.padding(.horizontal, 10)
|
||
.padding(.vertical, 10)
|
||
.background(
|
||
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
|
||
.fill(Tj.Palette.sand2.opacity(0.5))
|
||
)
|
||
.overlay(
|
||
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
|
||
.strokeBorder(Tj.Palette.line.opacity(0.6),
|
||
style: StrokeStyle(lineWidth: 1, dash: [4, 3]))
|
||
)
|
||
}
|
||
.buttonStyle(.plain)
|
||
}
|
||
|
||
private func monitorTile(_ m: MonitorMetric) -> some View {
|
||
let selected = selectedMonitor == m
|
||
return Button {
|
||
applyMonitor(m)
|
||
} label: {
|
||
HStack(spacing: 10) {
|
||
Image(systemName: m.icon)
|
||
.font(.system(size: 18, weight: .medium))
|
||
.foregroundStyle(selected ? Tj.Palette.paper : Tj.Palette.ink)
|
||
.frame(width: 32, height: 32)
|
||
.background(Circle().fill(selected ? Tj.Palette.ink : Tj.Palette.amber.opacity(0.25)))
|
||
|
||
Text(m.displayName)
|
||
.font(.system(size: 14, weight: selected ? .semibold : .medium))
|
||
.foregroundStyle(selected ? Tj.Palette.paper : Tj.Palette.text)
|
||
Spacer()
|
||
}
|
||
.padding(.horizontal, 10)
|
||
.padding(.vertical, 10)
|
||
.background(
|
||
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
|
||
.fill(selected ? Tj.Palette.ink : Tj.Palette.paper)
|
||
)
|
||
.overlay(
|
||
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
|
||
.strokeBorder(Tj.Palette.line, lineWidth: selected ? 0 : 1)
|
||
)
|
||
}
|
||
.buttonStyle(.plain)
|
||
.contextMenu {
|
||
Button(role: .destructive) {
|
||
hideMonitor(m)
|
||
} label: {
|
||
Label("隐藏", systemImage: "eye.slash")
|
||
}
|
||
}
|
||
}
|
||
|
||
private var labPresetSection: some View {
|
||
VStack(alignment: .leading, spacing: 8) {
|
||
sectionLabel(String(appLoc: "化验项快捷(不进趋势)"))
|
||
ScrollView(.horizontal, showsIndicators: false) {
|
||
HStack(spacing: 8) {
|
||
ForEach(labPresets) { p in
|
||
chip(p.name, selected: selectedLabPreset == p) {
|
||
applyLab(p)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
private var bpFieldSection: some View {
|
||
VStack(alignment: .leading, spacing: 12) {
|
||
HStack {
|
||
sectionLabel(String(appLoc: "收缩 / 舒张"))
|
||
Spacer()
|
||
bpRangeHint
|
||
}
|
||
HStack(spacing: 12) {
|
||
bpField(label: String(appLoc: "收缩压"), value: $systolic, placeholder: "120")
|
||
Text("/").font(.system(size: 22, weight: .light)).foregroundStyle(Tj.Palette.text3)
|
||
bpField(label: String(appLoc: "舒张压"), value: $diastolic, placeholder: "80")
|
||
Text("mmHg").foregroundStyle(Tj.Palette.text3)
|
||
}
|
||
bpStatusChips
|
||
}
|
||
}
|
||
|
||
private func bpField(label: String, value: Binding<String>, placeholder: String) -> some View {
|
||
VStack(alignment: .leading, spacing: 4) {
|
||
Text(label).font(.system(size: 11)).foregroundStyle(Tj.Palette.text3)
|
||
TextField(placeholder, text: value)
|
||
.keyboardType(.decimalPad)
|
||
.font(.system(size: 20, weight: .semibold, design: .monospaced))
|
||
.multilineTextAlignment(.center)
|
||
.padding(.vertical, 10)
|
||
.frame(width: 90)
|
||
.background(fieldBg)
|
||
.overlay(fieldBorder)
|
||
}
|
||
}
|
||
|
||
private var bpRangeHint: some View {
|
||
let sysRange = MonitorMetric.bloodPressure.effectiveRange(
|
||
for: MonitorMetric.bloodPressure.fields[0], profile: profile)
|
||
let diasRange = MonitorMetric.bloodPressure.effectiveRange(
|
||
for: MonitorMetric.bloodPressure.fields[1], profile: profile)
|
||
let personalized = MonitorMetric.bloodPressure.isRangePersonalized(
|
||
for: MonitorMetric.bloodPressure.fields[0], profile: profile)
|
||
let rangeText = "\(formatRange(sysRange)) / \(formatRange(diasRange))"
|
||
return HStack(spacing: 4) {
|
||
Text(rangeText)
|
||
.font(.system(size: 11, design: .monospaced))
|
||
.foregroundStyle(Tj.Palette.text3)
|
||
if personalized, let age = profile?.age {
|
||
Text("· 按\(age)岁调整")
|
||
.font(.system(size: 10))
|
||
.foregroundStyle(Tj.Palette.amber)
|
||
}
|
||
}
|
||
}
|
||
|
||
private var bpStatusChips: some View {
|
||
HStack(spacing: 8) {
|
||
if let s = computedBPStatus(.systolic) {
|
||
statusBadge(String(appLoc: "收缩 ") + s.label, color: s.color)
|
||
}
|
||
if let s = computedBPStatus(.diastolic) {
|
||
statusBadge(String(appLoc: "舒张 ") + s.label, color: s.color)
|
||
}
|
||
Spacer()
|
||
}
|
||
}
|
||
|
||
private var nameSection: some View {
|
||
VStack(alignment: .leading, spacing: 8) {
|
||
sectionLabel(String(appLoc: "指标名"))
|
||
TextField("例如:血红蛋白", text: $name)
|
||
.textInputAutocapitalization(.never)
|
||
.padding(.horizontal, 14)
|
||
.padding(.vertical, 12)
|
||
.background(fieldBg)
|
||
.overlay(fieldBorder)
|
||
.onChange(of: name) { _, _ in
|
||
if let p = selectedLabPreset, p.name != name {
|
||
selectedLabPreset = nil
|
||
}
|
||
}
|
||
.disabled(isLongTermMetric)
|
||
.opacity(isLongTermMetric ? 0.6 : 1)
|
||
}
|
||
}
|
||
|
||
private var valueRow: some View {
|
||
HStack(alignment: .top, spacing: 12) {
|
||
VStack(alignment: .leading, spacing: 8) {
|
||
sectionLabel(String(appLoc: "数值"))
|
||
TextField(monitorFieldPlaceholder, text: $value)
|
||
.keyboardType(.decimalPad)
|
||
.font(.system(size: 18, weight: .semibold, design: .monospaced))
|
||
.padding(.horizontal, 14)
|
||
.padding(.vertical, 12)
|
||
.background(fieldBg)
|
||
.overlay(fieldBorder)
|
||
}
|
||
VStack(alignment: .leading, spacing: 8) {
|
||
sectionLabel(String(appLoc: "单位"))
|
||
TextField("mmol/L", text: $unit)
|
||
.textInputAutocapitalization(.never)
|
||
.autocorrectionDisabled()
|
||
.padding(.horizontal, 14)
|
||
.padding(.vertical, 12)
|
||
.background(fieldBg)
|
||
.overlay(fieldBorder)
|
||
.disabled(isLongTermMetric)
|
||
.opacity(isLongTermMetric ? 0.6 : 1)
|
||
}
|
||
.frame(maxWidth: 130)
|
||
}
|
||
}
|
||
|
||
private var rangeSection: some View {
|
||
VStack(alignment: .leading, spacing: 8) {
|
||
HStack {
|
||
sectionLabel(String(appLoc: "参考范围"))
|
||
Spacer()
|
||
if let m = selectedMonitor, m != .bloodPressure {
|
||
monitorRangeHint(m)
|
||
}
|
||
}
|
||
TextField("例如:< 3.40 或 3.9 - 6.1", text: $range)
|
||
.textInputAutocapitalization(.never)
|
||
.autocorrectionDisabled()
|
||
.padding(.horizontal, 14)
|
||
.padding(.vertical, 12)
|
||
.background(fieldBg)
|
||
.overlay(fieldBorder)
|
||
.disabled(isLongTermMetric)
|
||
.opacity(isLongTermMetric ? 0.6 : 1)
|
||
}
|
||
}
|
||
|
||
private func monitorRangeHint(_ m: MonitorMetric) -> some View {
|
||
let personalized = m.isRangePersonalized(for: m.fields[0], profile: profile)
|
||
return HStack(spacing: 4) {
|
||
if personalized, let age = profile?.age {
|
||
Text("按\(age)岁调整")
|
||
.font(.system(size: 10))
|
||
.foregroundStyle(Tj.Palette.amber)
|
||
}
|
||
}
|
||
}
|
||
|
||
private var statusSection: some View {
|
||
VStack(alignment: .leading, spacing: 8) {
|
||
sectionLabel(String(appLoc: "状态"))
|
||
HStack(spacing: 8) {
|
||
statusChip(.normal, label: String(appLoc: "正常"), color: Tj.Palette.leaf)
|
||
statusChip(.high, label: String(appLoc: "偏高 ↑"), color: Tj.Palette.brick)
|
||
statusChip(.low, label: String(appLoc: "偏低 ↓"), color: Tj.Palette.amber)
|
||
}
|
||
}
|
||
}
|
||
|
||
private var autoStatusHint: some View {
|
||
let auto = computedSingleStatus
|
||
return HStack(spacing: 8) {
|
||
sectionLabel(String(appLoc: "状态(按数值自动判)"))
|
||
if let s = auto {
|
||
statusBadge(s.label, color: s.color)
|
||
} else {
|
||
Text("待输入")
|
||
.font(.system(size: 12))
|
||
.foregroundStyle(Tj.Palette.text3)
|
||
}
|
||
}
|
||
}
|
||
|
||
private var timeSection: some View {
|
||
VStack(alignment: .leading, spacing: 8) {
|
||
sectionLabel(String(appLoc: "测量时间"))
|
||
DatePicker("", selection: $capturedAt, in: ...Date.now)
|
||
.datePickerStyle(.compact)
|
||
.labelsHidden()
|
||
}
|
||
}
|
||
|
||
private var noteSection: some View {
|
||
VStack(alignment: .leading, spacing: 8) {
|
||
sectionLabel(String(appLoc: "备注(可选)"))
|
||
TextField("例如:空腹采血", text: $note, axis: .vertical)
|
||
.lineLimit(1...3)
|
||
.padding(.horizontal, 14)
|
||
.padding(.vertical, 12)
|
||
.background(fieldBg)
|
||
.overlay(fieldBorder)
|
||
}
|
||
}
|
||
|
||
// MARK: - 周期提醒
|
||
|
||
private var reminderSection: some View {
|
||
VStack(alignment: .leading, spacing: 10) {
|
||
HStack {
|
||
sectionLabel(String(appLoc: "周期提醒"))
|
||
Spacer()
|
||
Toggle("", isOn: $reminderEnabled)
|
||
.labelsHidden()
|
||
.tint(Tj.Palette.ink)
|
||
.onChange(of: reminderEnabled) { _, on in
|
||
if on { Task { await requestNotifAuthIfNeeded() } }
|
||
}
|
||
}
|
||
|
||
if reminderEnabled {
|
||
VStack(alignment: .leading, spacing: 12) {
|
||
HStack {
|
||
Text("时间")
|
||
.font(.system(size: 13))
|
||
.foregroundStyle(Tj.Palette.text2)
|
||
Spacer()
|
||
DatePicker("", selection: $reminderTime,
|
||
displayedComponents: .hourAndMinute)
|
||
.datePickerStyle(.compact)
|
||
.labelsHidden()
|
||
}
|
||
|
||
VStack(alignment: .leading, spacing: 6) {
|
||
HStack {
|
||
Text("频率")
|
||
.font(.system(size: 13))
|
||
.foregroundStyle(Tj.Palette.text2)
|
||
Spacer()
|
||
Text(reminderFrequencyLabel)
|
||
.font(.system(size: 12))
|
||
.foregroundStyle(Tj.Palette.text3)
|
||
}
|
||
weekdayPickerRow
|
||
HStack(spacing: 8) {
|
||
quickFreqChip(String(appLoc: "每天")) {
|
||
reminderWeekdays = Set(1...7)
|
||
}
|
||
quickFreqChip(String(appLoc: "工作日")) {
|
||
reminderWeekdays = Set([2, 3, 4, 5, 6])
|
||
}
|
||
quickFreqChip(String(appLoc: "周末")) {
|
||
reminderWeekdays = Set([1, 7])
|
||
}
|
||
}
|
||
}
|
||
|
||
if notifAuthBlocked {
|
||
Text("⚠️ 通知权限已关闭,去「设置 → 康康 → 通知」打开")
|
||
.font(.system(size: 11))
|
||
.foregroundStyle(Tj.Palette.brick)
|
||
} else {
|
||
Text("本机提醒 · 不发任何数据")
|
||
.font(.system(size: 11))
|
||
.foregroundStyle(Tj.Palette.text3)
|
||
}
|
||
}
|
||
.padding(12)
|
||
.background(fieldBg)
|
||
.overlay(fieldBorder)
|
||
}
|
||
}
|
||
}
|
||
|
||
private var reminderFrequencyLabel: String {
|
||
if reminderWeekdays.count == 7 { return String(appLoc: "每天") }
|
||
if reminderWeekdays.isEmpty { return String(appLoc: "未选") }
|
||
let names = [
|
||
String(appLoc: "日"), String(appLoc: "一"), String(appLoc: "二"),
|
||
String(appLoc: "三"), String(appLoc: "四"), String(appLoc: "五"),
|
||
String(appLoc: "六"),
|
||
]
|
||
let sorted = reminderWeekdays.sorted()
|
||
return String(appLoc: "每周 ") + sorted.map { names[$0 - 1] }.joined()
|
||
}
|
||
|
||
private var weekdayPickerRow: some View {
|
||
let names = [
|
||
String(appLoc: "一"), String(appLoc: "二"), String(appLoc: "三"),
|
||
String(appLoc: "四"), String(appLoc: "五"), String(appLoc: "六"),
|
||
String(appLoc: "日"),
|
||
]
|
||
let weekdayValues = [2, 3, 4, 5, 6, 7, 1] // 周一到周日(Apple Calendar 编号)
|
||
return HStack(spacing: 6) {
|
||
ForEach(Array(weekdayValues.enumerated()), id: \.offset) { idx, w in
|
||
Button {
|
||
if reminderWeekdays.contains(w) {
|
||
reminderWeekdays.remove(w)
|
||
} else {
|
||
reminderWeekdays.insert(w)
|
||
}
|
||
} label: {
|
||
Text(names[idx])
|
||
.font(.system(size: 13,
|
||
weight: reminderWeekdays.contains(w) ? .semibold : .regular))
|
||
.foregroundStyle(reminderWeekdays.contains(w) ? Tj.Palette.paper : Tj.Palette.text)
|
||
.frame(maxWidth: .infinity, minHeight: 32)
|
||
.background(
|
||
RoundedRectangle(cornerRadius: 8, style: .continuous)
|
||
.fill(reminderWeekdays.contains(w) ? Tj.Palette.ink : Tj.Palette.paper)
|
||
)
|
||
.overlay(
|
||
RoundedRectangle(cornerRadius: 8, style: .continuous)
|
||
.strokeBorder(Tj.Palette.line,
|
||
lineWidth: reminderWeekdays.contains(w) ? 0 : 1)
|
||
)
|
||
}
|
||
.buttonStyle(.plain)
|
||
}
|
||
}
|
||
}
|
||
|
||
private func quickFreqChip(_ label: String, action: @escaping () -> Void) -> some View {
|
||
Button(action: action) {
|
||
Text(label)
|
||
.font(.system(size: 11))
|
||
.foregroundStyle(Tj.Palette.text2)
|
||
.padding(.horizontal, 10)
|
||
.padding(.vertical, 4)
|
||
.background(Capsule().fill(Tj.Palette.sand2))
|
||
}
|
||
.buttonStyle(.plain)
|
||
}
|
||
|
||
private func hydrateReminder() {
|
||
guard let key = longTermKey else { return }
|
||
if reminderHydratedFor == key { return }
|
||
reminderHydratedFor = key
|
||
if let existing = allReminders.first(where: { $0.metricId == key }) {
|
||
reminderEnabled = existing.enabled
|
||
reminderTime = Calendar.current.date(
|
||
bySettingHour: existing.hour, minute: existing.minute, second: 0, of: .now
|
||
) ?? Self.defaultReminderTime
|
||
reminderWeekdays = Set(existing.weekdays)
|
||
} else {
|
||
reminderEnabled = false
|
||
reminderTime = Self.defaultReminderTime
|
||
reminderWeekdays = Set(1...7)
|
||
}
|
||
}
|
||
|
||
private func requestNotifAuthIfNeeded() async {
|
||
let state = await ReminderService.requestAuthorization()
|
||
notifAuthBlocked = (state == .denied)
|
||
if notifAuthBlocked {
|
||
reminderEnabled = false
|
||
}
|
||
}
|
||
|
||
/// submit() 调用,处理提醒:enabled → upsert SwiftData + 调度通知;disabled → 删旧 reminder + 取消通知。
|
||
private func persistReminderIfNeeded() async {
|
||
guard let key = longTermKey, let displayName = longTermDisplayName else { return }
|
||
let existing = allReminders.first(where: { $0.metricId == key })
|
||
let cal = Calendar.current
|
||
let hour = cal.component(.hour, from: reminderTime)
|
||
let minute = cal.component(.minute, from: reminderTime)
|
||
|
||
if reminderEnabled && !reminderWeekdays.isEmpty {
|
||
let reminder: MetricReminder
|
||
if let existing {
|
||
existing.enabled = true
|
||
existing.hour = hour
|
||
existing.minute = minute
|
||
existing.weekdays = reminderWeekdays.sorted()
|
||
existing.displayName = displayName
|
||
existing.updatedAt = .now
|
||
reminder = existing
|
||
} else {
|
||
let new = MetricReminder(
|
||
metricId: key,
|
||
displayName: displayName,
|
||
hour: hour,
|
||
minute: minute,
|
||
weekdays: reminderWeekdays.sorted(),
|
||
enabled: true
|
||
)
|
||
ctx.insert(new)
|
||
reminder = new
|
||
}
|
||
try? ctx.save()
|
||
await ReminderService.sync(reminder)
|
||
} else if let existing {
|
||
// 关闭:保留 SwiftData 行,只改 enabled = false,取消通知
|
||
existing.enabled = false
|
||
existing.updatedAt = .now
|
||
try? ctx.save()
|
||
ReminderService.cancel(metricId: key)
|
||
}
|
||
}
|
||
|
||
private var footer: some View {
|
||
HStack(spacing: 12) {
|
||
Button("取消") { dismiss() }
|
||
.buttonStyle(TjGhostButton(height: 44, fontSize: 15, horizontalPadding: 18))
|
||
Button("保存到记录") { submit() }
|
||
.buttonStyle(TjPrimaryButton(height: 44, fontSize: 15, horizontalPadding: 18))
|
||
.disabled(!canSubmit)
|
||
.opacity(canSubmit ? 1 : 0.4)
|
||
}
|
||
.padding(.horizontal, 20)
|
||
.padding(.vertical, 14)
|
||
.background(
|
||
Tj.Palette.sand
|
||
.overlay(alignment: .top) {
|
||
Rectangle().fill(Tj.Palette.lineSoft).frame(height: 1)
|
||
}
|
||
)
|
||
}
|
||
|
||
// MARK: - helpers
|
||
|
||
private var fieldBg: some View {
|
||
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
|
||
.fill(Tj.Palette.paper)
|
||
}
|
||
|
||
private var fieldBorder: some View {
|
||
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
|
||
.strokeBorder(Tj.Palette.line, lineWidth: 1)
|
||
}
|
||
|
||
private func sectionLabel(_ text: String) -> some View {
|
||
Text(text)
|
||
.font(.system(size: 12, weight: .semibold))
|
||
.tracking(0.3)
|
||
.foregroundStyle(Tj.Palette.text2)
|
||
}
|
||
|
||
private func chip(_ label: String, selected: Bool, action: @escaping () -> Void) -> some View {
|
||
Button(action: action) {
|
||
Text(label)
|
||
.font(.system(size: 13, weight: selected ? .semibold : .regular))
|
||
.foregroundStyle(selected ? Tj.Palette.paper : Tj.Palette.text)
|
||
.padding(.horizontal, 14)
|
||
.padding(.vertical, 8)
|
||
.background(Capsule().fill(selected ? Tj.Palette.ink : Tj.Palette.paper))
|
||
.overlay(Capsule().strokeBorder(Tj.Palette.line, lineWidth: selected ? 0 : 1))
|
||
}
|
||
.buttonStyle(.plain)
|
||
}
|
||
|
||
private func statusChip(_ value: IndicatorStatus, label: String, color: Color) -> some View {
|
||
let selected = manualStatus == value
|
||
return Button {
|
||
manualStatus = value
|
||
} label: {
|
||
Text(label)
|
||
.font(.system(size: 13, weight: selected ? .semibold : .regular))
|
||
.foregroundStyle(selected ? Tj.Palette.paper : color)
|
||
.padding(.horizontal, 14)
|
||
.padding(.vertical, 8)
|
||
.frame(maxWidth: .infinity)
|
||
.background(Capsule().fill(selected ? color : Tj.Palette.paper))
|
||
.overlay(Capsule().strokeBorder(color.opacity(selected ? 0 : 0.5), lineWidth: 1))
|
||
}
|
||
.buttonStyle(.plain)
|
||
}
|
||
|
||
private func statusBadge(_ label: String, color: Color) -> some View {
|
||
Text(label)
|
||
.font(.system(size: 11, weight: .semibold))
|
||
.foregroundStyle(color)
|
||
.padding(.horizontal, 10)
|
||
.padding(.vertical, 4)
|
||
.background(Capsule().fill(color.opacity(0.18)))
|
||
}
|
||
|
||
private var monitorFieldPlaceholder: String {
|
||
selectedMonitor?.fields.first?.placeholder ?? "3.84"
|
||
}
|
||
|
||
private func formatRange(_ r: ClosedRange<Double>?) -> String {
|
||
guard let r = r else { return "—" }
|
||
let fmt = { (v: Double) in
|
||
v.truncatingRemainder(dividingBy: 1) == 0
|
||
? String(format: "%.0f", v) : String(format: "%.1f", v)
|
||
}
|
||
return "\(fmt(r.lowerBound))–\(fmt(r.upperBound))"
|
||
}
|
||
|
||
// MARK: - hidden preset 管理
|
||
|
||
private var hiddenSet: Set<String> {
|
||
Set(profile?.hiddenPresetMetrics ?? [])
|
||
}
|
||
|
||
private var visibleMonitorMetrics: [MonitorMetric] {
|
||
MonitorMetric.allCases.filter { !hiddenSet.contains($0.rawValue) }
|
||
}
|
||
|
||
private var hiddenMonitorMetrics: [MonitorMetric] {
|
||
MonitorMetric.allCases.filter { hiddenSet.contains($0.rawValue) }
|
||
}
|
||
|
||
private var hiddenCountChip: some View {
|
||
Button {
|
||
showHiddenSheet = true
|
||
} label: {
|
||
HStack(spacing: 3) {
|
||
Text("已隐藏 \(hiddenSet.count)")
|
||
.font(.system(size: 11, weight: .medium))
|
||
Image(systemName: "chevron.right")
|
||
.font(.system(size: 9, weight: .semibold))
|
||
}
|
||
.foregroundStyle(Tj.Palette.text2)
|
||
.padding(.horizontal, 10)
|
||
.padding(.vertical, 4)
|
||
.background(Capsule().fill(Tj.Palette.sand2))
|
||
}
|
||
.buttonStyle(.plain)
|
||
}
|
||
|
||
private func hideMonitor(_ m: MonitorMetric) {
|
||
let profile = UserProfileStore.loadOrCreate(in: ctx)
|
||
guard !profile.hiddenPresetMetrics.contains(m.rawValue) else { return }
|
||
profile.hiddenPresetMetrics.append(m.rawValue)
|
||
profile.updatedAt = .now
|
||
try? ctx.save()
|
||
if selectedMonitor == m {
|
||
clearMonitor()
|
||
}
|
||
}
|
||
|
||
private func unhideMonitor(_ m: MonitorMetric) {
|
||
guard let profile = profile else { return }
|
||
profile.hiddenPresetMetrics.removeAll { $0 == m.rawValue }
|
||
profile.updatedAt = .now
|
||
try? ctx.save()
|
||
if profile.hiddenPresetMetrics.isEmpty {
|
||
showHiddenSheet = false
|
||
}
|
||
}
|
||
|
||
// MARK: - apply preset
|
||
|
||
private func applyMonitor(_ m: MonitorMetric) {
|
||
if selectedMonitor == m {
|
||
// 取消选择
|
||
clearMonitor()
|
||
return
|
||
}
|
||
selectedMonitor = m
|
||
selectedLabPreset = nil
|
||
selectedCustom = nil
|
||
|
||
if m == .bloodPressure {
|
||
// 血压走 bp 字段,不动 name/value/unit
|
||
name = m.displayName
|
||
unit = "mmHg"
|
||
} else {
|
||
let f = m.fields[0]
|
||
name = m.displayName
|
||
value = ""
|
||
unit = f.unit
|
||
let r = m.effectiveRange(for: f, profile: profile)
|
||
range = f.rangeText(r)
|
||
}
|
||
}
|
||
|
||
private func clearMonitor() {
|
||
selectedMonitor = nil
|
||
name = ""; value = ""; unit = ""; range = ""
|
||
systolic = ""; diastolic = ""
|
||
}
|
||
|
||
private func applyLab(_ p: IndicatorPreset) {
|
||
selectedLabPreset = p
|
||
selectedMonitor = nil
|
||
selectedCustom = nil
|
||
systolic = ""; diastolic = ""
|
||
name = p.name
|
||
if unit.trimmingCharacters(in: .whitespaces).isEmpty { unit = p.unit }
|
||
if range.trimmingCharacters(in: .whitespaces).isEmpty { range = p.range }
|
||
}
|
||
|
||
private func applyCustom(_ cm: CustomMonitorMetric) {
|
||
if selectedCustom?.seriesKey == cm.seriesKey {
|
||
selectedCustom = nil
|
||
clearAllFields()
|
||
return
|
||
}
|
||
selectedCustom = cm
|
||
selectedMonitor = nil
|
||
selectedLabPreset = nil
|
||
fillFromCustom(cm)
|
||
}
|
||
|
||
private func fillFromCustom(_ cm: CustomMonitorMetric) {
|
||
name = cm.name
|
||
value = ""
|
||
unit = cm.unit
|
||
range = cm.rangeText
|
||
systolic = ""; diastolic = ""
|
||
}
|
||
|
||
private func clearAllFields() {
|
||
name = ""; value = ""; unit = ""; range = ""
|
||
systolic = ""; diastolic = ""
|
||
}
|
||
|
||
// MARK: - auto status
|
||
|
||
private var computedSingleStatus: (label: String, color: Color)? {
|
||
guard let v = Double(value.trimmingCharacters(in: .whitespaces)) else { return nil }
|
||
if let m = selectedMonitor, m != .bloodPressure {
|
||
let f = m.fields[0]
|
||
let r = m.effectiveRange(for: f, profile: profile)
|
||
let s = MonitorMetric.status(value: v, in: r)
|
||
return (s.label, s.color)
|
||
}
|
||
if let cm = selectedCustom {
|
||
let s = MonitorMetric.status(value: v, in: cm.referenceRange)
|
||
return (s.label, s.color)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
private enum BPSide { case systolic, diastolic }
|
||
|
||
private func computedBPStatus(_ side: BPSide) -> (label: String, color: Color)? {
|
||
let text = side == .systolic ? systolic : diastolic
|
||
guard let v = Double(text.trimmingCharacters(in: .whitespaces)) else { return nil }
|
||
let m = MonitorMetric.bloodPressure
|
||
let f = m.fields[side == .systolic ? 0 : 1]
|
||
let r = m.effectiveRange(for: f, profile: profile)
|
||
let s = MonitorMetric.status(value: v, in: r)
|
||
return (s.label, s.color)
|
||
}
|
||
|
||
// MARK: - submit
|
||
|
||
private func submit() {
|
||
guard canSubmit else { return }
|
||
|
||
if isBP {
|
||
saveBP()
|
||
} else if let m = selectedMonitor {
|
||
saveSingleMonitor(m)
|
||
} else if let cm = selectedCustom {
|
||
saveCustom(cm)
|
||
} else {
|
||
saveFreeform()
|
||
}
|
||
|
||
Task {
|
||
await persistReminderIfNeeded()
|
||
await MainActor.run { dismiss() }
|
||
}
|
||
}
|
||
|
||
private func saveBP() {
|
||
let m = MonitorMetric.bloodPressure
|
||
let sys = Double(systolic.trimmingCharacters(in: .whitespaces)) ?? 0
|
||
let dia = Double(diastolic.trimmingCharacters(in: .whitespaces)) ?? 0
|
||
let sysField = m.fields[0]
|
||
let diaField = m.fields[1]
|
||
let sysRange = m.effectiveRange(for: sysField, profile: profile)
|
||
let diaRange = m.effectiveRange(for: diaField, profile: profile)
|
||
let sysStatus = MonitorMetric.status(value: sys, in: sysRange)
|
||
let diaStatus = MonitorMetric.status(value: dia, in: diaRange)
|
||
|
||
let timestamp = capturedAt
|
||
let systolicI = Indicator(
|
||
name: sysField.label,
|
||
value: systolic,
|
||
unit: sysField.unit,
|
||
range: sysField.rangeText(sysRange),
|
||
status: sysStatus,
|
||
note: note.isEmpty ? nil : note,
|
||
capturedAt: timestamp,
|
||
pinned: true,
|
||
seriesKey: sysField.seriesKey
|
||
)
|
||
let diastolicI = Indicator(
|
||
name: diaField.label,
|
||
value: diastolic,
|
||
unit: diaField.unit,
|
||
range: diaField.rangeText(diaRange),
|
||
status: diaStatus,
|
||
note: nil,
|
||
capturedAt: timestamp,
|
||
pinned: true,
|
||
seriesKey: diaField.seriesKey
|
||
)
|
||
ctx.insert(systolicI)
|
||
ctx.insert(diastolicI)
|
||
try? ctx.save()
|
||
}
|
||
|
||
private func saveSingleMonitor(_ m: MonitorMetric) {
|
||
let f = m.fields[0]
|
||
let v = Double(value.trimmingCharacters(in: .whitespaces)) ?? 0
|
||
let r = m.effectiveRange(for: f, profile: profile)
|
||
let status = MonitorMetric.status(value: v, in: r)
|
||
let indicator = Indicator(
|
||
name: m.displayName,
|
||
value: value.trimmingCharacters(in: .whitespaces),
|
||
unit: f.unit,
|
||
range: f.rangeText(r),
|
||
status: status,
|
||
note: note.isEmpty ? nil : note,
|
||
capturedAt: capturedAt,
|
||
pinned: true,
|
||
seriesKey: f.seriesKey
|
||
)
|
||
ctx.insert(indicator)
|
||
try? ctx.save()
|
||
}
|
||
|
||
private func saveCustom(_ cm: CustomMonitorMetric) {
|
||
let v = Double(value.trimmingCharacters(in: .whitespaces)) ?? 0
|
||
let status = MonitorMetric.status(value: v, in: cm.referenceRange)
|
||
let indicator = Indicator(
|
||
name: cm.name,
|
||
value: value.trimmingCharacters(in: .whitespaces),
|
||
unit: cm.unit,
|
||
range: cm.rangeText,
|
||
status: status,
|
||
note: note.isEmpty ? nil : note,
|
||
capturedAt: capturedAt,
|
||
pinned: true,
|
||
seriesKey: cm.seriesKey
|
||
)
|
||
ctx.insert(indicator)
|
||
try? ctx.save()
|
||
}
|
||
|
||
private func saveFreeform() {
|
||
let indicator = Indicator(
|
||
name: name.trimmingCharacters(in: .whitespaces),
|
||
value: value.trimmingCharacters(in: .whitespaces),
|
||
unit: unit.trimmingCharacters(in: .whitespaces),
|
||
range: range.trimmingCharacters(in: .whitespaces),
|
||
status: manualStatus,
|
||
note: note.trimmingCharacters(in: .whitespaces).isEmpty ? nil : note,
|
||
capturedAt: capturedAt
|
||
)
|
||
ctx.insert(indicator)
|
||
try? ctx.save()
|
||
}
|
||
}
|
||
|
||
// MARK: - Status display helpers
|
||
|
||
private extension IndicatorStatus {
|
||
var label: String {
|
||
switch self {
|
||
case .normal: return String(appLoc: "正常")
|
||
case .high: return String(appLoc: "偏高 ↑")
|
||
case .low: return String(appLoc: "偏低 ↓")
|
||
}
|
||
}
|
||
|
||
var color: Color {
|
||
switch self {
|
||
case .normal: return Tj.Palette.leaf
|
||
case .high: return Tj.Palette.brick
|
||
case .low: return Tj.Palette.amber
|
||
}
|
||
}
|
||
}
|
||
|
||
/// `.sheet(item:)` 要求 Identifiable;包一层避免 CustomMonitorMetric? 不能直接当 binding 用。
|
||
struct CustomMetricEditTarget: Identifiable {
|
||
let metric: CustomMonitorMetric?
|
||
var id: String { metric?.seriesKey ?? "_new_" }
|
||
}
|
||
|
||
/// 已隐藏的长期监测预设恢复列表。点"显示"把对应 metric 从 hiddenPresetMetrics 移除。
|
||
private struct HiddenMonitorRestoreSheet: View {
|
||
let hiddenMetrics: [MonitorMetric]
|
||
let onRestore: (MonitorMetric) -> Void
|
||
|
||
@Environment(\.dismiss) private var dismiss
|
||
|
||
var body: some View {
|
||
VStack(spacing: 0) {
|
||
Capsule()
|
||
.fill(Tj.Palette.line)
|
||
.frame(width: 40, height: 4)
|
||
.padding(.top, 10)
|
||
.padding(.bottom, 14)
|
||
|
||
HStack {
|
||
Text("已隐藏的长期监测")
|
||
.font(.tjH2())
|
||
.foregroundStyle(Tj.Palette.text)
|
||
Spacer()
|
||
Button("完成") { dismiss() }
|
||
.font(.system(size: 14))
|
||
.foregroundStyle(Tj.Palette.ink)
|
||
}
|
||
.padding(.horizontal, 20)
|
||
.padding(.bottom, 12)
|
||
|
||
ScrollView {
|
||
VStack(spacing: 8) {
|
||
ForEach(hiddenMetrics) { m in
|
||
row(m)
|
||
}
|
||
}
|
||
.padding(.horizontal, 20)
|
||
.padding(.bottom, 24)
|
||
}
|
||
}
|
||
.background(Tj.Palette.sand)
|
||
.presentationDetents([.medium])
|
||
.presentationBackground(Tj.Palette.sand)
|
||
.presentationCornerRadius(Tj.Radius.xl)
|
||
}
|
||
|
||
private func row(_ m: MonitorMetric) -> some View {
|
||
HStack(spacing: 12) {
|
||
Image(systemName: m.icon)
|
||
.font(.system(size: 16, weight: .medium))
|
||
.foregroundStyle(Tj.Palette.ink)
|
||
.frame(width: 32, height: 32)
|
||
.background(Circle().fill(Tj.Palette.amber.opacity(0.25)))
|
||
|
||
Text(m.displayName)
|
||
.font(.system(size: 15, weight: .medium))
|
||
.foregroundStyle(Tj.Palette.text)
|
||
|
||
Spacer()
|
||
|
||
Button("显示") {
|
||
onRestore(m)
|
||
}
|
||
.font(.system(size: 13, weight: .semibold))
|
||
.foregroundStyle(Tj.Palette.paper)
|
||
.padding(.horizontal, 14)
|
||
.padding(.vertical, 6)
|
||
.background(Capsule().fill(Tj.Palette.ink))
|
||
}
|
||
.padding(.horizontal, 12)
|
||
.padding(.vertical, 10)
|
||
.background(
|
||
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
|
||
.fill(Tj.Palette.paper)
|
||
)
|
||
.overlay(
|
||
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
|
||
.strokeBorder(Tj.Palette.line, lineWidth: 1)
|
||
)
|
||
}
|
||
}
|
||
|
||
#Preview {
|
||
IndicatorQuickSheet()
|
||
.modelContainer(for: [
|
||
Indicator.self, UserProfile.self,
|
||
MetricReminder.self, CustomMonitorMetric.self
|
||
], inMemory: true)
|
||
}
|