- 替换 QuickCaptureFlow 和 ArchiveFlow 为 UnifiedCaptureFlow 统一流程 - 新增 VLSession 封装 Qwen2.5-VL 模型进行图像文本推理 - 实现 AIRuntime 中 VL 模型的准备和分析功能 - 添加 VLPrompts 定义体检化验单识别的 JSON 输出模板 - 创建 CaptureReviewForm 提供 VL 解析结果的可编辑表单界面 - 集成 VisionKit 文档扫描器支持真机多页文档扫描 - 为模拟器实现 PhotosPicker 回退方案选择已有照片 - 在 RootView 中统一使用 UnifiedCaptureFlow 处理快速和归档流程 - 添加 CustomMetricEditor 支持自定义监测指标的创建编辑删除 - 扩展 KangkangApp 模型配置以支持新数据类型 - 实现档案列表中症状结束功能通过时间线行点击触发
330 lines
12 KiB
Swift
330 lines
12 KiB
Swift
import SwiftUI
|
|
import SwiftData
|
|
|
|
let customMetricIconChoices: [String] = [
|
|
"circle.fill",
|
|
"drop.fill",
|
|
"flame.fill",
|
|
"bolt.fill",
|
|
"leaf.fill",
|
|
"pills.fill",
|
|
"gauge.high",
|
|
"moon.fill",
|
|
]
|
|
|
|
/// 名称冲突判定结果。`detectNameConflict` 返回此值用于 UI 警告。
|
|
enum CustomMetricNameConflict: Equatable {
|
|
case none
|
|
case builtin(String) // 撞到 MonitorMetric.displayName
|
|
case existingCustom(String) // 撞到其他 CustomMonitorMetric.name
|
|
|
|
var warningText: String {
|
|
switch self {
|
|
case .none: return ""
|
|
case .builtin(let n): return "「\(n)」是内置指标的名字 — 录入 grid 里会出现两个同名块"
|
|
case .existingCustom(let n):return "已经有一个叫「\(n)」的自定义指标"
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 纯函数:给定 candidate name + 现有 customs + 编辑时排除的 seriesKey,返回冲突类型。
|
|
/// 抽离方便单测,不依赖 SwiftData 上下文。
|
|
func detectNameConflict(
|
|
candidate: String,
|
|
customs: [CustomMonitorMetric],
|
|
excludingSeriesKey: String? = nil
|
|
) -> CustomMetricNameConflict {
|
|
let trimmed = candidate.trimmingCharacters(in: .whitespaces)
|
|
guard !trimmed.isEmpty else { return .none }
|
|
|
|
if MonitorMetric.allCases.contains(where: { $0.displayName == trimmed }) {
|
|
return .builtin(trimmed)
|
|
}
|
|
for c in customs where c.seriesKey != excludingSeriesKey && c.name == trimmed {
|
|
return .existingCustom(trimmed)
|
|
}
|
|
return .none
|
|
}
|
|
|
|
/// 自定义长期监测指标的 create / edit / delete sheet。
|
|
struct CustomMetricEditor: View {
|
|
@Environment(\.modelContext) private var ctx
|
|
@Environment(\.dismiss) private var dismiss
|
|
|
|
/// nil = 新建;非 nil = 编辑现有
|
|
let existing: CustomMonitorMetric?
|
|
/// 保存或删除后回调,parent 可借此 setSelectedCustom(metric? ) 触发后续 UI
|
|
var onSaved: (CustomMonitorMetric?) -> Void
|
|
|
|
@Query private var allCustoms: [CustomMonitorMetric]
|
|
|
|
@State private var name: String = ""
|
|
@State private var unit: String = ""
|
|
@State private var lower: String = ""
|
|
@State private var upper: String = ""
|
|
@State private var icon: String = "circle.fill"
|
|
@State private var hydrated = false
|
|
|
|
private var trimmedName: String { name.trimmingCharacters(in: .whitespaces) }
|
|
private var trimmedUnit: String { unit.trimmingCharacters(in: .whitespaces) }
|
|
private var canSubmit: Bool { !trimmedName.isEmpty }
|
|
|
|
private var nameConflict: CustomMetricNameConflict {
|
|
detectNameConflict(
|
|
candidate: name,
|
|
customs: allCustoms,
|
|
excludingSeriesKey: existing?.seriesKey
|
|
)
|
|
}
|
|
|
|
var body: some View {
|
|
VStack(spacing: 0) {
|
|
Capsule()
|
|
.fill(Tj.Palette.line)
|
|
.frame(width: 40, height: 4)
|
|
.padding(.top, 10)
|
|
.padding(.bottom, 14)
|
|
|
|
header
|
|
.padding(.horizontal, 20)
|
|
.padding(.bottom, 16)
|
|
|
|
ScrollView(showsIndicators: false) {
|
|
VStack(alignment: .leading, spacing: 18) {
|
|
nameSection
|
|
unitSection
|
|
rangeRow
|
|
iconSection
|
|
if existing != nil {
|
|
deleteButton
|
|
}
|
|
}
|
|
.padding(.horizontal, 20)
|
|
.padding(.bottom, 20)
|
|
}
|
|
|
|
footer
|
|
}
|
|
.background(
|
|
Tj.Palette.sand
|
|
.clipShape(RoundedRectangle(cornerRadius: Tj.Radius.xl, style: .continuous))
|
|
.ignoresSafeArea(edges: .bottom)
|
|
)
|
|
.presentationDetents([.medium, .large])
|
|
.presentationDragIndicator(.hidden)
|
|
.presentationBackground(Tj.Palette.sand)
|
|
.presentationCornerRadius(Tj.Radius.xl)
|
|
.onAppear { hydrate() }
|
|
}
|
|
|
|
private var header: some View {
|
|
HStack {
|
|
Text(existing == nil ? "新建自定义指标" : "编辑「\(existing!.name)」")
|
|
.font(.tjH2())
|
|
.foregroundStyle(Tj.Palette.text)
|
|
Spacer()
|
|
if existing == nil {
|
|
Text("保存后会出现在录入选项里")
|
|
.font(.system(size: 11))
|
|
.foregroundStyle(Tj.Palette.text3)
|
|
}
|
|
}
|
|
}
|
|
|
|
private var nameSection: some View {
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
sectionLabel("名称")
|
|
TextField("例如:腰围 / 步数 / 睡眠时长", text: $name)
|
|
.padding(.horizontal, 14).padding(.vertical, 12)
|
|
.background(fieldBg)
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
|
|
.strokeBorder(
|
|
nameConflict == .none ? Tj.Palette.line : Tj.Palette.amber,
|
|
lineWidth: 1
|
|
)
|
|
)
|
|
if nameConflict != .none {
|
|
HStack(spacing: 6) {
|
|
Image(systemName: "exclamationmark.triangle.fill")
|
|
.font(.system(size: 11))
|
|
.foregroundStyle(Tj.Palette.amber)
|
|
Text(nameConflict.warningText)
|
|
.font(.system(size: 11))
|
|
.foregroundStyle(Tj.Palette.amber)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
Spacer(minLength: 0)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private var unitSection: some View {
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
sectionLabel("单位(可选)")
|
|
TextField("例如:cm / 步 / 小时", text: $unit)
|
|
.autocorrectionDisabled()
|
|
.padding(.horizontal, 14).padding(.vertical, 12)
|
|
.background(fieldBg).overlay(fieldBorder)
|
|
}
|
|
}
|
|
|
|
private var rangeRow: some View {
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
HStack {
|
|
sectionLabel("参考范围(可选)")
|
|
Spacer()
|
|
Text("用于自动判定 正常/偏高/偏低")
|
|
.font(.system(size: 10))
|
|
.foregroundStyle(Tj.Palette.text3)
|
|
}
|
|
HStack(spacing: 12) {
|
|
rangeField(label: "下限", value: $lower, placeholder: "70")
|
|
Text("—").foregroundStyle(Tj.Palette.text3)
|
|
rangeField(label: "上限", value: $upper, placeholder: "90")
|
|
}
|
|
}
|
|
}
|
|
|
|
private func rangeField(label: String, value: Binding<String>, placeholder: String) -> some View {
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
Text(label).font(.system(size: 11)).foregroundStyle(Tj.Palette.text3)
|
|
TextField(placeholder, text: value)
|
|
.keyboardType(.decimalPad)
|
|
.font(.system(size: 16, weight: .medium, design: .monospaced))
|
|
.padding(.horizontal, 12).padding(.vertical, 10)
|
|
.background(fieldBg).overlay(fieldBorder)
|
|
}
|
|
}
|
|
|
|
private var iconSection: some View {
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
sectionLabel("图标")
|
|
LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 8), count: 4),
|
|
spacing: 8) {
|
|
ForEach(customMetricIconChoices, id: \.self) { sf in
|
|
Button {
|
|
icon = sf
|
|
} label: {
|
|
Image(systemName: sf)
|
|
.font(.system(size: 20, weight: .medium))
|
|
.foregroundStyle(icon == sf ? Tj.Palette.paper : Tj.Palette.ink)
|
|
.frame(maxWidth: .infinity, minHeight: 44)
|
|
.background(
|
|
RoundedRectangle(cornerRadius: 10, style: .continuous)
|
|
.fill(icon == sf ? Tj.Palette.ink : Tj.Palette.paper)
|
|
)
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: 10, style: .continuous)
|
|
.strokeBorder(Tj.Palette.line, lineWidth: icon == sf ? 0 : 1)
|
|
)
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private var deleteButton: some View {
|
|
Button(role: .destructive) {
|
|
if let m = existing {
|
|
ReminderService.cancel(metricId: m.seriesKey)
|
|
ctx.delete(m)
|
|
try? ctx.save()
|
|
onSaved(nil)
|
|
dismiss()
|
|
}
|
|
} label: {
|
|
HStack {
|
|
Image(systemName: "trash")
|
|
Text("删除这项自定义指标")
|
|
}
|
|
.font(.system(size: 13, weight: .semibold))
|
|
.foregroundStyle(Tj.Palette.brick)
|
|
.frame(maxWidth: .infinity)
|
|
.padding(.vertical, 12)
|
|
.background(
|
|
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
|
|
.fill(Tj.Palette.brickSoft.opacity(0.5))
|
|
)
|
|
}
|
|
.buttonStyle(.plain)
|
|
.padding(.top, 8)
|
|
}
|
|
|
|
private var footer: some View {
|
|
HStack(spacing: 12) {
|
|
Button("取消") { dismiss() }
|
|
.buttonStyle(TjGhostButton(height: 44, fontSize: 15, horizontalPadding: 18))
|
|
Button(existing == nil ? "新建" : "保存") { submit() }
|
|
.buttonStyle(TjPrimaryButton(height: 44, fontSize: 15, horizontalPadding: 18))
|
|
.disabled(!canSubmit)
|
|
.opacity(canSubmit ? 1 : 0.4)
|
|
}
|
|
.padding(.horizontal, 20)
|
|
.padding(.vertical, 14)
|
|
.background(
|
|
Tj.Palette.sand
|
|
.overlay(alignment: .top) {
|
|
Rectangle().fill(Tj.Palette.lineSoft).frame(height: 1)
|
|
}
|
|
)
|
|
}
|
|
|
|
// MARK: - helpers
|
|
|
|
private var fieldBg: some View {
|
|
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
|
|
.fill(Tj.Palette.paper)
|
|
}
|
|
private var fieldBorder: some View {
|
|
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
|
|
.strokeBorder(Tj.Palette.line, lineWidth: 1)
|
|
}
|
|
private func sectionLabel(_ t: String) -> some View {
|
|
Text(t).font(.system(size: 12, weight: .semibold)).tracking(0.3)
|
|
.foregroundStyle(Tj.Palette.text2)
|
|
}
|
|
|
|
private func hydrate() {
|
|
guard !hydrated, let m = existing else { hydrated = true; return }
|
|
name = m.name; unit = m.unit; icon = m.icon
|
|
lower = m.lowerBound.map { fmt($0) } ?? ""
|
|
upper = m.upperBound.map { fmt($0) } ?? ""
|
|
hydrated = true
|
|
}
|
|
|
|
private func submit() {
|
|
guard canSubmit else { return }
|
|
let lo = Double(lower.trimmingCharacters(in: .whitespaces))
|
|
let hi = Double(upper.trimmingCharacters(in: .whitespaces))
|
|
if let m = existing {
|
|
m.name = trimmedName
|
|
m.unit = trimmedUnit
|
|
m.lowerBound = lo
|
|
m.upperBound = hi
|
|
m.icon = icon
|
|
try? ctx.save()
|
|
onSaved(m)
|
|
} else {
|
|
let m = CustomMonitorMetric(
|
|
name: trimmedName,
|
|
unit: trimmedUnit,
|
|
lowerBound: lo,
|
|
upperBound: hi,
|
|
icon: icon
|
|
)
|
|
ctx.insert(m)
|
|
try? ctx.save()
|
|
onSaved(m)
|
|
}
|
|
dismiss()
|
|
}
|
|
|
|
private func fmt(_ v: Double) -> String {
|
|
v.truncatingRemainder(dividingBy: 1) == 0
|
|
? String(format: "%.0f", v)
|
|
: String(format: "%.1f", v)
|
|
}
|
|
}
|