import SwiftUI import SwiftData /// 「记录 · 药品库」管理页:我有哪些药的 master 清单(药名 / 规格 / 用法 / 原图)。 /// 拍药盒识别或手动录入入库;某次服用流水另走「写日记 · 用药」(带 `DiaryEntry.medicationTag` 的日记,含剂量 + 时间)。 /// 列表 / 增删改范式照搬 `CustomMetricsListView`;编辑表单照搬 `CustomReminderEditSheet`。 struct MedicationLibraryView: View { @Environment(\.modelContext) private var ctx @Environment(\.dismiss) private var dismiss @Query(sort: \Medication.updatedAt, order: .reverse) private var medications: [Medication] /// sheet 形态(从「记录」拉起)补「完成」按钮;push 形态不补,靠返回键。 var presentedAsSheet: Bool = false @State private var editingTarget: MedicationEditTarget? @State private var showScan = false var body: some View { ScrollView { VStack(alignment: .leading, spacing: 12) { hintBanner if medications.isEmpty { emptyState } else { ForEach(medications) { m in Button { editingTarget = MedicationEditTarget(medication: m) } label: { row(m) } .buttonStyle(.plain) } } } .padding(.horizontal, 16) .padding(.top, 8) .padding(.bottom, 32) } .background(Tj.Palette.sand.ignoresSafeArea()) .navigationTitle("药品库") .navigationBarTitleDisplayMode(.inline) .toolbar { if presentedAsSheet { ToolbarItem(placement: .topBarLeading) { Button(String(appLoc: "完成")) { dismiss() } } } ToolbarItem(placement: .topBarTrailing) { HStack(spacing: 16) { Button { showScan = true } label: { Image(systemName: "camera") .font(.tjScaled( 16, weight: .semibold)) } .accessibilityLabel(String(appLoc: "拍药盒添加")) Button { editingTarget = MedicationEditTarget(medication: nil) } label: { Image(systemName: "plus") .font(.tjScaled( 16, weight: .semibold)) } .accessibilityLabel(String(appLoc: "手动添加")) } } } .sheet(item: $editingTarget) { target in MedicationEditSheet(existing: target.medication) } .fullScreenCover(isPresented: $showScan) { // 拍药盒 → 本地 OCR + LLM 识别 → 核对 → 入药品库(含原图)。 MedicationScanFlow( onSave: { meds, images in MedicationArchiver.archive(medications: meds, images: images, in: ctx) }, onClose: { showScan = false } ) } } // MARK: - subviews private var hintBanner: some View { HStack(spacing: 10) { Image(systemName: "info.circle.fill") .foregroundStyle(Tj.Palette.text3) Text("药品库是你的常用药清单。记录某次服用请到「写日记 · 用药」,可填剂量和时间。") .font(.tjScaled( 12)) .foregroundStyle(Tj.Palette.text2) .fixedSize(horizontal: false, vertical: true) Spacer(minLength: 0) } .padding(12) .background( RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous) .fill(Tj.Palette.sand2.opacity(0.5)) ) } private var emptyState: some View { VStack(spacing: 14) { Spacer(minLength: 40) TjPlaceholder(label: String(appLoc: "药品库还是空的")) .frame(width: 220, height: 130) Text("右上角拍药盒或 + 手动添加") .font(.tjScaled( 12)) .foregroundStyle(Tj.Palette.text3) Spacer() } .frame(maxWidth: .infinity) } private func row(_ m: Medication) -> some View { HStack(spacing: 12) { ZStack { Circle().fill(Tj.Palette.leafSoft) Image(systemName: "pills.fill") .font(.tjScaled( 17, weight: .medium)) .foregroundStyle(Tj.Palette.ink) } .frame(width: 40, height: 40) VStack(alignment: .leading, spacing: 3) { Text(m.name) .font(.tjScaled( 15, weight: .semibold)) .foregroundStyle(Tj.Palette.text) .lineLimit(1) if !m.detailLine.isEmpty { Text(m.detailLine) .font(.tjScaled( 11)) .foregroundStyle(Tj.Palette.text3) .lineLimit(1) } } Spacer(minLength: 8) if !m.assets.isEmpty { Text("📷 \(m.assets.count)") .font(.tjScaled( 11)) .foregroundStyle(Tj.Palette.text3) } Image(systemName: "chevron.right") .font(.tjScaled( 11, weight: .medium)) .foregroundStyle(Tj.Palette.text3) } .padding(14) .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) ) } } /// `medication == nil` → 新建;否则编辑。`id` 用 UUID 让同一对象重开 sheet 也能刷新。 private struct MedicationEditTarget: Identifiable { let id = UUID() let medication: Medication? } /// 药品库的新建 / 编辑表单(范式同 `CustomReminderEditSheet`:本地 @State 暂存,保存才写库)。 private struct MedicationEditSheet: View { @Environment(\.modelContext) private var ctx @Environment(\.dismiss) private var dismiss /// nil = 新建模式。 let existing: Medication? @State private var name = "" @State private var strength = "" @State private var usage = "" @State private var note = "" @State private var hydrated = false /// 点缩略图全屏查看的起始页;nil = 未打开查看器。 @State private var viewerStart: PhotoIndex? /// 「记录一次服用」:嵌套拉起 MedicationLogSheet,预选当前药。 @State private var showLog = false private var isEditing: Bool { existing != nil } private var canSave: Bool { !name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } var body: some View { NavigationStack { Form { if isEditing { Section { Button { showLog = true } label: { HStack(spacing: 10) { Image(systemName: "pills.circle.fill") .font(.tjScaled( 18)) .foregroundStyle(Tj.Palette.ink) Text("记录一次服用") .font(.tjScaled( 15, weight: .semibold)) .foregroundStyle(Tj.Palette.text) Spacer() Image(systemName: "chevron.right") .font(.tjScaled( 12, weight: .medium)) .foregroundStyle(Tj.Palette.text3) } .contentShape(Rectangle()) } .buttonStyle(.plain) } footer: { Text("记某次吃药的剂量和时间,会进「记录 · 用药」时间线。不提供剂量建议。") } } if let m = existing, !m.assets.isEmpty { Section { ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 10) { ForEach(Array(m.assets.enumerated()), id: \.offset) { idx, asset in Button { viewerStart = PhotoIndex(index: idx) } label: { MedicationAssetThumb(asset: asset) } .buttonStyle(.plain) } } .padding(.vertical, 4) } .listRowInsets(EdgeInsets(top: 8, leading: 12, bottom: 8, trailing: 12)) } header: { Text(String(appLoc: "原图\(m.assets.count)张")) } footer: { Text("点图片可放大查看。原图均存在本机加密目录,不上传。") } } Section { TextField(String(appLoc: "药名,如:缬沙坦胶囊"), text: $name) .foregroundStyle(Tj.Palette.text) TextField(String(appLoc: "规格,如:80mg×7粒"), text: $strength) .foregroundStyle(Tj.Palette.text2) TextField(String(appLoc: "用法,如:一日一次,一次一粒"), text: $usage) .foregroundStyle(Tj.Palette.text2) } footer: { Text("仅作清单记录,不提供任何用药或剂量建议。") } Section { TextField(String(appLoc: "备注(可选)"), text: $note, axis: .vertical) .lineLimit(1...3) .foregroundStyle(Tj.Palette.text2) } if isEditing { Section { Button(role: .destructive) { deleteMedication() } label: { Label(String(appLoc: "从药品库删除"), systemImage: "trash") } } } } .scrollContentBackground(.hidden) .background(Tj.Palette.sand.ignoresSafeArea()) .navigationTitle(isEditing ? String(appLoc: "编辑药品") : String(appLoc: "添加药品")) .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .topBarLeading) { Button(String(appLoc: "取消")) { dismiss() } } ToolbarItem(placement: .topBarTrailing) { Button(String(appLoc: "保存")) { save() } .fontWeight(.semibold) .disabled(!canSave) } } .onAppear(perform: hydrate) .fullScreenCover(item: $viewerStart) { start in if let m = existing { MedicationPhotoViewer(assets: m.assets, startIndex: start.index) } } .sheet(isPresented: $showLog) { MedicationLogSheet(preselected: existing) } } } private func hydrate() { guard !hydrated else { return } hydrated = true if let m = existing { name = m.name strength = m.strength usage = m.usage note = m.note ?? "" } } private func save() { guard canSave else { return } let n = name.trimmingCharacters(in: .whitespacesAndNewlines) let s = strength.trimmingCharacters(in: .whitespacesAndNewlines) let u = usage.trimmingCharacters(in: .whitespacesAndNewlines) let nt = note.trimmingCharacters(in: .whitespacesAndNewlines) if let m = existing { m.name = n m.strength = s m.usage = u m.note = nt.isEmpty ? nil : nt m.updatedAt = .now } else { let med = Medication(name: n, strength: s, usage: u, note: nt.isEmpty ? nil : nt) ctx.insert(med) } try? ctx.save() dismiss() } private func deleteMedication() { guard let m = existing else { return } // 先删 Vault 里的 JPEG(cascade 只删 Asset 记录,文件要手动 unlink,§6 永久删除)。 for a in m.assets { try? FileVault.shared.remove(relativePath: a.relativePath) } ctx.delete(m) try? ctx.save() dismiss() } } // MARK: - 原图查看 /// 全屏查看器的起始页载体(`.fullScreenCover(item:)` 需 Identifiable)。 private struct PhotoIndex: Identifiable { let id = UUID() let index: Int } /// 药品库行内 / 编辑表单里的方形缩略图。原图从加密 Vault 同步读取(数量少,与 EvidenceImagePage 同款)。 private struct MedicationAssetThumb: View { let asset: Asset var body: some View { VaultImage(relativePath: asset.relativePath, maxPixel: 500) { img in Image(uiImage: img).resizable().scaledToFill() } placeholder: { isLoading in if isLoading { Tj.Palette.paper } else { TjPlaceholder(label: String(appLoc: "原图无法读取")) } } .frame(width: 150, height: 150) .clipped() .clipShape(RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)) .overlay( RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous) .strokeBorder(Tj.Palette.lineSoft, lineWidth: 1) ) } } /// 全屏翻页查看药品原图(看清药盒小字)。 private struct MedicationPhotoViewer: View { @Environment(\.dismiss) private var dismiss let assets: [Asset] @State private var selection: Int init(assets: [Asset], startIndex: Int) { self.assets = assets _selection = State(initialValue: min(max(startIndex, 0), max(assets.count - 1, 0))) } var body: some View { ZStack(alignment: .topTrailing) { Color.black.ignoresSafeArea() TabView(selection: $selection) { ForEach(Array(assets.enumerated()), id: \.offset) { idx, asset in VaultImage(relativePath: asset.relativePath, maxPixel: 2000) { img in Image(uiImage: img).resizable().scaledToFit() } placeholder: { isLoading in if isLoading { ProgressView().tint(.white) } else { TjPlaceholder(label: String(appLoc: "原图无法读取")) } } .tag(idx) } } .tabViewStyle(.page(indexDisplayMode: assets.count > 1 ? .automatic : .never)) .ignoresSafeArea() Button { dismiss() } label: { Image(systemName: "xmark") .font(.tjScaled( 16, weight: .semibold)) .foregroundStyle(.white) .frame(width: 36, height: 36) .background(Circle().fill(.black.opacity(0.4))) } .padding(.trailing, 18) .padding(.top, 14) } } } #Preview { NavigationStack { MedicationLibraryView(presentedAsSheet: true) } .modelContainer(for: [Medication.self, Asset.self], inMemory: true) }