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 String(appLoc: "「\(n)」是内置指标的名字 — 录入 grid 里会出现两个同名块") case .existingCustom(let n):return String(appLoc: "已经有一个叫「\(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(String(appLoc: "名称")) 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(String(appLoc: "单位(可选)")) 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(String(appLoc: "参考范围(可选)")) Spacer() Text("用于自动判定 正常/偏高/偏低") .font(.system(size: 10)) .foregroundStyle(Tj.Palette.text3) } HStack(spacing: 12) { rangeField(label: String(appLoc: "下限"), value: $lower, placeholder: "70") Text("—").foregroundStyle(Tj.Palette.text3) rangeField(label: String(appLoc: "上限"), value: $upper, placeholder: "90") } } } private func rangeField(label: String, value: Binding, 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(String(appLoc: "图标")) 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) } }