Files
kangkang/康康/Features/Quick/QuickRegionCaptureFlow.swift
link2026 6c6a950140 ```
feat: 添加拍药盒功能和语音直达入口

- 实现拍药盒扫描流程,支持本地OCR识别药品信息
- 在日记页面添加拍药盒和记症状的三选一入口
- 优化按钮点击区域,确保符合苹果HIG最小命中区标准
- 添加用药记录到时间线的独立分类显示
- 实现长按+号语音直达功能,支持语音意图分类跳转
- 更新项目配置文件,启用代码分析和死代码剥离选项
- 增加多项本地化字符串支持新功能
```
2026-06-13 09:16:25 +08:00

168 lines
6.6 KiB
Swift

import SwiftUI
import SwiftData
import UIKit
/// ·
/// ()/ () OCR+LLM Indicator
///
/// :
/// ```
/// idle(/) adjust(,) confirm() save dismiss
/// confirm idle
/// ```
/// /: adjust , confirm (§3.2 退线)
struct QuickRegionCaptureFlow: View {
@Environment(\.modelContext) private var ctx
let onClose: () -> Void
@State private var phase: Phase = .idle
enum Phase {
case idle
case adjust(image: UIImage)
case confirm(image: UIImage?, items: [QuickRegionItem], warning: String?)
}
var body: some View {
content
.background(Tj.Palette.sand.ignoresSafeArea())
}
@ViewBuilder
private var content: some View {
switch phase {
case .idle:
// ignoresSafeArea:/,
// ,
captureEntry
case .adjust(let image):
RegionAdjustView(
image: image,
recognize: { await recognizeRegion($0) },
onProceed: { items in
phase = .confirm(image: image, items: items, warning: nil)
},
onRetake: { phase = .idle },
onCancel: { onClose() }
)
case .confirm(let image, let items, let warning):
NavigationStack {
QuickRegionConfirmView(
image: image,
items: items,
warning: warning,
onSave: { finalItems, capturedAt in save(items: finalItems, capturedAt: capturedAt) },
onCancel: { onClose() },
onRetake: { phase = .idle }
)
.navigationTitle(String(appLoc: "核对指标"))
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarLeading) {
Button("取消") { onClose() }
.foregroundStyle(Tj.Palette.text)
}
}
}
}
}
// MARK: - :()/ ()
@ViewBuilder
private var captureEntry: some View {
#if targetEnvironment(simulator)
PhotoPickerSheet(
onFinish: { handlePicked($0) },
onCancel: onClose
)
#else
SingleShotCameraView(
onCapture: { phase = .adjust(image: $0) },
onCancel: onClose
)
#endif
}
/// /:;(=)
private func handlePicked(_ images: [UIImage]) {
if let first = images.first {
phase = .adjust(image: first)
} else {
onClose()
}
}
// MARK: - ( Vision OCR Qwen3 )
/// /,( RegionAdjustView )
/// :Vision OCR Qwen3
/// (VL :,OCR)
private func recognizeRegion(_ image: UIImage) async -> (items: [QuickRegionItem], warning: String?) {
do {
let text = try await OCRService.recognizeText(in: image)
if Task.isCancelled { return ([], nil) } // :
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
#if DEBUG
NSLog("KKDBG-OCR region text:\n%@\n--- end OCR ---", trimmed)
#endif
if trimmed.isEmpty {
return ([], String(appLoc: "没识别到文字,挪一下框再试"))
}
let parsed = try await CaptureService.shared.recognizeIndicators(fromOCRText: trimmed)
if Task.isCancelled { return ([], nil) }
let items = Self.buildItems(from: parsed)
return (items, items.isEmpty ? String(appLoc: "没读出指标,挪一下框再试") : nil)
} catch CaptureError.modelNotReady {
return ([], String(appLoc: "AI 模型未就绪,手动补充"))
} catch let CaptureError.parseFailed(msg) {
return ([], String(appLoc: "解析失败:\(msg)"))
} catch let CaptureError.inferenceFailed(msg) {
return ([], Task.isCancelled ? nil : String(appLoc: "识别失败:\(msg)"))
} catch {
return ([], Task.isCancelled ? nil : String(appLoc: "未知错误:\(error.localizedDescription)"))
}
}
/// LLM ,(high/low)
private static func buildItems(from parsed: [ParsedReport.ParsedIndicator]) -> [QuickRegionItem] {
let mapped = parsed.map {
QuickRegionItem(name: $0.name, value: $0.value, unit: $0.unit,
range: $0.range, status: $0.status, include: true)
}
return mapped.enumerated().sorted { a, b in
let aAbn = a.element.status != .normal
let bAbn = b.element.status != .normal
if aAbn != bAbn { return aAbn && !bAbn }
return a.offset < b.offset
}.map { $0.element }
}
// MARK: -
/// Indicator(): Report Asset seriesKey
private func save(items: [QuickRegionItem], capturedAt: Date) {
let selected = items.filter {
$0.include
&& !$0.name.trimmingCharacters(in: .whitespaces).isEmpty
&& !$0.value.trimmingCharacters(in: .whitespaces).isEmpty
}
for item in selected {
let indicator = Indicator(
name: item.name.trimmingCharacters(in: .whitespaces),
value: item.value.trimmingCharacters(in: .whitespaces),
unit: item.unit.trimmingCharacters(in: .whitespaces),
range: item.range.trimmingCharacters(in: .whitespaces),
status: item.status,
capturedAt: capturedAt,
source: .quickCapture
)
ctx.insert(indicator)
}
try? ctx.save()
onClose()
}
}