Files
kangkang/康康/Features/Trends/DayDetailSheet.swift
link2026 bff7cfd4b6 fix(core): 代码审查修复 AI 并发/隐私/解析等多处缺陷
- 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 个回归测试,编译 + 测试全部通过
2026-06-01 08:16:14 +08:00

393 lines
14 KiB
Swift

import SwiftUI
import SwiftData
struct SelectedDay: Identifiable, Hashable {
let date: Date
var id: TimeInterval { date.timeIntervalSince1970 }
}
// MARK: - DayDetailContent( inline sheet)
/// sheet , TrendsView inline 使, sheet
struct DayDetailContent: View {
let date: Date
let indicators: [Indicator]
let reports: [Report]
let diaries: [DiaryEntry]
let symptoms: [Symptom]
/// header(inline header,sheet DayDetailSheet )
var showHeader: Bool = true
@State private var endingSymptom: Symptom?
private let calendar: Calendar = {
var c = Calendar(identifier: .gregorian)
c.locale = Locale.current
return c
}()
// MARK:
private var dayIndicators: [Indicator] {
indicators.filter { calendar.isDate($0.capturedAt, inSameDayAs: date) }
}
private var dayReports: [Report] {
reports.filter { calendar.isDate($0.reportDate, inSameDayAs: date) }
}
private var dayDiaries: [DiaryEntry] {
diaries.filter { calendar.isDate($0.createdAt, inSameDayAs: date) }
}
private var daySymptoms: [(symptom: Symptom, state: SymptomDayState)] {
symptoms.compactMap { s in
let start = calendar.startOfDay(for: s.startedAt)
let end = calendar.startOfDay(for: s.endedAt ?? .now)
let target = calendar.startOfDay(for: date)
guard target >= start && target <= end else { return nil }
let state: SymptomDayState
if start == end && s.isOngoing { state = .startedToday }
else if target == start { state = .startedToday }
else if !s.isOngoing && target == end { state = .endedToday }
else { state = .ongoing }
return (s, state)
}
}
private var totalCount: Int {
dayIndicators.count + dayReports.count + dayDiaries.count + daySymptoms.count
}
var body: some View {
VStack(alignment: .leading, spacing: 14) {
if showHeader { header }
if totalCount == 0 {
emptyState
} else {
if !daySymptoms.isEmpty {
section(String(appLoc: "症状"), count: daySymptoms.count) {
VStack(spacing: 8) {
ForEach(daySymptoms, id: \.symptom.id) { item in
symptomRow(item.symptom, state: item.state)
}
}
}
}
if !dayIndicators.isEmpty {
section(String(appLoc: "指标"), count: dayIndicators.count) {
VStack(spacing: 8) {
ForEach(dayIndicators) { i in indicatorRow(i) }
}
}
}
if !dayReports.isEmpty {
section(String(appLoc: "报告"), count: dayReports.count) {
VStack(spacing: 8) {
ForEach(dayReports) { r in reportRow(r) }
}
}
}
if !dayDiaries.isEmpty {
section(String(appLoc: "日记"), count: dayDiaries.count) {
VStack(spacing: 8) {
ForEach(dayDiaries) { d in diaryRow(d) }
}
}
}
}
}
.sheet(item: $endingSymptom) { sym in
SymptomEndSheet(symptom: sym)
}
}
// MARK: header
private var header: some View {
HStack(alignment: .firstTextBaseline) {
VStack(alignment: .leading, spacing: 4) {
Text(dateLine)
.font(.system(size: 12, weight: .semibold))
.tracking(0.5)
.foregroundStyle(Tj.Palette.text3)
Text(dayLabel)
.font(.tjTitle(22))
.foregroundStyle(Tj.Palette.text)
}
Spacer()
if totalCount > 0 {
Text("\(totalCount)")
.font(.system(size: 12, design: .monospaced))
.foregroundStyle(Tj.Palette.text3)
}
}
}
private var dateLine: String {
date.formatted(.dateTime.year()) + " · " + weekdayLabel
}
private var dayLabel: String {
date.formatted(.dateTime.month().day())
}
private var weekdayLabel: String {
date.formatted(.dateTime.weekday(.wide))
}
// MARK: section helper
private func section<Content: View>(_ title: String,
count: Int,
@ViewBuilder content: () -> Content) -> some View {
VStack(alignment: .leading, spacing: 10) {
HStack {
Text(title)
.font(.system(size: 13, weight: .semibold))
.tracking(0.3)
.foregroundStyle(Tj.Palette.text2)
Text("\(count)")
.font(.system(size: 11, design: .monospaced))
.foregroundStyle(Tj.Palette.text3)
Spacer()
}
content()
}
}
// MARK: rows
private func symptomRow(_ s: Symptom, state: SymptomDayState) -> some View {
HStack(spacing: 12) {
Capsule()
.fill(severityColor(s.severity))
.frame(width: 4, height: 36)
VStack(alignment: .leading, spacing: 3) {
HStack(spacing: 6) {
Text(s.name)
.font(.system(size: 15, weight: .semibold))
.foregroundStyle(Tj.Palette.text)
Text(state.badge)
.font(.system(size: 10, weight: .semibold))
.foregroundStyle(state.badgeFg)
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(Capsule().fill(state.badgeBg))
}
Text("\(state.subtitle) · 持续 \(formatDuration(s.duration))")
.font(.system(size: 11))
.foregroundStyle(Tj.Palette.text3)
}
Spacer(minLength: 6)
if s.isOngoing {
Button {
endingSymptom = s
} 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(12)
.tjCard(bordered: true)
}
private func indicatorRow(_ i: Indicator) -> some View {
HStack(spacing: 12) {
ZStack {
RoundedRectangle(cornerRadius: 8, style: .continuous)
.fill(indicatorAccent(i).opacity(0.12))
Image(systemName: "drop.fill")
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(indicatorAccent(i))
}
.frame(width: 32, height: 32)
VStack(alignment: .leading, spacing: 2) {
Text(i.name)
.font(.system(size: 14, weight: .medium))
.foregroundStyle(Tj.Palette.text)
.lineLimit(1)
if !i.range.isEmpty {
Text("参考 \(i.range)")
.font(.system(size: 11))
.foregroundStyle(Tj.Palette.text3)
}
}
Spacer(minLength: 6)
Text("\(i.value) \(i.unit)\(arrow(i))")
.font(.system(size: 13, weight: .semibold, design: .monospaced))
.foregroundStyle(i.status == .normal ? Tj.Palette.text2 : Tj.Palette.brick)
.lineLimit(1)
.fixedSize()
}
.padding(12)
.tjCard(bordered: true)
}
private func reportRow(_ r: Report) -> some View {
let highCount = r.indicators.filter { $0.status == .high }.count
let lowCount = r.indicators.filter { $0.status == .low }.count
let summary = TimelineEntry.abnormalSummary(high: highCount, low: lowCount)
return HStack(spacing: 12) {
ZStack {
RoundedRectangle(cornerRadius: 8, style: .continuous)
.fill(Tj.Palette.ink2.opacity(0.12))
Image(systemName: "doc.fill")
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(Tj.Palette.ink2)
}
.frame(width: 32, height: 32)
VStack(alignment: .leading, spacing: 2) {
Text(r.title)
.font(.system(size: 14, weight: .medium))
.foregroundStyle(Tj.Palette.text)
.lineLimit(1)
Text("\(r.type.label) · 共 \(r.pageCount)")
.font(.system(size: 11))
.foregroundStyle(Tj.Palette.text3)
}
Spacer(minLength: 6)
if let summary {
Text(summary)
.font(.system(size: 11, weight: .semibold, design: .monospaced))
.foregroundStyle(Tj.Palette.brick)
}
}
.padding(12)
.tjCard(bordered: true)
}
private func diaryRow(_ d: DiaryEntry) -> some View {
VStack(alignment: .leading, spacing: 6) {
HStack {
Text(d.createdAt.formatted(date: .omitted, time: .shortened))
.font(.system(size: 11, design: .monospaced))
.foregroundStyle(Tj.Palette.text3)
Spacer()
}
Text(d.content)
.font(.tjSerifBody(14))
.foregroundStyle(Tj.Palette.text)
.lineSpacing(4)
.multilineTextAlignment(.leading)
}
.padding(12)
.frame(maxWidth: .infinity, alignment: .leading)
.tjCard(bordered: true)
}
private var emptyState: some View {
VStack(spacing: 8) {
TjPlaceholder(label: String(appLoc: "这一天还没有记录"))
.frame(height: 90)
.frame(maxWidth: 240)
Text("点底部 + 号可以补一条")
.font(.system(size: 11))
.foregroundStyle(Tj.Palette.text3)
}
.padding(.vertical, 12)
.frame(maxWidth: .infinity)
}
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
}
}
private func indicatorAccent(_ i: Indicator) -> Color {
i.status == .normal ? Tj.Palette.leaf : Tj.Palette.brick
}
private func arrow(_ i: Indicator) -> String {
switch i.status {
case .high: return ""
case .low: return ""
case .normal: return ""
}
}
}
// MARK: - Sheet wrapper(; TrendsView inline,)
struct DayDetailSheet: View {
let date: Date
let indicators: [Indicator]
let reports: [Report]
let diaries: [DiaryEntry]
let symptoms: [Symptom]
var body: some View {
VStack(spacing: 0) {
Capsule()
.fill(Tj.Palette.line)
.frame(width: 40, height: 4)
.padding(.top, 10)
.padding(.bottom, 14)
ScrollView(showsIndicators: false) {
DayDetailContent(
date: date,
indicators: indicators,
reports: reports,
diaries: diaries,
symptoms: symptoms,
showHeader: true
)
.padding(.horizontal, 20)
.padding(.bottom, 24)
}
}
.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)
}
}
// MARK: - SymptomDayState
enum SymptomDayState {
case startedToday, ongoing, endedToday
var subtitle: String {
switch self {
case .startedToday: return String(appLoc: "今天开始")
case .ongoing: return String(appLoc: "进行中")
case .endedToday: return String(appLoc: "今天结束")
}
}
var badge: String {
switch self {
case .startedToday: return String(appLoc: "开始")
case .ongoing: return String(appLoc: "持续")
case .endedToday: return String(appLoc: "结束")
}
}
var badgeBg: Color {
switch self {
case .startedToday: return Tj.Palette.brickSoft
case .ongoing: return Tj.Palette.sand2
case .endedToday: return Tj.Palette.leafSoft
}
}
var badgeFg: Color {
switch self {
case .startedToday: return Tj.Palette.brick
case .ongoing: return Tj.Palette.text2
case .endedToday: return Tj.Palette.leaf
}
}
}