Files
kangkang/康康/Features/Timeline/TimelineEntryDetailView.swift
link2026 77a4ee1c37 缺少代码差异信息,无法生成具体的commit message。请提供code differences内容以便分析并生成符合Angular规范的提交信息。
当您提供代码差异后,我将按照以下格式生成:

```
<type>(<scope>): <subject>

<body>
```

其中type会根据更改类型选择(feat、fix、docs、style、refactor等),scope表示影响范围,subject简要描述变更内容,body详细说明修改内容。
2026-06-07 14:17:18 +08:00

561 lines
22 KiB
Swift

import SwiftUI
import SwiftData
/// 线, sheet
/// : W2 ;W4 C2 `ReportDetailView`( Tab + ),
/// 线 C2 ,
enum TimelineDetail {
case indicator(Indicator)
case bloodPressure(sys: Indicator, dia: Indicator?)
case report(Report)
case diary(DiaryEntry)
case symptom(Symptom)
/// 线(id `<kind>-<persistentModelID>` / `bp-<sysID>-<diaID>`)
/// C1 , nil
static func resolve(for entry: TimelineEntry,
indicators: [Indicator],
reports: [Report],
diaries: [DiaryEntry],
symptoms: [Symptom]) -> TimelineDetail? {
switch entry.kind {
case .report:
return reports.first { "report-\($0.persistentModelID)" == entry.id }
.map(TimelineDetail.report)
case .diary:
return diaries.first { "diary-\($0.persistentModelID)" == entry.id }
.map(TimelineDetail.diary)
case .symptom:
return symptoms.first { "symptom-\($0.persistentModelID)" == entry.id }
.map(TimelineDetail.symptom)
case .indicator:
if let i = indicators.first(where: { "indicator-\($0.persistentModelID)" == entry.id }) {
return .indicator(i)
}
// :bp-<sysID>-<diaID>
if entry.id.hasPrefix("bp-"),
let sys = indicators.first(where: { entry.id.hasPrefix("bp-\($0.persistentModelID)-") }) {
// id diaID , ±5s
//()
let dia = indicators.first { entry.id.hasSuffix("-\($0.persistentModelID)") }
return .bloodPressure(sys: sys, dia: dia)
}
return nil
}
}
}
/// 线:,
struct TimelineEntryDetailView: View {
@Environment(\.dismiss) private var dismiss
@Environment(\.modelContext) private var ctx
let detail: TimelineDetail
@State private var showDeleteConfirm = false
@State private var evidenceTarget: Indicator?
var body: some View {
VStack(spacing: 0) {
header
ScrollView {
VStack(alignment: .leading, spacing: 16) {
bodyContent
deleteButton
}
.padding(.horizontal, 20)
.padding(.vertical, 16)
.frame(maxWidth: .infinity, alignment: .leading)
}
}
.background(Tj.Palette.sand.ignoresSafeArea())
.presentationDetents([.medium, .large])
.presentationDragIndicator(.visible)
.presentationBackground(Tj.Palette.sand)
.presentationCornerRadius(Tj.Radius.xl)
.alert(String(appLoc: "永久删除这条记录?"), isPresented: $showDeleteConfirm) {
Button(String(appLoc: "删除"), role: .destructive) { performDelete() }
Button(String(appLoc: "取消"), role: .cancel) { }
} message: {
Text("删除后无法恢复。")
}
.sheet(item: $evidenceTarget) { indicator in
if let report = indicator.report {
EvidenceImagePreview(report: report, indicator: indicator)
}
}
}
// MARK: - (:SwiftData + Vault unlink, CLAUDE.md §6)
private var deleteButton: some View {
Button(role: .destructive) { showDeleteConfirm = true } label: {
Label(String(appLoc: "永久删除"), systemImage: "trash")
.font(.tjScaled( 12, weight: .medium))
.foregroundStyle(Tj.Palette.brick.opacity(0.8))
.padding(.horizontal, 14)
.padding(.vertical, 8)
.background(
RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)
.strokeBorder(Tj.Palette.brick.opacity(0.3), lineWidth: 1)
)
// : contentShape ()
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.padding(.top, 8)
}
private func performDelete() {
switch detail {
case .indicator(let i):
deleteIndicator(i)
case .bloodPressure(let sys, let dia):
deleteIndicator(sys)
if let dia { deleteIndicator(dia) }
case .report(let r):
// cascade Asset/Indicator ,Vault JPEG unlink
var paths = Set(r.assets.map(\.relativePath))
paths.formUnion(r.indicators.compactMap { $0.asset?.relativePath })
for p in paths { try? FileVault.shared.remove(relativePath: p) }
ctx.delete(r)
case .diary(let d):
ctx.delete(d)
case .symptom(let s):
ctx.delete(s)
}
try? ctx.save()
dismiss()
}
/// : unlink + Asset ( nullify,),
private func deleteIndicator(_ i: Indicator) {
if let asset = i.asset {
try? FileVault.shared.remove(relativePath: asset.relativePath)
ctx.delete(asset)
}
ctx.delete(i)
}
// MARK: - Header
private var header: some View {
HStack(spacing: 12) {
Button { dismiss() } label: {
Image(systemName: "xmark")
.font(.tjScaled( 16, weight: .semibold))
.foregroundStyle(Tj.Palette.text)
.frame(width: 32, height: 32)
.background(Circle().fill(Tj.Palette.sand2))
}
Text(titleText)
.font(.tjH2())
.foregroundStyle(Tj.Palette.text)
Spacer()
TjLockChip()
}
.padding(.horizontal, 20)
.padding(.vertical, 14)
.background(Tj.Palette.sand)
.overlay(alignment: .bottom) {
Rectangle().fill(Tj.Palette.lineSoft).frame(height: 1)
}
}
private var titleText: String {
switch detail {
case .indicator: return String(appLoc: "指标详情")
case .bloodPressure: return String(appLoc: "血压详情")
case .report: return String(appLoc: "报告详情")
case .diary: return String(appLoc: "日记详情")
case .symptom: return String(appLoc: "症状详情")
}
}
@ViewBuilder
private var bodyContent: some View {
switch detail {
case .indicator(let i): indicatorBody(i)
case .bloodPressure(let s, let d): bpBody(sys: s, dia: d)
case .report(let r): reportBody(r)
case .diary(let d): diaryBody(d)
case .symptom(let s): symptomBody(s)
}
}
// MARK: -
private func indicatorBody(_ i: Indicator) -> some View {
card {
HStack(alignment: .firstTextBaseline) {
Text(i.name).font(.tjH2()).foregroundStyle(Tj.Palette.text)
Spacer()
statusChip(i.status)
}
HStack(alignment: .firstTextBaseline, spacing: 4) {
Text(i.value)
.font(.tjScaled( 30, weight: .bold, design: .rounded))
.foregroundStyle(i.status == .normal ? Tj.Palette.text : Tj.Palette.brick)
if !i.unit.isEmpty {
Text(i.unit).font(.tjScaled( 14)).foregroundStyle(Tj.Palette.text3)
}
}
divider
if !i.range.isEmpty { field(String(appLoc: "参考范围"), i.range) }
field(String(appLoc: "记录时间"), Self.dateTimeText(i.capturedAt))
field(String(appLoc: "来源"), i.report?.title ?? i.source.label)
if let report = i.report {
evidenceButton(for: i, assets: report.assets)
}
if let note = i.note, !note.isEmpty { field(String(appLoc: "备注"), note) }
}
}
// MARK: - ()
private func bpBody(sys: Indicator, dia: Indicator?) -> some View {
let combined: IndicatorStatus = sys.status != .normal
? sys.status
: (dia?.status ?? .normal)
return card {
HStack(alignment: .firstTextBaseline) {
Text(String(appLoc: "血压")).font(.tjH2()).foregroundStyle(Tj.Palette.text)
Spacer()
statusChip(combined)
}
HStack(alignment: .firstTextBaseline, spacing: 4) {
Text("\(sys.value)/\(dia?.value ?? "")")
.font(.tjScaled( 30, weight: .bold, design: .rounded))
.foregroundStyle(combined == .normal ? Tj.Palette.text : Tj.Palette.brick)
Text("mmHg").font(.tjScaled( 14)).foregroundStyle(Tj.Palette.text3)
}
divider
if !sys.range.isEmpty { field(String(appLoc: "参考范围"), sys.range) }
field(String(appLoc: "记录时间"), Self.dateTimeText(sys.capturedAt))
}
}
// MARK: -
private func reportBody(_ r: Report) -> some View {
let sorted = r.indicators.sorted {
($0.status == .normal ? 1 : 0) < ($1.status == .normal ? 1 : 0)
}
return VStack(alignment: .leading, spacing: 16) {
card {
Text(r.title).font(.tjH2()).foregroundStyle(Tj.Palette.text)
HStack(spacing: 8) {
TjBadge(text: r.type.label, style: .neutral)
Text(Self.dateText(r.reportDate))
.font(.tjScaled( 12)).foregroundStyle(Tj.Palette.text3)
if !r.assets.isEmpty {
Text(String(appLoc: "原图\(r.assets.count)"))
.font(.tjScaled( 12)).foregroundStyle(Tj.Palette.text3)
}
}
if let inst = r.institution, !inst.isEmpty {
field(String(appLoc: "机构"), inst)
}
}
if let sum = r.summary, !sum.isEmpty {
card {
Text(String(appLoc: "摘要"))
.font(.tjScaled( 12, weight: .semibold)).foregroundStyle(Tj.Palette.text2)
Text(sum).font(.tjScaled( 14)).foregroundStyle(Tj.Palette.text)
.fixedSize(horizontal: false, vertical: true)
}
}
if !r.indicators.isEmpty {
card {
Text(String(appLoc: "指标"))
.font(.tjScaled( 12, weight: .semibold)).foregroundStyle(Tj.Palette.text2)
ForEach(sorted) { ind in
VStack(alignment: .leading, spacing: 6) {
HStack {
Text(ind.name).font(.tjScaled( 14)).foregroundStyle(Tj.Palette.text)
Spacer(minLength: 8)
Text(ind.unit.isEmpty ? ind.value : "\(ind.value) \(ind.unit)")
.font(.tjScaled( 13, design: .monospaced))
.foregroundStyle(ind.status == .normal ? Tj.Palette.text2 : Tj.Palette.brick)
statusChip(ind.status)
}
evidenceButton(for: ind, assets: r.assets)
}
}
}
}
if let note = r.note, !note.isEmpty {
card { field(String(appLoc: "备注"), note) }
}
}
}
// MARK: -
private func diaryBody(_ d: DiaryEntry) -> some View {
VStack(alignment: .leading, spacing: 16) {
card {
Text(Self.dateTimeText(d.createdAt))
.font(.tjScaled( 12)).foregroundStyle(Tj.Palette.text3)
Text(d.content)
.font(.tjScaled( 15))
.foregroundStyle(Tj.Palette.text)
.textSelection(.enabled)
.frame(maxWidth: .infinity, alignment: .leading)
.fixedSize(horizontal: false, vertical: true)
if !d.tags.isEmpty {
field(String(appLoc: "标签"), d.tags.map { "#\($0)" }.joined(separator: " "))
}
}
}
}
// MARK: -
private func symptomBody(_ s: Symptom) -> some View {
card {
HStack(alignment: .firstTextBaseline) {
Text(s.name).font(.tjH2()).foregroundStyle(Tj.Palette.text)
Spacer()
if s.isOngoing {
Text(String(appLoc: "进行中"))
.font(.tjScaled( 12, weight: .semibold))
.foregroundStyle(Tj.Palette.brick)
.padding(.horizontal, 8).padding(.vertical, 4)
.background(Capsule().fill(Tj.Palette.brick.opacity(0.14)))
}
}
divider
field(String(appLoc: "程度"), "\(s.severity) / 5")
field(String(appLoc: "开始"), Self.dateTimeText(s.startedAt))
field(String(appLoc: "结束"), s.endedAt.map(Self.dateTimeText) ?? String(appLoc: "进行中"))
field(String(appLoc: "持续"), formatDuration(s.duration))
if let note = s.note, !note.isEmpty { field(String(appLoc: "备注"), note) }
if !s.tags.isEmpty {
field(String(appLoc: "标签"), s.tags.map { "#\($0)" }.joined(separator: " "))
}
}
}
// MARK: -
@ViewBuilder
private func card<Content: View>(@ViewBuilder content: () -> Content) -> some View {
VStack(alignment: .leading, spacing: 10) { content() }
.padding(14)
.frame(maxWidth: .infinity, alignment: .leading)
.background(
RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)
.fill(Tj.Palette.paper)
)
.overlay(
RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)
.strokeBorder(Tj.Palette.lineSoft, lineWidth: 1)
)
}
private func field(_ label: String, _ value: String) -> some View {
HStack(alignment: .top, spacing: 12) {
Text(label).font(.tjScaled( 13)).foregroundStyle(Tj.Palette.text3)
Spacer(minLength: 12)
Text(value)
.font(.tjScaled( 14, weight: .medium))
.foregroundStyle(Tj.Palette.text)
.multilineTextAlignment(.trailing)
.fixedSize(horizontal: false, vertical: true)
}
}
@ViewBuilder
private func evidenceButton(for indicator: Indicator, assets: [Asset]) -> some View {
if indicator.hasEvidenceBox,
let page = indicator.sourcePageIndex,
assets.indices.contains(page) {
Button {
evidenceTarget = indicator
} label: {
Label(String(appLoc: "查看原图位置"), systemImage: "viewfinder")
.font(.tjScaled( 12, weight: .semibold))
.foregroundStyle(Tj.Palette.ink)
.padding(.horizontal, 10)
.padding(.vertical, 6)
.background(Capsule().fill(Tj.Palette.leaf.opacity(0.14)))
.contentShape(Rectangle())
}
.buttonStyle(.plain)
}
}
private var divider: some View {
Rectangle().fill(Tj.Palette.lineSoft).frame(height: 1)
}
private func statusChip(_ s: IndicatorStatus) -> some View {
let text: String
let color: Color
let arrow: String
switch s {
case .high: text = String(appLoc: "偏高"); color = Tj.Palette.brick; arrow = ""
case .low: text = String(appLoc: "偏低"); color = Tj.Palette.brick; arrow = ""
case .normal: text = String(appLoc: "正常"); color = Tj.Palette.leaf; arrow = ""
}
return HStack(spacing: 3) {
if !arrow.isEmpty { Text(arrow).font(.tjScaled( 11, weight: .bold)) }
Text(text).font(.tjScaled( 12, weight: .semibold))
}
.foregroundStyle(color)
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(Capsule().fill(color.opacity(0.14)))
}
private nonisolated static func dateTimeText(_ d: Date) -> String {
d.formatted(.dateTime.year().month().day().hour().minute())
}
private nonisolated static func dateText(_ d: Date) -> String {
d.formatted(.dateTime.year().month().day())
}
}
private struct EvidenceImagePreview: View {
@Environment(\.dismiss) private var dismiss
let report: Report
let indicator: Indicator
@State private var selection: Int
init(report: Report, indicator: Indicator) {
self.report = report
self.indicator = indicator
let page = indicator.sourcePageIndex ?? 0
_selection = State(initialValue: min(max(page, 0), max(report.assets.count - 1, 0)))
}
var body: some View {
VStack(spacing: 0) {
HStack(spacing: 12) {
Button { dismiss() } label: {
Image(systemName: "xmark")
.font(.tjScaled( 16, weight: .semibold))
.foregroundStyle(Tj.Palette.text)
.frame(width: 32, height: 32)
.background(Circle().fill(Tj.Palette.sand2))
}
VStack(alignment: .leading, spacing: 2) {
Text(indicator.name)
.font(.tjScaled( 16, weight: .semibold))
.foregroundStyle(Tj.Palette.text)
Text("\(selection + 1) 页 · 原图证据")
.font(.tjScaled( 12))
.foregroundStyle(Tj.Palette.text3)
}
Spacer()
}
.padding(.horizontal, 20)
.padding(.vertical, 14)
.background(Tj.Palette.sand)
.overlay(alignment: .bottom) {
Rectangle().fill(Tj.Palette.lineSoft).frame(height: 1)
}
TabView(selection: $selection) {
ForEach(Array(report.assets.enumerated()), id: \.offset) { index, asset in
EvidenceImagePage(
asset: asset,
highlight: index == indicator.sourcePageIndex ? indicator.evidenceRect : nil
)
.tag(index)
.padding(16)
}
}
.tabViewStyle(.page(indexDisplayMode: report.assets.count > 1 ? .automatic : .never))
}
.background(Tj.Palette.sand.ignoresSafeArea())
.presentationDetents([.large])
.presentationDragIndicator(.visible)
.presentationBackground(Tj.Palette.sand)
}
}
private struct EvidenceImagePage: View {
let asset: Asset
let highlight: CGRect?
private var image: UIImage? {
try? FileVault.shared.loadImage(relativePath: asset.relativePath)
}
var body: some View {
GeometryReader { geo in
if let image {
ZStack {
Image(uiImage: image)
.resizable()
.scaledToFit()
.frame(width: geo.size.width, height: geo.size.height)
if let highlight {
EvidenceHighlightOverlay(imageSize: image.size, normalizedRect: highlight)
}
}
.frame(width: geo.size.width, height: geo.size.height)
.background(Tj.Palette.paper)
.clipShape(RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)
.strokeBorder(Tj.Palette.lineSoft, lineWidth: 1)
)
} else {
TjPlaceholder(label: String(appLoc: "原图无法读取"))
.frame(width: geo.size.width, height: geo.size.height)
}
}
}
}
private struct EvidenceHighlightOverlay: View {
let imageSize: CGSize
let normalizedRect: CGRect
var body: some View {
GeometryReader { geo in
let fitted = fittedRect(imageSize: imageSize, containerSize: geo.size)
let rect = CGRect(
x: fitted.minX + normalizedRect.minX * fitted.width,
y: fitted.minY + normalizedRect.minY * fitted.height,
width: normalizedRect.width * fitted.width,
height: normalizedRect.height * fitted.height
)
RoundedRectangle(cornerRadius: 4, style: .continuous)
.fill(Tj.Palette.brick.opacity(0.16))
.overlay(
RoundedRectangle(cornerRadius: 4, style: .continuous)
.stroke(Tj.Palette.brick, lineWidth: 2)
)
.frame(width: rect.width, height: rect.height)
.position(x: rect.midX, y: rect.midY)
.shadow(color: Tj.Palette.brick.opacity(0.24), radius: 8, y: 2)
}
.allowsHitTesting(false)
}
private func fittedRect(imageSize: CGSize, containerSize: CGSize) -> CGRect {
guard imageSize.width > 0,
imageSize.height > 0,
containerSize.width > 0,
containerSize.height > 0 else {
return .zero
}
let scale = min(containerSize.width / imageSize.width, containerSize.height / imageSize.height)
let size = CGSize(width: imageSize.width * scale, height: imageSize.height * scale)
return CGRect(
x: (containerSize.width - size.width) / 2,
y: (containerSize.height - size.height) / 2,
width: size.width,
height: size.height
)
}
}