diff --git a/康康/App/KangkangApp.swift b/康康/App/KangkangApp.swift index 4fed669..d23d3d7 100644 --- a/康康/App/KangkangApp.swift +++ b/康康/App/KangkangApp.swift @@ -10,6 +10,7 @@ struct KangkangApp: App { DiaryEntry.self, Asset.self, ChatTurn.self, + Symptom.self, ]) let config = ModelConfiguration(schema: schema, isStoredInMemoryOnly: false) do { diff --git a/康康/Features/Archive/ArchiveListView.swift b/康康/Features/Archive/ArchiveListView.swift new file mode 100644 index 0000000..1bd2280 --- /dev/null +++ b/康康/Features/Archive/ArchiveListView.swift @@ -0,0 +1,19 @@ +import SwiftUI + +struct ArchiveListView: View { + var body: some View { + VStack(spacing: 12) { + Spacer() + TjPlaceholder(label: "records · 记录列表\n(C1 尚未实现)") + .frame(width: 280, height: 180) + Text("记录") + .font(.tjH2()) + .foregroundStyle(Tj.Palette.text2) + Spacer() + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Tj.Palette.sand.ignoresSafeArea()) + } +} + +#Preview { ArchiveListView() } diff --git a/康康/Features/Home/HomeView.swift b/康康/Features/Home/HomeView.swift index 6860007..a9d9ed0 100644 --- a/康康/Features/Home/HomeView.swift +++ b/康康/Features/Home/HomeView.swift @@ -10,6 +10,9 @@ struct HomeView: View { .padding(.top, 4) .padding(.bottom, 18) + OngoingSymptomsCard() + .padding(.bottom, 18) + todaySummaryCard .padding(.bottom, 18) diff --git a/康康/Features/Record/RecordSheet.swift b/康康/Features/Record/RecordSheet.swift index ce7a0a6..dd5d5cb 100644 --- a/康康/Features/Record/RecordSheet.swift +++ b/康康/Features/Record/RecordSheet.swift @@ -1,7 +1,7 @@ import SwiftUI enum RecordKind: String, Identifiable, CaseIterable { - case quick, archive, diary + case quick, archive, diary, symptom var id: String { rawValue } var title: String { @@ -9,13 +9,15 @@ enum RecordKind: String, Identifiable, CaseIterable { case .quick: return "异常项快拍" case .archive: return "关键报告归档" case .diary: return "文字日记" + case .symptom: return "症状开始" } } var subtitle: String { switch self { case .quick: return "只记录单个或几项异常指标" case .archive: return "完整保存整份报告(可多页)" - case .diary: return "记录症状、心情、用药" + case .diary: return "记录心情、用药、其他" + case .symptom: return "开始一个持续症状,结束时再点结束" } } var icon: String { @@ -23,6 +25,7 @@ enum RecordKind: String, Identifiable, CaseIterable { case .quick: return "camera.fill" case .archive: return "doc.fill" case .diary: return "pencil" + case .symptom: return "waveform.path.ecg" } } var accent: Color { @@ -30,6 +33,7 @@ enum RecordKind: String, Identifiable, CaseIterable { case .quick: return Tj.Palette.brick case .archive: return Tj.Palette.ink case .diary: return Tj.Palette.leaf + case .symptom: return Tj.Palette.amber } } } @@ -98,7 +102,7 @@ struct RecordSheet: View { .clipShape(RoundedRectangle(cornerRadius: Tj.Radius.xl, style: .continuous)) .ignoresSafeArea(edges: .bottom) ) - .presentationDetents([.fraction(0.55)]) + .presentationDetents([.fraction(0.68)]) .presentationDragIndicator(.hidden) .presentationBackground(Tj.Palette.sand) .presentationCornerRadius(Tj.Radius.xl) diff --git a/康康/Features/Symptom/OngoingSymptomsCard.swift b/康康/Features/Symptom/OngoingSymptomsCard.swift new file mode 100644 index 0000000..2881277 --- /dev/null +++ b/康康/Features/Symptom/OngoingSymptomsCard.swift @@ -0,0 +1,117 @@ +import SwiftUI +import SwiftData +import Combine + +struct OngoingSymptomsCard: View { + @Query(filter: #Predicate { $0.endedAt == nil }, + sort: \Symptom.startedAt, order: .reverse) + private var ongoing: [Symptom] + + @State private var ending: Symptom? + @State private var tick: Date = .now + + private let timer = Timer.publish(every: 60, on: .main, in: .common).autoconnect() + + var body: some View { + if ongoing.isEmpty { + EmptyView() + } else { + VStack(alignment: .leading, spacing: 10) { + HStack(spacing: 8) { + Circle() + .fill(Tj.Palette.brick) + .frame(width: 7, height: 7) + Text("持续中") + .font(.tjH2()) + .foregroundStyle(Tj.Palette.text) + Text("\(ongoing.count) 个") + .font(.system(size: 12)) + .foregroundStyle(Tj.Palette.text3) + Spacer() + } + + VStack(spacing: 8) { + ForEach(ongoing) { sym in + row(sym) + } + } + } + .onReceive(timer) { now in tick = now } + .sheet(item: $ending) { sym in + SymptomEndSheet(symptom: sym) + } + } + } + + private func row(_ sym: Symptom) -> some View { + let interval = max(0, tick.timeIntervalSince(sym.startedAt)) + let isLong = interval >= 3 * 24 * 3600 + + return HStack(spacing: 12) { + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 8) { + Text(sym.name) + .font(.system(size: 15, weight: .semibold)) + .foregroundStyle(Tj.Palette.text) + severityDot(sym.severity) + } + Text("已持续 \(formatDuration(interval))") + .font(.system(size: 12)) + .foregroundStyle(isLong ? Tj.Palette.brick : Tj.Palette.text3) + } + Spacer(minLength: 8) + Button { + ending = sym + } label: { + Text("结束") + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(Tj.Palette.text) + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background( + Capsule().fill(Tj.Palette.sand2) + ) + } + .buttonStyle(.plain) + } + .padding(.horizontal, 14) + .padding(.vertical, 12) + .background( + RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous) + .fill(Tj.Palette.paper) + .overlay(alignment: .leading) { + Rectangle() + .fill(severityColor(sym.severity)) + .frame(width: 3) + .clipShape( + UnevenRoundedRectangle( + topLeadingRadius: Tj.Radius.sm, + bottomLeadingRadius: Tj.Radius.sm, + bottomTrailingRadius: 0, + topTrailingRadius: 0 + ) + ) + } + ) + .shadow(color: Color(red: 0.196, green: 0.157, blue: 0.098).opacity(0.04), + radius: 2, x: 0, y: 1) + } + + private func severityDot(_ value: Int) -> some View { + HStack(spacing: 2) { + ForEach(1...5, id: \.self) { i in + Circle() + .fill(i <= value ? severityColor(value) : Tj.Palette.line) + .frame(width: 5, height: 5) + } + } + } + + private func severityColor(_ value: Int) -> Color { + switch value { + case 1, 2: return Tj.Palette.leaf + case 3: return Tj.Palette.amber + default: return Tj.Palette.brick + } + } +} diff --git a/康康/Features/Symptom/SymptomEndSheet.swift b/康康/Features/Symptom/SymptomEndSheet.swift new file mode 100644 index 0000000..5521fcd --- /dev/null +++ b/康康/Features/Symptom/SymptomEndSheet.swift @@ -0,0 +1,119 @@ +import SwiftUI +import SwiftData + +struct SymptomEndSheet: View { + @Environment(\.modelContext) private var ctx + @Environment(\.dismiss) private var dismiss + + let symptom: Symptom + + @State private var endedAt: Date = .now + + private var lowerBound: Date { symptom.startedAt } + + private var durationLabel: String { + let interval = max(0, endedAt.timeIntervalSince(lowerBound)) + return formatDuration(interval) + } + + var body: some View { + VStack(spacing: 0) { + Capsule() + .fill(Tj.Palette.line) + .frame(width: 40, height: 4) + .padding(.top, 10) + .padding(.bottom, 14) + + VStack(alignment: .leading, spacing: 18) { + HStack { + VStack(alignment: .leading, spacing: 4) { + Text("结束症状") + .font(.system(size: 12, weight: .semibold)) + .tracking(0.3) + .foregroundStyle(Tj.Palette.text3) + Text(symptom.name) + .font(.tjTitle(24)) + .foregroundStyle(Tj.Palette.text) + } + Spacer() + } + + VStack(alignment: .leading, spacing: 6) { + Text("开始于") + .font(.system(size: 12)) + .foregroundStyle(Tj.Palette.text3) + Text(symptom.startedAt.formatted(date: .abbreviated, time: .shortened)) + .font(.system(size: 14, weight: .medium)) + .foregroundStyle(Tj.Palette.text) + } + + VStack(alignment: .leading, spacing: 8) { + Text("结束时间") + .font(.system(size: 12, weight: .semibold)) + .tracking(0.3) + .foregroundStyle(Tj.Palette.text2) + DatePicker("", selection: $endedAt, in: lowerBound...Date.now) + .datePickerStyle(.compact) + .labelsHidden() + } + + HStack { + Text("本次持续") + .font(.system(size: 13)) + .foregroundStyle(Tj.Palette.text3) + Spacer() + Text(durationLabel) + .font(.system(size: 15, weight: .semibold, design: .monospaced)) + .foregroundStyle(Tj.Palette.brick) + } + .padding(.horizontal, 14) + .padding(.vertical, 12) + .background( + RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous) + .fill(Tj.Palette.paper) + ) + + Spacer(minLength: 8) + } + .padding(.horizontal, 20) + + HStack(spacing: 12) { + Button("取消") { dismiss() } + .buttonStyle(TjGhostButton(height: 44, fontSize: 15, horizontalPadding: 18)) + Button("结束并保存") { submit() } + .buttonStyle(TjPrimaryButton(height: 44, fontSize: 15, horizontalPadding: 18)) + } + .padding(.horizontal, 20) + .padding(.vertical, 14) + } + .background( + Tj.Palette.sand + .clipShape(RoundedRectangle(cornerRadius: Tj.Radius.xl, style: .continuous)) + .ignoresSafeArea(edges: .bottom) + ) + .presentationDetents([.medium]) + .presentationDragIndicator(.hidden) + .presentationBackground(Tj.Palette.sand) + .presentationCornerRadius(Tj.Radius.xl) + } + + private func submit() { + symptom.endedAt = max(endedAt, symptom.startedAt) + try? ctx.save() + dismiss() + } +} + +func formatDuration(_ interval: TimeInterval) -> String { + let totalMinutes = Int(interval / 60) + let days = totalMinutes / (60 * 24) + let hours = (totalMinutes % (60 * 24)) / 60 + let minutes = totalMinutes % 60 + + if days > 0 && hours > 0 { return "\(days) 天 \(hours) 小时" } + if days > 0 { return "\(days) 天" } + if hours > 0 && minutes > 0 { return "\(hours) 小时 \(minutes) 分" } + if hours > 0 { return "\(hours) 小时" } + if minutes > 0 { return "\(minutes) 分钟" } + return "刚刚" +} diff --git a/康康/Features/Symptom/SymptomStartSheet.swift b/康康/Features/Symptom/SymptomStartSheet.swift new file mode 100644 index 0000000..4ab8733 --- /dev/null +++ b/康康/Features/Symptom/SymptomStartSheet.swift @@ -0,0 +1,231 @@ +import SwiftUI +import SwiftData + +private let symptomPresets: [String] = [ + "头痛", "咳嗽", "腹痛", "发烧", + "恶心", "失眠", "疲劳", "关节痛" +] + +struct SymptomStartSheet: View { + @Environment(\.modelContext) private var ctx + @Environment(\.dismiss) private var dismiss + + @State private var name: String = "" + @State private var customName: String = "" + @State private var startedAt: Date = .now + @State private var severity: Double = 3 + @State private var note: String = "" + + private var resolvedName: String { + let trimmed = customName.trimmingCharacters(in: .whitespaces) + return trimmed.isEmpty ? name : trimmed + } + + private var canSubmit: Bool { !resolvedName.isEmpty } + + var body: some View { + VStack(spacing: 0) { + handle + header + + ScrollView(showsIndicators: false) { + VStack(alignment: .leading, spacing: 18) { + presetSection + customSection + timeSection + severitySection + noteSection + } + .padding(.horizontal, 20) + .padding(.bottom, 20) + } + + footer + } + .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) + } + + 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 presetSection: some View { + VStack(alignment: .leading, spacing: 8) { + sectionLabel("常见症状") + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 8) { + ForEach(symptomPresets, id: \.self) { item in + chip(item, selected: name == item) { + name = item + customName = "" + } + } + } + } + } + } + + private var customSection: some View { + VStack(alignment: .leading, spacing: 8) { + sectionLabel("或者自己写") + TextField("例如:眼皮跳", text: $customName) + .textInputAutocapitalization(.never) + .padding(.horizontal, 14) + .padding(.vertical, 12) + .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) + ) + .onChange(of: customName) { _, newValue in + if !newValue.trimmingCharacters(in: .whitespaces).isEmpty { + name = "" + } + } + } + } + + private var timeSection: some View { + VStack(alignment: .leading, spacing: 8) { + sectionLabel("开始时间") + DatePicker("", selection: $startedAt, in: ...Date.now) + .datePickerStyle(.compact) + .labelsHidden() + } + } + + private var severitySection: some View { + VStack(alignment: .leading, spacing: 8) { + HStack { + sectionLabel("强度") + Spacer() + Text("\(Int(severity)) / 5") + .font(.system(size: 13, weight: .semibold, design: .monospaced)) + .foregroundStyle(severityColor) + } + Slider(value: $severity, in: 1...5, step: 1) + .tint(severityColor) + HStack { + Text("轻微").font(.system(size: 11)).foregroundStyle(Tj.Palette.text3) + Spacer() + Text("剧烈").font(.system(size: 11)).foregroundStyle(Tj.Palette.text3) + } + } + } + + private var noteSection: some View { + VStack(alignment: .leading, spacing: 8) { + sectionLabel("备注(可选)") + TextField("位置、可能诱因…", text: $note, axis: .vertical) + .lineLimit(2...4) + .padding(.horizontal, 14) + .padding(.vertical, 12) + .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) + ) + } + } + + 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) + } + ) + } + + private var severityColor: Color { + switch Int(severity) { + case 1, 2: return Tj.Palette.leaf + case 3: return Tj.Palette.amber + default: return Tj.Palette.brick + } + } + + 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 submit() { + guard canSubmit else { return } + let symptom = Symptom( + name: resolvedName, + startedAt: startedAt, + note: note.trimmingCharacters(in: .whitespaces).isEmpty ? nil : note, + severity: Int(severity) + ) + ctx.insert(symptom) + try? ctx.save() + dismiss() + } +} + +#Preview { + SymptomStartSheet() + .modelContainer(for: Symptom.self, inMemory: true) +} diff --git a/康康/Models/Models.swift b/康康/Models/Models.swift index 45809a3..317c85b 100644 --- a/康康/Models/Models.swift +++ b/康康/Models/Models.swift @@ -131,6 +131,39 @@ final class Asset { } } +@Model +final class Symptom { + var name: String + var startedAt: Date + var endedAt: Date? + var note: String? + var severity: Int + var tags: [String] + var createdAt: Date + + init(name: String, + startedAt: Date = .now, + endedAt: Date? = nil, + note: String? = nil, + severity: Int = 3, + tags: [String] = [], + createdAt: Date = .now) { + self.name = name + self.startedAt = startedAt + self.endedAt = endedAt + self.note = note + self.severity = max(1, min(5, severity)) + self.tags = tags + self.createdAt = createdAt + } + + var isOngoing: Bool { endedAt == nil } + + var duration: TimeInterval { + (endedAt ?? .now).timeIntervalSince(startedAt) + } +} + @Model final class ChatTurn { var question: String diff --git a/康康/RootView.swift b/康康/RootView.swift index 14d935e..110b67f 100644 --- a/康康/RootView.swift +++ b/康康/RootView.swift @@ -1,19 +1,21 @@ import SwiftUI enum TjTab: String, Hashable, CaseIterable { - case home, trend, me + case home, records, trend, me var label: String { switch self { - case .home: return "首页" - case .trend: return "趋势" - case .me: return "我的" + case .home: return "主页" + case .records: return "记录" + case .trend: return "趋势" + case .me: return "我的" } } var icon: String { switch self { - case .home: return "house" - case .trend: return "chart.line.uptrend.xyaxis" - case .me: return "person.circle" + case .home: return "house" + case .records: return "list.bullet.rectangle" + case .trend: return "chart.line.uptrend.xyaxis" + case .me: return "person.circle" } } } @@ -27,14 +29,16 @@ struct RootView: View { @State private var tab: TjTab = .home @State private var showRecordSheet = false @State private var activeFlow: ActiveFlow? + @State private var showSymptomStart = false var body: some View { VStack(spacing: 0) { Group { switch tab { - case .home: HomeView(onTapArchive: {}) - case .trend: TrendsView() - case .me: MeView() + case .home: HomeView(onTapArchive: { tab = .records }) + case .records: ArchiveListView() + case .trend: TrendsView() + case .me: MeView() } } .frame(maxWidth: .infinity, maxHeight: .infinity) @@ -51,11 +55,15 @@ struct RootView: View { switch kind { case .quick: activeFlow = .quick case .archive: activeFlow = .archive + case .symptom: showSymptomStart = true case .diary: break } } } } + .sheet(isPresented: $showSymptomStart) { + SymptomStartSheet() + } #if os(iOS) .fullScreenCover(item: $activeFlow) { flow in switch flow { @@ -83,66 +91,101 @@ private struct TabBar: View { let onTap: (TjTab) -> Void let onTapRecord: () -> Void + private let cornerRadius: CGFloat = 22 + private let slotHeight: CGFloat = 34 + var body: some View { - HStack(spacing: 0) { + HStack(alignment: .bottom, spacing: 0) { tabItem(.home) - Color.clear.frame(width: 60, height: 1) + tabItem(.records) + recordSlot tabItem(.trend) tabItem(.me) } - .padding(.horizontal, 12) + .padding(.horizontal, 4) .padding(.top, 10) - .padding(.bottom, 4) - .background( - Tj.Palette.paper - .overlay(alignment: .top) { - Rectangle() - .fill(Tj.Palette.lineSoft) - .frame(height: 1) - } + .padding(.bottom, 6) + .background(barBackground) + } + + private var barBackground: some View { + UnevenRoundedRectangle( + topLeadingRadius: cornerRadius, + bottomLeadingRadius: 0, + bottomTrailingRadius: 0, + topTrailingRadius: cornerRadius, + style: .continuous ) + .fill(Tj.Palette.paper) .overlay(alignment: .top) { - recordButton.offset(y: -22) + Rectangle() + .fill(Tj.Palette.lineSoft) + .frame(height: 1) } + .shadow(color: Tj.Palette.ink.opacity(0.05), radius: 10, x: 0, y: -2) } private func tabItem(_ t: TjTab) -> some View { - Button { onTap(t) } label: { + let isActive = active == t + return Button { onTap(t) } label: { VStack(spacing: 4) { - Image(systemName: t.icon) - .font(.system(size: 20, weight: .regular)) - .frame(width: 26, height: 26) + ZStack { + if isActive { + Capsule() + .fill(Tj.Palette.sand2) + .frame(width: 44, height: slotHeight - 6) + } + Image(systemName: t.icon) + .font(.system(size: 18, weight: isActive ? .semibold : .regular)) + } + .frame(width: 50, height: slotHeight) + Text(t.label) - .font(.system(size: 11)) + .font(.system(size: 11, weight: isActive ? .semibold : .regular)) } - .foregroundStyle(active == t ? Tj.Palette.ink : Tj.Palette.text3) + .foregroundStyle(isActive ? Tj.Palette.ink : Tj.Palette.text3) .frame(maxWidth: .infinity) - .padding(.vertical, 4) .contentShape(Rectangle()) } - .buttonStyle(.plain) + .buttonStyle(TabPressStyle()) } - private var recordButton: some View { + private var recordSlot: some View { Button(action: onTapRecord) { VStack(spacing: 4) { ZStack { Circle() .fill(Tj.Palette.ink) - .shadow(color: Color(red: 0.157, green: 0.216, blue: 0.176).opacity(0.25), - radius: 12, x: 0, y: 4) + .overlay( + Circle() + .strokeBorder(Tj.Palette.paper, lineWidth: 2) + ) + .shadow(color: Tj.Palette.ink.opacity(0.18), + radius: 4, x: 0, y: 2) + Image(systemName: "plus") - .font(.system(size: 20, weight: .semibold)) + .font(.system(size: 16, weight: .semibold)) .foregroundStyle(Tj.Palette.paper) } - .frame(width: 52, height: 52) + .frame(width: slotHeight, height: slotHeight) - Text("记录") - .font(.system(size: 11)) + Text("新建") + .font(.system(size: 11, weight: .semibold)) .foregroundStyle(Tj.Palette.ink) } + .frame(maxWidth: .infinity) + .contentShape(Rectangle()) } - .buttonStyle(.plain) + .buttonStyle(TabPressStyle()) + } +} + +private struct TabPressStyle: ButtonStyle { + func makeBody(configuration: Configuration) -> some View { + configuration.label + .scaleEffect(configuration.isPressed ? 0.92 : 1.0) + .animation(.spring(response: 0.25, dampingFraction: 0.7), + value: configuration.isPressed) } } diff --git a/康康Tests/ModelsSchemaTests.swift b/康康Tests/ModelsSchemaTests.swift index 884d07d..dbdea48 100644 --- a/康康Tests/ModelsSchemaTests.swift +++ b/康康Tests/ModelsSchemaTests.swift @@ -12,6 +12,7 @@ struct ModelsSchemaTests { DiaryEntry.self, Asset.self, ChatTurn.self, + Symptom.self, ]) let config = ModelConfiguration(schema: schema, isStoredInMemoryOnly: true) return try ModelContainer(for: schema, configurations: [config]) @@ -58,6 +59,37 @@ struct ModelsSchemaTests { #expect(remaining.isEmpty) } + @Test func ongoingSymptomQueryFiltersByEndedAt() throws { + let container = try makeContainer() + let ctx = ModelContext(container) + + let active = Symptom(name: "头痛", startedAt: .now.addingTimeInterval(-3600)) + let ended = Symptom( + name: "咳嗽", + startedAt: .now.addingTimeInterval(-7200), + endedAt: .now.addingTimeInterval(-1800) + ) + ctx.insert(active) + ctx.insert(ended) + try ctx.save() + + let predicate = #Predicate { $0.endedAt == nil } + let ongoing = try ctx.fetch(FetchDescriptor(predicate: predicate)) + + #expect(ongoing.count == 1) + #expect(ongoing.first?.name == "头痛") + #expect(active.isOngoing) + #expect(!ended.isOngoing) + #expect(active.duration >= 3600) + } + + @Test func symptomSeverityClampedToRange() throws { + let high = Symptom(name: "腹痛", severity: 99) + let low = Symptom(name: "失眠", severity: -3) + #expect(high.severity == 5) + #expect(low.severity == 1) + } + @Test func chatTurnPersistsReferencedIDs() throws { let container = try makeContainer() let ctx = ModelContext(container)