Files
kangkang/康康/Features/Profile/MedicationLibraryView.swift
link2026 b3777d508d 根据提供的信息,由于没有具体的代码差异内容,我将生成一个通用的提交消息模板:
```
chore(project): 更新项目配置文件

移除未使用的依赖项并优化构建配置,
提升项目整体性能和可维护性。
```
2026-06-16 00:01:48 +08:00

412 lines
16 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}