Files
kangkang/康康/Features/Profile/MedicationScanFlow.swift
link2026 de19d7abcd 根据提供的code differences信息,由于没有具体的代码变更内容,我将生成一个通用的commit message模板:
```
docs(readme): 更新文档说明

- 添加了项目使用指南
- 完善了API接口说明
- 修正了一些文字错误
```

注:由于未提供具体的代码差异信息,以上为示例格式。请提供具体的代码变更内容以便生成准确的commit message。
2026-06-17 08:35:59 +08:00

458 lines
19 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
import UIKit
/// :/( 5 ,) Vision OCR LLM ()
/// : · · ·
/// `MedicationArchiver`: `Medication`(),
/// · `medicationTag` DiaryEntry,/(§1)
///
/// :
/// ```
/// idle(/) 1 collecting(:/5//)
///
///
/// recognizing( OCR + LLM) confirm() onSave
/// / confirm( + )
/// ```
struct MedicationScanFlow: View {
/// (, )( MedicationArchiver.archive(medications:))
let onSave: ([ParsedMedication], [UIImage]) -> Void
let onClose: () -> Void
/// 5 (//)
static let maxImages = 5
@State private var phase: Phase = .idle
/// /, collecting recognizing confirm ,
@State private var images: [UIImage] = []
/// () OCR ;
@State private var recognizeIndex = 0
/// collecting /
@State private var showMoreCapture = false
/// :,
@State private var recognitionTask: Task<Void, Never>?
enum Phase {
case idle
case collecting
case recognizing
case confirm(items: [EditableMedication], warning: String?)
}
struct EditableMedication: Identifiable {
let id = UUID()
var name: String
var strength: String
var usage: String
var include: Bool = true
}
private var remainingSlots: Int { max(0, Self.maxImages - images.count) }
var body: some View {
content
.background(Tj.Palette.sand.ignoresSafeArea())
}
@ViewBuilder
private var content: some View {
switch phase {
case .idle:
// ignoresSafeArea:,
initialCaptureEntry
case .collecting:
collectingView
.fullScreenCover(isPresented: $showMoreCapture) { moreCaptureSheet }
case .recognizing:
recognizingView
case .confirm(let items, let warning):
NavigationStack {
MedicationConfirmView(
items: items,
warning: warning,
onSave: { saveItems($0) },
onRetake: { images = []; phase = .idle }
)
.navigationTitle("核对药品")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarLeading) {
Button("取消") { onClose() }
.foregroundStyle(Tj.Palette.text)
}
}
}
}
}
// MARK: - :()/ ()
/// :/ collecting
@ViewBuilder
private var initialCaptureEntry: some View {
#if targetEnvironment(simulator)
PhotoPickerSheet(
onFinish: { picked in
appendImages(picked)
if images.isEmpty { onClose() } else { phase = .collecting }
},
onCancel: onClose
)
#else
SingleShotCameraView(
onCapture: { appendImages([$0]); phase = .collecting },
onCancel: onClose
)
#endif
}
/// collecting /
@ViewBuilder
private var moreCaptureSheet: some View {
#if targetEnvironment(simulator)
PhotoPickerSheet(
onFinish: { picked in appendImages(picked); showMoreCapture = false },
onCancel: { showMoreCapture = false }
)
#else
SingleShotCameraView(
onCapture: { appendImages([$0]); showMoreCapture = false },
onCancel: { showMoreCapture = false }
)
#endif
}
private func appendImages(_ new: [UIImage]) {
guard remainingSlots > 0 else { return }
images.append(contentsOf: new.prefix(remainingSlots))
}
// MARK: - ( N : / / )
private var collectingView: some View {
VStack(spacing: 0) {
ScrollView {
LazyVGrid(columns: [GridItem(.adaptive(minimum: 96), spacing: 12)], spacing: 12) {
ForEach(Array(images.enumerated()), id: \.offset) { idx, img in
let isPick = idx == recognizeIndex
ZStack(alignment: .topTrailing) {
Image(uiImage: img)
.resizable()
.scaledToFill()
.frame(width: 96, height: 96)
.clipShape(RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)
.strokeBorder(isPick ? Tj.Palette.ink : Color.clear, lineWidth: 3)
)
.overlay(alignment: .bottomLeading) {
if isPick {
Text("识别此张")
.font(.tjScaled(10, weight: .semibold))
.foregroundStyle(Tj.Palette.paper)
.padding(.horizontal, 6)
.padding(.vertical, 3)
.background(Capsule().fill(Tj.Palette.ink))
.padding(5)
}
}
// ()
.onTapGesture { recognizeIndex = idx }
Button {
images.remove(at: idx)
// :;
if images.isEmpty {
recognizeIndex = 0
phase = .idle
} else if idx < recognizeIndex {
recognizeIndex -= 1
} else if recognizeIndex >= images.count {
recognizeIndex = images.count - 1
}
} label: {
Image(systemName: "xmark.circle.fill")
.font(.tjScaled(20))
.foregroundStyle(.white, .black.opacity(0.5))
.padding(4)
}
.buttonStyle(.plain)
}
}
if remainingSlots > 0 {
Button { showMoreCapture = true } label: {
VStack(spacing: 6) {
Image(systemName: "plus")
.font(.tjScaled(22, weight: .medium))
Text("继续拍")
.font(.tjScaled(12))
}
.foregroundStyle(Tj.Palette.text2)
.frame(width: 96, height: 96)
.background(
RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)
.strokeBorder(Tj.Palette.line, style: StrokeStyle(lineWidth: 1, dash: [4]))
)
}
.buttonStyle(.plain)
}
}
.padding(18)
}
VStack(spacing: 8) {
Text("已拍 \(images.count)/\(Self.maxImages) 张 · 可拍正面、背面、说明书")
.font(.tjScaled(12))
.foregroundStyle(Tj.Palette.text3)
if images.count > 1 {
Text("点照片选「识别此张」· 一次记一种药")
.font(.tjScaled(11))
.foregroundStyle(Tj.Palette.ink)
}
Text("照片与文字均不离开设备")
.font(.tjScaled(11))
.foregroundStyle(Tj.Palette.text3)
Button {
startRecognition()
} label: {
Text("开始识别")
.frame(maxWidth: .infinity)
}
.buttonStyle(TjPrimaryButton())
.disabled(images.isEmpty)
.opacity(images.isEmpty ? 0.4 : 1)
}
.padding(.horizontal, 18)
.padding(.bottom, 12)
}
.overlay(alignment: .topLeading) {
flowCancelButton { onClose() }
}
}
private var recognizingView: some View {
VStack(spacing: 18) {
if images.indices.contains(recognizeIndex) {
Image(uiImage: images[recognizeIndex])
.resizable()
.scaledToFit()
.frame(maxHeight: 320)
.clipShape(RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous))
.padding(.horizontal, 24)
}
ProgressView().tint(Tj.Palette.ink)
Text("正在本地识别药品…")
.font(.tjScaled(14))
.foregroundStyle(Tj.Palette.text2)
Text("照片与文字均不离开设备")
.font(.tjScaled(12))
.foregroundStyle(Tj.Palette.text3)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
// 退,(§3.2 )
.overlay(alignment: .topLeading) {
flowCancelButton {
recognitionTask?.cancel()
onClose()
}
}
}
private func flowCancelButton(_ action: @escaping () -> Void) -> some View {
Button(action: action) {
Text("取消")
.font(.tjScaled(16, weight: .medium))
.foregroundStyle(Tj.Palette.text)
.padding(.horizontal, 18)
.frame(minHeight: 44)
.background(Capsule().fill(Tj.Palette.paper))
.overlay(Capsule().strokeBorder(Tj.Palette.line, lineWidth: 1))
.contentShape(Capsule())
}
.buttonStyle(.plain)
.padding(.leading, 16)
.padding(.top, 8)
}
// MARK: - ( OCR LLM )
private func startRecognition() {
guard images.indices.contains(recognizeIndex) else { return }
phase = .recognizing
let target = images[recognizeIndex]
recognitionTask = Task {
let (items, warning) = await recognize(target)
guard !Task.isCancelled else { return } // : phase
await MainActor.run {
// :(§3.2 退线)
if items.isEmpty {
phase = .confirm(items: [EditableMedication(name: "", strength: "", usage: "")],
warning: warning ?? String(appLoc: "没读出药品,可以手动填写"))
} else {
phase = .confirm(items: items, warning: warning)
}
}
}
}
private func recognize(_ image: UIImage) async -> (items: [EditableMedication], warning: String?) {
do {
//
let text = (try? await OCRService.recognizeText(in: image))?
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if text.isEmpty {
return ([], String(appLoc: "没识别到文字,拍清楚一点再试"))
}
let parsed = try await MedicationScanService.shared.recognizeMedications(fromOCRText: text)
// :使,
let items = parsed.prefix(1).map {
EditableMedication(name: $0.name, strength: $0.strength, usage: $0.usage)
}
return (items, items.isEmpty ? String(appLoc: "没读出药品,可以手动填写") : nil)
} catch CaptureError.modelNotReady {
return ([], String(appLoc: "AI 模型未就绪,可以手动填写"))
} catch CaptureError.parseFailed {
// 退,; DEBUG ,
return ([], String(appLoc: "没认出药品信息,可检查照片清晰度后重拍,或手动填写"))
} catch CaptureError.inferenceFailed {
return ([], String(appLoc: "识别没成功,可重拍或手动填写"))
} catch {
return ([], String(appLoc: "识别没成功,可重拍或手动填写"))
}
}
// MARK: -
private func saveItems(_ items: [EditableMedication]) {
let meds = items
.filter { $0.include && !$0.name.trimmingCharacters(in: .whitespaces).isEmpty }
.map {
ParsedMedication(name: $0.name.trimmingCharacters(in: .whitespaces),
strength: $0.strength.trimmingCharacters(in: .whitespaces),
usage: $0.usage.trimmingCharacters(in: .whitespaces))
}
// (),
onSave(meds, images)
onClose()
}
}
// MARK: - (MainActor,SwiftData View ctx ,§3.1)
/// ,( · ):
/// `Medication`(), name+strength ;** currentMedications**
/// · `DiaryEntry.medicationTag`
@MainActor
enum MedicationArchiver {
static func archive(medications: [ParsedMedication], images: [UIImage] = [], in ctx: ModelContext) {
guard !medications.isEmpty else { return }
// Vault(§5/§6: Application Support/Vault,)
// , JPEG Asset
// cascade
// autoreleasepool: JPEG Data / ,
// 5 (,,线)
let savedAssets = images
.prefix(MedicationScanFlow.maxImages)
.compactMap { img in
autoreleasepool { try? FileVault.shared.writeJPEG(img) }
}
let existing = (try? ctx.fetch(FetchDescriptor<Medication>())) ?? []
var attachedImages = false
for m in medications {
// : name+strength / ,
if let dup = existing.first(where: { $0.name == m.name && $0.strength == m.strength }) {
if dup.usage.isEmpty, !m.usage.isEmpty { dup.usage = m.usage }
dup.updatedAt = .now
continue
}
let med = Medication(name: m.name, strength: m.strength, usage: m.usage)
if !attachedImages {
for s in savedAssets {
let asset = Asset(relativePath: s.relativePath, bytes: s.bytes)
ctx.insert(asset)
med.assets.append(asset)
}
attachedImages = true
}
ctx.insert(med)
}
try? ctx.save()
}
}
// MARK: -
private struct MedicationConfirmView: View {
@State var items: [MedicationScanFlow.EditableMedication]
let warning: String?
let onSave: ([MedicationScanFlow.EditableMedication]) -> Void
let onRetake: () -> Void
private var canSave: Bool {
items.contains {
$0.include && !$0.name.trimmingCharacters(in: .whitespaces).isEmpty
}
}
var body: some View {
VStack(spacing: 0) {
Form {
if let warning {
Section {
Label(warning, systemImage: "exclamationmark.triangle")
.font(.tjScaled(13))
.foregroundStyle(Tj.Palette.amber)
}
}
ForEach($items) { $item in
Section {
TextField(String(appLoc: "药品名,如:缬沙坦胶囊"), text: $item.name)
.foregroundStyle(Tj.Palette.text)
TextField(String(appLoc: "规格,如:80mg×7粒"), text: $item.strength)
.foregroundStyle(Tj.Palette.text2)
TextField(String(appLoc: "用法,如:一日一次,一次一粒"), text: $item.usage)
.foregroundStyle(Tj.Palette.text2)
}
}
Section {
Button {
onRetake()
} label: {
Label("重拍", systemImage: "camera")
.foregroundStyle(Tj.Palette.ink)
}
} footer: {
Text("一次记一种药,多张照片都会作为这种药的原图存入药品库,供查看与 AI 解读参考。不提供任何用药建议。")
}
}
.scrollContentBackground(.hidden)
Button {
onSave(items)
} label: {
Text("存入药品库")
.frame(maxWidth: .infinity)
}
.buttonStyle(TjPrimaryButton())
.disabled(!canSave)
.opacity(canSave ? 1 : 0.4)
.padding(.horizontal, 18)
.padding(.bottom, 12)
}
.background(Tj.Palette.sand.ignoresSafeArea())
}
}
#Preview {
MedicationScanFlow(onSave: { _, _ in }, onClose: {})
}