feat(AI): 集成MNN推理引擎替换MLX作为主AI运行时

- 引入MNN(alibaba) + Arm SME2 + CPU作为主AI运行时,支持A19/iPhone17的
  SME2和A17的NEON加速
- 添加MLX Swift作为兜底GPU推理方案,实现双后端切换机制
- 使用单一Qwen3.5-2B多模态模型(1.2GB),替代原有的LLM+VL分离架构
- 实现InferenceEngine.current引擎选择逻辑,真机默认MNN,模拟器回退MLX
- 更新AIAgent架构,通过MNNLLMBridge(ObjC++) → MNNBackend进行推理
- 修改队列机制防止并发推理导致OOM,使用信号量闸门控制显存占用
- 更新文档中的技术栈说明、模块边界和周次交付计划
```
This commit is contained in:
link2026
2026-06-15 09:24:59 +08:00
parent 6c6a950140
commit 9d856fcfc4
37 changed files with 2605 additions and 430 deletions

View File

@@ -7,42 +7,55 @@ import Foundation
nonisolated enum MedicationPrompts {
static func medicationsFromText(_ ocrText: String) -> String {
// 5 OCR, 2400( 1200 ,)
medicationsFromTextTemplate
.replacingOccurrences(of: "{{OCR_TEXT}}", with: VLPrompts.clipOCR(ocrText, limit: 1200))
.replacingOccurrences(of: "{{OCR_TEXT}}", with: VLPrompts.clipOCR(ocrText, limit: 2400))
}
private static let medicationsFromTextTemplate: String = #"""
你是药品包装识别助手。下面是对一张药盒、药品说明书处方单做 OCR 得到的纯文本,可能有错字、换行混乱或无关噪声。
你是药品包装识别助手。下面是对一种药品的多张照片(药盒正面/背面/说明书/处方单)做 OCR 得到的纯文本,各张之间用「---」分隔,可能有错字、换行混乱或无关噪声。
请从中提取药品信息,只输出一段合法 JSON,不要解释、不要 markdown 围栏、不要任何前后缀文字。
JSON schema(严格):
{
"medications": [
{
"name": string, // 药品通用名或商品名,如 ""
"name": string, // 药品名,见下方「name 怎么填」
"strength": string, // 规格,如 "80mg""0.5g×24";识别不出填 ""
"usage": string // 用法用量,如 ",";包装上没有就填 ""
}
]
}
规则:
- 只提取药品本身;""批准文号、生产厂家、批号、有效期、条形码一律忽略
- 一张药盒通常只有 1 种药;处方单可能有多种,都要提取
name 怎么填(关键,别搞混):
- 药品名 = 通用名(化学/药典名),这是要填进 name 的主体。中文药名照中文写,英文药名(如 "Metformin""Amoxicillin")就照英文原样抄,不要翻译、不要丢
- 若包装上同时印有商品名/商标名(厂商起的牌子名,如 """""Tylenol"),把它放在通用名后的括号里,例如 "()"。只读到商品名、读不到通用名时,就直接用商品名当 name
- 生产厂家/公司名/品牌 LOGO 文字(如 "XX药业有限公司""""")不是药名,一律不要当 name,也不要塞进括号。
通用规则:
- 只提取药品本身;""批准文号、生产厂家、批号、有效期、条形码、贮藏、二维码一律忽略。
- 多张照片通常是同一种药的不同面,合并成一条,不要因为来自不同照片就重复输出;处方单可能有多种药,才分多条。
- 不要发明药品。名称读不清的整条跳过;strength / usage 读不清就填 "",不要编造。
- 不要输出任何服药建议或剂量调整建议,只抄录包装上已有的文字。
- 同一药品只输出一次。
示例 1(药盒):
输入 OCR 文本: 缬沙坦胶囊 80mg×7粒 国药准字H20103521 XX药业有限公司
示例 1(药盒,含商品名 + 厂商):
输入 OCR 文本: 代文 缬沙坦胶囊 80mg×7粒 国药准字H20103521 北京诺华制药有限公司
输出:
{"medications":[{"name":"","strength":"80mg×7","usage":""}]}
{"medications":[{"name":"()","strength":"80mg×7","usage":""}]}
示例 2(说明书含用法):
输入 OCR 文本: 二甲双胍缓释片 0.5g×30片 用法用量:口服,一次1片,一日2次,随餐服用
输出:
{"medications":[{"name":"","strength":"0.5g×30","usage":",1,2,"}]}
示例 3(英文药名,正反两张合并):
输入 OCR 文本: Amoxicillin Capsules 500mg GSK
---
Dosage: Take one capsule three times daily
输出:
{"medications":[{"name":"Amoxicillin","strength":"500mg","usage":"Take one capsule three times daily"}]}
现在请解析下面这段 OCR 文本,只输出 JSON。/no_think
OCR 文本:

View File

@@ -112,6 +112,53 @@ JSON schema(严格):
{"title":"","type":"checkup","report_date":"2026-04-12","institution":"","page_count":1,"summary":"","indicators":[{"name":"","value":"3.84","unit":"mmol/L","range":"< 3.40","status":"high","source_page":1,"source_box":[0.12,0.31,0.76,0.07]},{"name":"","value":"32","unit":"U/L","range":"9 - 50","status":"normal","source_page":1,"source_box":[0.12,0.39,0.76,0.07]},{"name":"","value":"5.2","unit":"mmol/L","range":"3.9 - 6.1","status":"normal","source_page":1,"source_box":[0.12,0.47,0.76,0.07]}]}
{{OCR_SECTION}}
现在请识别图片并输出 JSON:
"""#
// MARK: - · meta(///,)
/// :,****( 2B OOM / )
/// Vision OCR , LLM meta (~50 token),
/// ,UI ( / ),
static func reportMetaFromText(_ ocrText: String, today: Date = .now) -> String {
let f = DateFormatter()
f.locale = Locale(identifier: "en_US_POSIX")
f.dateFormat = "yyyy-MM-dd"
let todayStr = f.string(from: today)
return reportMetaTemplate
.replacingOccurrences(of: "{{TODAY}}", with: todayStr)
.replacingOccurrences(of: "{{OCR_TEXT}}", with: clipOCR(ocrText, limit: 1500))
}
private static let reportMetaTemplate: String = #"""
你是体检/化验报告归档助手。下面是对一份报告做 OCR 得到的纯文本,可能有错字、错位、噪声。
请只提取这份报告的「元信息」,**不要提取任何具体指标/数值**。只输出一段合法 JSON,不要解释、不要 markdown 围栏、不要任何前后缀文字。
今天的日期是 {{TODAY}}。
JSON schema(严格):
{
"title": string, // 报告抬头,如 "";读不出就填 ""
"type": "checkup" | "lab" | "imaging" | "prescription" | "other",
"report_date": "YYYY-MM-DD", // 报告/采样/体检日期;实在读不出就填 ""
"institution": string // 医院/体检机构名;读不出就填 ""
}
规则:
- 只输出上面 4 个字段,绝不输出 indicators / 数值 / 参考范围。
- type:化验单→"lab";体检套餐→"checkup";影像(B超/CT/X光/MRI)→"imaging";处方→"prescription";拿不准→"other"
- 日期挑「报告日期 / 检查日期 / 采样日期」其一,统一成 YYYY-MM-DD;只有年月就补 -01;读不出填 ""
- institution 取医院/体检中心全称,去掉「检验科/报告单」等栏目词;读不出填 ""
- 不要编造;读不出的字段填 ""
示例 OCR 文本:
协和医院体检中心 健康体检报告 姓名:张三 体检日期:2026-04-12 低密度脂蛋白 3.84 ↑ ...
输出:
{"title":"","type":"checkup","report_date":"2026-04-12","institution":""}
现在请解析下面这段 OCR 文本,只输出 JSON。/no_think
OCR 文本:
{{OCR_TEXT}}
"""#
// MARK: - ()

View File

@@ -24,6 +24,7 @@ struct KangkangApp: App {
CustomMonitorMetric.self,
HealthExport.self,
CustomReminder.self,
Medication.self,
])
let config = ModelConfiguration(schema: schema, isStoredInMemoryOnly: false)
// store .completeUnlessOpen (§6),

View File

@@ -0,0 +1,47 @@
import SwiftUI
/// Vault
///
/// body `try? FileVault.shared.loadImage(...)` + ,
/// :
/// 1. **OOM**:(4000×3000 48MB), jetsam `maxPixel`
/// , KB, MB
/// 2. **线**: + JPEG 线线,线
///
/// :,,
/// `content` `UIImage`( `Image`),
/// 便 `image.size` ( overlay)
struct VaultImage<Content: View, Placeholder: View>: View {
let relativePath: String
/// () ~400, ~2000
var maxPixel: CGFloat = 1024
@ViewBuilder var content: (UIImage) -> Content
/// ,`isLoading == true` ,`false`
@ViewBuilder var placeholder: (_ isLoading: Bool) -> Placeholder
@State private var image: UIImage?
@State private var loading = true
var body: some View {
Group {
if let image {
content(image)
} else {
placeholder(loading)
}
}
// id (TabView / asset);
.task(id: relativePath) {
loading = true
let path = relativePath
let mp = maxPixel
let loaded = await Task.detached(priority: .userInitiated) {
try? FileVault.shared.loadDownsampledImage(relativePath: path, maxPixelSize: mp)
}.value
guard !Task.isCancelled else { return }
image = loaded
loading = false
}
}
}

View File

@@ -23,9 +23,12 @@ struct ArchiveListView: View {
@Query(sort: \MetricReminder.updatedAt, order: .reverse)
private var metricReminders: [MetricReminder]
@Query(sort: \Medication.updatedAt, order: .reverse)
private var medications: [Medication]
/// push `navigationDestination(item:)`
/// `navigationDestination(isPresented:)` SwiftUI ()
private enum Route: Hashable { case exports, reminders }
private enum Route: Hashable { case exports, reminders, medicationLibrary }
@State private var filter: TimelineKind? = nil
@State private var endingSymptom: Symptom?
@@ -33,57 +36,73 @@ struct ArchiveListView: View {
@State private var selectedGroup: IndicatorGroup?
@State private var route: Route?
/// :,(///), chip
@State private var searching = false
@State private var query = ""
@MainActor
private var allEntries: [TimelineEntry] {
let mapped =
TimelineEntry.from(indicators: indicators) +
TimelineEntry.aggregatedIndicators(indicators) +
reports.map(TimelineEntry.from(report:)) +
diaries.map(TimelineEntry.from(diary:)) +
symptoms.map(TimelineEntry.from(symptom:))
let filtered = filter.map { kind in mapped.filter { $0.kind == kind } } ?? mapped
return filtered.sorted { $0.date > $1.date }
let byKind = filter.map { kind in mapped.filter { $0.kind == kind } } ?? mapped
let q = query.trimmingCharacters(in: .whitespaces)
let byQuery = q.isEmpty ? byKind : byKind.filter { $0.title.localizedCaseInsensitiveContains(q) }
return byQuery.sorted { $0.date > $1.date }
}
private var grouped: [(section: DateSection, items: [TimelineEntry])] {
TimelineGrouping.group(allEntries)
}
private var totalCount: Int { allEntries.count }
var body: some View {
NavigationStack {
content
.navigationDestination(item: $route) { route in
switch route {
case .exports: HealthExportListView()
case .reminders: RemindersListView()
case .exports: HealthExportListView()
case .reminders: RemindersListView()
case .medicationLibrary: MedicationLibraryView()
}
}
}
}
private var content: some View {
VStack(alignment: .leading, spacing: 0) {
header
// ( O(m²))+ / body .isEmpty
// allEntries,;,
let entries = allEntries
let groups = TimelineGrouping.group(entries)
return VStack(alignment: .leading, spacing: 0) {
header(total: entries.count)
.padding(.horizontal, 20)
.padding(.top, 8)
.padding(.bottom, 14)
if reminderTotal > 0 {
reminderBoard
.padding(.horizontal, 20)
.padding(.bottom, 10)
}
// :/,
medicationBoard
.padding(.horizontal, 20)
.padding(.bottom, 14)
filterChips
.padding(.bottom, searching ? 10 : 14)
if searching {
searchField
.padding(.horizontal, 20)
.padding(.bottom, 14)
}
filterChips
.padding(.bottom, 14)
if allEntries.isEmpty {
if entries.isEmpty {
emptyState
} else {
ScrollView(showsIndicators: false) {
LazyVStack(alignment: .leading, spacing: 18, pinnedViews: [.sectionHeaders]) {
ForEach(grouped, id: \.section) { group in
ForEach(groups, id: \.section) { group in
Section {
VStack(spacing: 10) {
ForEach(group.items) { entry in
@@ -149,12 +168,12 @@ struct ArchiveListView: View {
diaries: diaries, symptoms: symptoms)
}
private var header: some View {
private func header(total: Int) -> some View {
HStack(alignment: .lastTextBaseline) {
Text("记录")
.font(.tjTitle(26))
.foregroundStyle(Tj.Palette.text)
Text(totalCount == 0 ? "" : String(appLoc: "\(totalCount)"))
Text(total == 0 ? "" : String(appLoc: "\(total)"))
.font(.tjScaled( 12))
.foregroundStyle(Tj.Palette.text3)
Spacer()
@@ -173,9 +192,57 @@ struct ArchiveListView: View {
}
.buttonStyle(.plain)
}
searchToggle
}
}
private var searchToggle: some View {
Button {
withAnimation(.easeInOut(duration: 0.18)) {
searching.toggle()
if !searching { query = "" }
}
} label: {
Image(systemName: searching ? "xmark" : "magnifyingglass")
.font(.tjScaled( 14, weight: .semibold))
.foregroundStyle(Tj.Palette.text)
.frame(width: 32, height: 32)
.background(Circle().fill(Tj.Palette.sand2))
}
.buttonStyle(.plain)
.accessibilityLabel(searching ? String(appLoc: "关闭搜索") : String(appLoc: "搜索记录"))
}
private var searchField: some View {
HStack(spacing: 8) {
Image(systemName: "magnifyingglass")
.font(.tjScaled( 13))
.foregroundStyle(Tj.Palette.text3)
TextField(String(appLoc: "搜索指标 / 报告 / 症状名"), text: $query)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.foregroundStyle(Tj.Palette.text)
.tint(Tj.Palette.ink)
if !query.isEmpty {
Button { query = "" } label: {
Image(systemName: "xmark.circle.fill")
.foregroundStyle(Tj.Palette.text3)
}
.buttonStyle(.plain)
}
}
.padding(.horizontal, 12)
.padding(.vertical, 10)
.background(
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
.fill(Tj.Palette.paper)
)
.overlay(
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
.strokeBorder(Tj.Palette.line, lineWidth: 1)
)
}
// MARK: -
/// ( + ),
@@ -241,6 +308,58 @@ struct ArchiveListView: View {
.buttonStyle(.plain)
}
// MARK: -
/// :, · N
private var medicationCountLabel: String {
medications.isEmpty
? String(appLoc: "药品库")
: String(appLoc: "药品库 · \(medications.count) 种常用药")
}
/// :; 3 (,)
private var medicationPreviewLine: String {
if medications.isEmpty { return String(appLoc: "拍药盒或手动添加常用药") }
let names = medications.prefix(3).map(\.name).joined(separator: " · ")
return medications.count > 3 ? names + "" : names
}
/// (MedicationLibraryView,push );
private var medicationBoard: some View {
Button { route = .medicationLibrary } label: {
HStack(spacing: 12) {
ZStack {
Circle().fill(medications.isEmpty ? Tj.Palette.sand2 : Tj.Palette.leafSoft)
Image(systemName: "pills.fill")
.font(.tjScaled( 16))
.foregroundStyle(medications.isEmpty ? Tj.Palette.text3 : Tj.Palette.ink)
}
.frame(width: 36, height: 36)
VStack(alignment: .leading, spacing: 2) {
Text(medicationCountLabel)
.font(.tjScaled( 15, weight: .semibold))
.foregroundStyle(Tj.Palette.text)
.lineLimit(1)
Text(medicationPreviewLine)
.font(.tjScaled( 12))
.foregroundStyle(Tj.Palette.text3)
.lineLimit(1)
}
Spacer(minLength: 0)
Image(systemName: "chevron.right")
.font(.tjScaled( 12, weight: .semibold))
.foregroundStyle(Tj.Palette.text3)
}
.padding(14)
.contentShape(Rectangle())
.tjCard()
}
.buttonStyle(.plain)
}
private var filterChips: some View {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
@@ -291,13 +410,19 @@ struct ArchiveListView: View {
}
private var emptyState: some View {
VStack(spacing: 14) {
let q = query.trimmingCharacters(in: .whitespaces)
let isSearchMiss = !q.isEmpty
return VStack(spacing: 14) {
Spacer()
TjPlaceholder(label: String(appLoc: "还没有任何记录\n点底部 + 号开始"))
TjPlaceholder(label: isSearchMiss
? String(appLoc: "没有匹配「\(q)」的记录")
: String(appLoc: "还没有任何记录\n点底部 + 号开始"))
.frame(width: 240, height: 140)
Text(filter == nil ? String(appLoc: "记录会按时间归类显示") : String(appLoc: "这个类别下没有记录"))
.font(.tjScaled( 13))
.foregroundStyle(Tj.Palette.text3)
if !isSearchMiss {
Text(filter == nil ? String(appLoc: "记录会按时间归类显示") : String(appLoc: "这个类别下没有记录"))
.font(.tjScaled( 13))
.foregroundStyle(Tj.Palette.text3)
}
Spacer()
}
.frame(maxWidth: .infinity)

View File

@@ -8,9 +8,12 @@ struct CaptureReviewForm: View {
@State var parsed: ParsedReport
let assets: [FileVault.SavedAsset]
let warning: String?
/// : + (///),
/// ( 2B OOM ), CaptureService.extractReportMeta
var metaOnly: Bool = false
let onSave: (ParsedReport) -> Void
let onCancel: () -> Void
/// assets () nil,banner
/// assets () nil,banner
var onReanalyze: (() -> Void)? = nil
var body: some View {
@@ -23,7 +26,9 @@ struct CaptureReviewForm: View {
pageThumbnails
}
metaSection
indicatorSection
if !metaOnly {
indicatorSection
}
Spacer(minLength: 8)
actions
}
@@ -68,20 +73,26 @@ struct CaptureReviewForm: View {
private var pageThumbnails: some View {
VStack(alignment: .leading, spacing: 8) {
sectionLabel(String(appLoc: "已保存 \(assets.count) 页(端侧加密)"))
if metaOnly {
Text("原图已加密保存,详情页随时可翻看放大。系统只识别报告日期与机构作为标签,不逐项录入数值。")
.font(.tjScaled( 11))
.foregroundStyle(Tj.Palette.text3)
.fixedSize(horizontal: false, vertical: true)
}
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 10) {
ForEach(Array(assets.enumerated()), id: \.offset) { _, asset in
if let img = try? FileVault.shared.loadImage(relativePath: asset.relativePath) {
Image(uiImage: img)
.resizable()
.scaledToFill()
.frame(width: 84, height: 110)
.clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: 8, style: .continuous)
.strokeBorder(Tj.Palette.line, lineWidth: 1)
)
VaultImage(relativePath: asset.relativePath, maxPixel: 400) { img in
Image(uiImage: img).resizable().scaledToFill()
} placeholder: { _ in
Tj.Palette.paper
}
.frame(width: 84, height: 110)
.clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: 8, style: .continuous)
.strokeBorder(Tj.Palette.line, lineWidth: 1)
)
}
}
}
@@ -117,9 +128,11 @@ struct CaptureReviewForm: View {
labeledField(String(appLoc: "机构(可选)")) {
TextField("如:协和医院", text: $parsed.institution)
}
labeledField(String(appLoc: "摘要(可选)")) {
TextField("一句话总结", text: $parsed.summary, axis: .vertical)
.lineLimit(1...3)
if !metaOnly {
labeledField(String(appLoc: "摘要(可选)")) {
TextField("一句话总结", text: $parsed.summary, axis: .vertical)
.lineLimit(1...3)
}
}
}
.padding(12)

View File

@@ -62,7 +62,7 @@ struct UnifiedCaptureFlow: View {
switch phase {
case .idle: return String(appLoc: "拍摄报告")
case .analyzing: return String(appLoc: "本地识别中…")
case .editing: return String(appLoc: "核对识别结果")
case .editing: return String(appLoc: "核对报告信息")
}
}
@@ -86,6 +86,7 @@ struct UnifiedCaptureFlow: View {
parsed: parsed,
assets: assets,
warning: warning,
metaOnly: true, // + meta,(§ CaptureService.extractReportMeta)
onSave: { final in saveAll(parsed: final, assets: assets) },
onCancel: cancelAll,
onReanalyze: assets.isEmpty ? nil : { reanalyze(assets: assets) }
@@ -152,9 +153,7 @@ struct UnifiedCaptureFlow: View {
phase = .analyzing(images: images, assets: nil)
let timeout = analyzeTimeoutSeconds
analyzeTask = Task {
// Step 1: Vault
// UI , CaptureService.analyze /退,
// assets phase ,cancelAll ,editingFallback
// Step 1: Vault(,)
let assets = images.compactMap { try? FileVault.shared.writeJPEG($0) }
// :,View dismisscancelAll
// phase .analyzing(_, nil),
@@ -167,7 +166,7 @@ struct UnifiedCaptureFlow: View {
phase = .editing(
parsed: .empty(),
assets: [],
warning: String(appLoc: "图片保存失败,手动录入并保留文本")
warning: String(appLoc: "图片保存失败,请重试")
)
}
return
@@ -179,49 +178,40 @@ struct UnifiedCaptureFlow: View {
}
}
// Step 2: VL (timeout cancel ,VLSession token break)
// Step 2: meta (OCR + LLM,///)
// 2B OOM watchdog cancel
let watchdog = Task {
try? await Task.sleep(for: .seconds(timeout))
analyzeTask?.cancel()
}
defer { watchdog.cancel() }
do {
let parsed = try await CaptureService.shared.reanalyze(assets: assets)
if Task.isCancelled {
await editingFallback(assets: assets,
msg: String(appLoc: "识别超时(>\(timeout)s),先手动录入"))
return
}
let (meta, recognized) = await CaptureService.shared.extractReportMeta(assets: assets)
if Task.isCancelled {
await MainActor.run {
phase = .editing(
parsed: parsed,
assets: assets,
warning: parsed.isEmpty ? String(appLoc: "识别没有读出指标,请手动补充") : nil
)
phase = .editing(parsed: .empty(), assets: assets,
warning: String(appLoc: "识别超时,已保存原图,请手动填写信息"))
}
} catch let CaptureError.parseFailed(msg) {
await editingFallback(assets: assets, msg: String(appLoc: "VL 输出无法解析:\(msg)"))
} catch let CaptureError.inferenceFailed(msg) {
await editingFallback(assets: assets,
msg: Task.isCancelled
? String(appLoc: "识别超时(>\(timeout)s),先手动录入")
: String(appLoc: "推理失败:\(msg)"))
} catch CaptureError.modelNotReady {
await editingFallback(assets: assets, msg: String(appLoc: "VL 模型未就绪,先手动录入"))
} catch {
await editingFallback(assets: assets,
msg: String(appLoc: "未知错误:\(error.localizedDescription)"))
return
}
await MainActor.run {
phase = .editing(
parsed: meta,
assets: assets,
warning: recognized ? nil
: String(appLoc: "未能自动识别报告信息,已保存原图,可手动填写日期 / 机构")
)
}
}
}
/// : assets,, VL
/// : assets,, meta
private func reanalyze(assets: [FileVault.SavedAsset]) {
analyzeTask?.cancel()
// UIImage,AnalyzingView
// UIImage,AnalyzingView , 600px ,
// ( MB)
let thumbnails: [UIImage] = assets.compactMap {
try? FileVault.shared.loadImage(relativePath: $0.relativePath)
try? FileVault.shared.loadDownsampledImage(relativePath: $0.relativePath, maxPixelSize: 600)
}
phase = .analyzing(images: thumbnails, assets: assets)
let timeout = analyzeTimeoutSeconds
@@ -232,40 +222,19 @@ struct UnifiedCaptureFlow: View {
}
defer { watchdog.cancel() }
do {
let parsed = try await CaptureService.shared.reanalyze(assets: assets)
if Task.isCancelled {
await editingFallback(assets: assets,
msg: String(appLoc: "识别超时(>\(timeout)s),保留旧编辑"))
return
}
let (meta, recognized) = await CaptureService.shared.extractReportMeta(assets: assets)
if Task.isCancelled {
await MainActor.run {
phase = .editing(
parsed: parsed,
assets: assets,
warning: parsed.isEmpty ? String(appLoc: "重新识别没有读出新指标") : nil
)
phase = .editing(parsed: .empty(), assets: assets,
warning: String(appLoc: "识别超时,已保留原图"))
}
} catch CaptureError.modelNotReady {
await editingFallback(assets: assets, msg: String(appLoc: "VL 模型未就绪"))
} catch let CaptureError.parseFailed(msg) {
await editingFallback(assets: assets, msg: String(appLoc: "VL 输出无法解析:\(msg)"))
} catch let CaptureError.inferenceFailed(msg) {
await editingFallback(assets: assets,
msg: Task.isCancelled
? String(appLoc: "识别超时(>\(timeout)s)")
: String(appLoc: "推理失败:\(msg)"))
} catch {
await editingFallback(assets: assets,
msg: String(appLoc: "未知错误:\(error.localizedDescription)"))
return
}
await MainActor.run {
phase = .editing(parsed: meta, assets: assets,
warning: recognized ? nil
: String(appLoc: "未能自动识别报告信息,可手动填写"))
}
}
}
/// reanalyze editing, assets parsed
private func editingFallback(assets: [FileVault.SavedAsset], msg: String) async {
await MainActor.run {
phase = .editing(parsed: .empty(), assets: assets, warning: msg)
}
}

View File

@@ -11,8 +11,10 @@ struct DiaryQuickSheet: View {
@State private var content: String = ""
@State private var createdAt: Date = .now
/// :,tag
/// :,
@State private var showMedicationScan = false
/// :( + + ),tag
@State private var showMedicationLog = false
/// : SymptomStartSheet(/,)
@State private var showSymptomStart = false
@@ -98,14 +100,20 @@ struct DiaryQuickSheet: View {
.padding(.horizontal, 20)
.padding(.bottom, 10)
// :()/ ()/ (SymptomStartSheet)
HStack(spacing: 10) {
// (2×2):()/ (MedicationLogSheet,+)/
// ()/ (SymptomStartSheet)
LazyVGrid(columns: [GridItem(.flexible(), spacing: 10),
GridItem(.flexible(), spacing: 10)], spacing: 10) {
modeCard(icon: "pencil", title: String(appLoc: "写日记"),
subtitle: String(appLoc: "文字或语音"), active: true) {
contentFocused = true
}
modeCard(icon: "pills.fill", title: String(appLoc: "拍药盒"),
subtitle: String(appLoc: "识别用药"), active: false) {
modeCard(icon: "pills.fill", title: String(appLoc: "用药"),
subtitle: String(appLoc: "记剂量与时间"), active: false) {
showMedicationLog = true
}
modeCard(icon: "camera.viewfinder", title: String(appLoc: "拍药盒"),
subtitle: String(appLoc: "识别入药品库"), active: false) {
showMedicationScan = true
}
modeCard(icon: "waveform.path.ecg", title: String(appLoc: "记症状"),
@@ -252,9 +260,9 @@ struct DiaryQuickSheet: View {
.presentationCornerRadius(Tj.Radius.xl)
.fullScreenCover(isPresented: $showMedicationScan) {
MedicationScanFlow(
onSave: { entries in
// :(线)+ ·
MedicationArchiver.archive(entries: entries, in: ctx)
onSave: { meds, images in
// (), ·
MedicationArchiver.archive(medications: meds, images: images, in: ctx)
dismiss()
},
onClose: { showMedicationScan = false }
@@ -264,6 +272,10 @@ struct DiaryQuickSheet: View {
// sheet:/;,
SymptomStartSheet()
}
.sheet(isPresented: $showMedicationLog) {
// sheet:/;()
MedicationLogSheet()
}
.onDisappear {
suggestTask?.cancel()
voiceFlowTask?.cancel()

View File

@@ -0,0 +1,133 @@
import SwiftUI
import SwiftData
/// · : ()+ +
/// `DiaryEntry.medicationTag` ,线
///
/// (`Medication`,master ):
/// sheet, / ( `SymptomStartSheet`),
struct MedicationLogSheet: View {
@Environment(\.modelContext) private var ctx
@Environment(\.dismiss) private var dismiss
@Query(sort: \Medication.updatedAt, order: .reverse)
private var library: [Medication]
/// ; nil
@State private var selectedMed: Medication?
/// (,) selectedMed
@State private var manualName = ""
@State private var dosage = ""
@State private var takenAt: Date = .now
private var resolvedName: String {
(selectedMed?.name ?? manualName).trimmingCharacters(in: .whitespacesAndNewlines)
}
private var canSave: Bool { !resolvedName.isEmpty }
var body: some View {
NavigationStack {
Form {
Section {
if library.isEmpty {
TextField(String(appLoc: "药名,如:缬沙坦胶囊"), text: $manualName)
.foregroundStyle(Tj.Palette.text)
} else {
ForEach(library) { m in
Button { select(m) } label: { medRow(m) }
.buttonStyle(.plain)
}
HStack(spacing: 8) {
Image(systemName: "pencil")
.foregroundStyle(Tj.Palette.text3)
TextField(String(appLoc: "或手动输入药名"), text: $manualName)
.foregroundStyle(Tj.Palette.text)
.onChange(of: manualName) { _, v in
if !v.trimmingCharacters(in: .whitespaces).isEmpty {
selectedMed = nil
}
}
}
}
} header: {
Text("吃了哪个药")
} footer: {
if library.isEmpty {
Text("药品库还没有药,可在「记录 · 药品库」拍药盒或手动添加。这里直接手输也行。")
}
}
Section {
TextField(String(appLoc: "剂量,如:1 片 / 80mg"), text: $dosage)
.foregroundStyle(Tj.Palette.text)
} header: {
Text("剂量")
}
Section {
DatePicker(String(appLoc: "时间"), selection: $takenAt, in: ...Date.now)
} header: {
Text("时间")
}
}
.scrollContentBackground(.hidden)
.background(Tj.Palette.sand.ignoresSafeArea())
.navigationTitle("记录用药")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarLeading) {
Button(String(appLoc: "取消")) { dismiss() }
}
ToolbarItem(placement: .topBarTrailing) {
Button(String(appLoc: "保存")) { save() }
.fontWeight(.semibold)
.disabled(!canSave)
}
}
}
}
private func medRow(_ m: Medication) -> some View {
let on = selectedMed === m
return HStack(spacing: 10) {
Image(systemName: on ? "checkmark.circle.fill" : "circle")
.foregroundStyle(on ? Tj.Palette.ink : Tj.Palette.text3)
VStack(alignment: .leading, spacing: 2) {
Text(m.name)
.foregroundStyle(Tj.Palette.text)
if !m.detailLine.isEmpty {
Text(m.detailLine)
.font(.tjScaled( 11))
.foregroundStyle(Tj.Palette.text3)
}
}
Spacer(minLength: 0)
}
.contentShape(Rectangle())
}
private func select(_ m: Medication) {
selectedMed = m
manualName = ""
}
private func save() {
guard canSave else { return }
// content : [] · , createdAt
// TimelineEntry.firstLine / TimelineEntryDetailView.medicationLines
var line = resolvedName
if let s = selectedMed?.strength, !s.isEmpty { line += " \(s)" }
let dose = dosage.trimmingCharacters(in: .whitespacesAndNewlines)
if !dose.isEmpty { line += " · \(dose)" }
let entry = DiaryEntry(content: line, createdAt: takenAt, tags: [DiaryEntry.medicationTag])
ctx.insert(entry)
try? ctx.save()
dismiss()
}
}
#Preview {
MedicationLogSheet()
.modelContainer(for: [Medication.self, DiaryEntry.self, Asset.self], inMemory: true)
}

View File

@@ -18,21 +18,19 @@ struct HomeView: View {
/// sheet( C1 )
@State private var selectedEntry: TimelineEntry?
/// ( + , C1 )
@State private var selectedGroup: IndicatorGroup?
@MainActor
private var recentEntries: [TimelineEntry] {
let all =
TimelineEntry.from(indicators: indicators) +
TimelineEntry.aggregatedIndicators(indicators) +
reports.map(TimelineEntry.from(report:)) +
diaries.map(TimelineEntry.from(diary:)) +
symptoms.map(TimelineEntry.from(symptom:))
return all.sorted { $0.date > $1.date }.prefix(6).map { $0 }
}
private var recentGrouped: [(section: DateSection, items: [TimelineEntry])] {
TimelineGrouping.group(recentEntries)
}
var body: some View {
ScrollView(showsIndicators: false) {
VStack(alignment: .leading, spacing: 0) {
@@ -65,6 +63,9 @@ struct HomeView: View {
TimelineEntryDetailView(detail: d)
}
}
.sheet(item: $selectedGroup) { group in
IndicatorSeriesDetailView(group: group)
}
}
private var greeting: some View {
@@ -100,7 +101,10 @@ struct HomeView: View {
}
private var recentSection: some View {
VStack(alignment: .leading, spacing: 10) {
// ( O(m²)) body ,, .isEmpty
let entries = recentEntries
let groups = TimelineGrouping.group(entries)
return VStack(alignment: .leading, spacing: 10) {
HStack(alignment: .lastTextBaseline) {
Text("最近记录").font(.tjH2()).foregroundStyle(Tj.Palette.text)
Spacer()
@@ -112,11 +116,11 @@ struct HomeView: View {
.buttonStyle(.plain)
}
if recentEntries.isEmpty {
if entries.isEmpty {
emptyRecent
} else {
VStack(alignment: .leading, spacing: 14) {
ForEach(recentGrouped, id: \.section) { group in
ForEach(groups, id: \.section) { group in
VStack(alignment: .leading, spacing: 8) {
Text(group.section.label)
.font(.tjScaled( 11, weight: .semibold))
@@ -125,12 +129,16 @@ struct HomeView: View {
VStack(spacing: 10) {
ForEach(group.items) { entry in
Button {
if TimelineDetail.resolve(
// ( + ); C1
guard let d = TimelineDetail.resolve(
for: entry,
indicators: indicators, reports: reports,
diaries: diaries, symptoms: symptoms
) != nil {
selectedEntry = entry
) else { return }
switch d {
case .indicator(let i): selectedGroup = IndicatorGroup.of(i)
case .bloodPressure(let sys, _): selectedGroup = IndicatorGroup.of(sys)
default: selectedEntry = entry
}
} label: {
TimelineRow(entry: entry)

View File

@@ -31,6 +31,18 @@ struct IndicatorQuickSheet: View {
/// nil ( Preview)
var onRequestCamera: (() -> Void)? = nil
/// nil =
/// seriesKey MonitorMetric / CustomMonitorMetric ( + );
/// name/unit/range ,
var prefill: Prefill? = nil
struct Prefill: Equatable {
var seriesKey: String?
var name: String = ""
var unit: String = ""
var range: String = ""
}
@Environment(\.modelContext) private var ctx
@Environment(\.dismiss) private var dismiss
@Query private var profiles: [UserProfile]
@@ -69,6 +81,32 @@ struct IndicatorQuickSheet: View {
// sheet
@State private var showHiddenSheet: Bool = false
//
@State private var didApplyPrefill = false
// :, / /
@State private var searchingMetrics = false
@State private var metricQuery = ""
private var isSearchingMetrics: Bool {
!metricQuery.trimmingCharacters(in: .whitespaces).isEmpty
}
private var filteredMonitorMetrics: [MonitorMetric] {
let q = metricQuery.trimmingCharacters(in: .whitespaces)
guard !q.isEmpty else { return visibleMonitorMetrics }
return visibleMonitorMetrics.filter { $0.displayName.localizedCaseInsensitiveContains(q) }
}
private var filteredCustomMetrics: [CustomMonitorMetric] {
let q = metricQuery.trimmingCharacters(in: .whitespaces)
guard !q.isEmpty else { return customMetrics }
return customMetrics.filter { $0.name.localizedCaseInsensitiveContains(q) }
}
private var filteredLabPresets: [IndicatorPreset] {
let q = metricQuery.trimmingCharacters(in: .whitespaces)
guard !q.isEmpty else { return labPresets }
return labPresets.filter { $0.name.localizedCaseInsensitiveContains(q) }
}
private static var defaultReminderTime: Date {
Calendar.current.date(bySettingHour: 8, minute: 0, second: 0, of: .now) ?? .now
}
@@ -137,6 +175,7 @@ struct IndicatorQuickSheet: View {
footer
}
.onAppear { applyPrefillIfNeeded() }
.task(id: longTermKey) { hydrateReminder() }
.background(
Tj.Palette.sand
@@ -161,19 +200,64 @@ struct IndicatorQuickSheet: View {
}
private var header: some View {
HStack {
Text("记录指标")
.font(.tjH2())
.foregroundStyle(Tj.Palette.text)
Spacer()
Text("本地处理 · 永不上传")
.font(.tjScaled( 12))
.foregroundStyle(Tj.Palette.text3)
VStack(spacing: 12) {
HStack(spacing: 10) {
Text("记录指标")
.font(.tjH2())
.foregroundStyle(Tj.Palette.text)
Spacer()
Text("本地处理 · 永不上传")
.font(.tjScaled( 12))
.foregroundStyle(Tj.Palette.text3)
searchToggle
}
if searchingMetrics { searchField }
}
.padding(.horizontal, 20)
.padding(.bottom, 16)
}
private var searchToggle: some View {
Button {
withAnimation(.easeInOut(duration: 0.18)) {
searchingMetrics.toggle()
if !searchingMetrics { metricQuery = "" }
}
} label: {
Image(systemName: searchingMetrics ? "xmark" : "magnifyingglass")
.font(.tjScaled( 14, weight: .semibold))
.foregroundStyle(Tj.Palette.text)
.frame(width: 32, height: 32)
.background(Circle().fill(Tj.Palette.sand2))
}
.buttonStyle(.plain)
.accessibilityLabel(searchingMetrics ? String(appLoc: "关闭搜索") : String(appLoc: "搜索指标"))
}
private var searchField: some View {
HStack(spacing: 8) {
Image(systemName: "magnifyingglass")
.font(.tjScaled( 13))
.foregroundStyle(Tj.Palette.text3)
TextField(String(appLoc: "搜索指标名"), text: $metricQuery)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.foregroundStyle(Tj.Palette.text)
.tint(Tj.Palette.ink)
if !metricQuery.isEmpty {
Button { metricQuery = "" } label: {
Image(systemName: "xmark.circle.fill")
.foregroundStyle(Tj.Palette.text3)
}
.buttonStyle(.plain)
}
}
.padding(.horizontal, 12)
.padding(.vertical, 10)
.background(fieldBg)
.overlay(fieldBorder)
}
/// : RootView VL
@ViewBuilder
private var cameraEntrySection: some View {
@@ -241,13 +325,19 @@ struct IndicatorQuickSheet: View {
}
let columns = [GridItem(.flexible()), GridItem(.flexible())]
LazyVGrid(columns: columns, spacing: 8) {
ForEach(visibleMonitorMetrics) { m in
ForEach(filteredMonitorMetrics) { m in
monitorTile(m)
}
ForEach(customMetrics) { cm in
ForEach(filteredCustomMetrics) { cm in
customTile(cm)
}
addCustomTile
// (),
if !isSearchingMetrics { addCustomTile }
}
if isSearchingMetrics, filteredMonitorMetrics.isEmpty, filteredCustomMetrics.isEmpty {
Text("没有匹配的长期监测指标")
.font(.tjScaled( 12))
.foregroundStyle(Tj.Palette.text3)
}
}
.sheet(isPresented: $showHiddenSheet) {
@@ -386,14 +476,18 @@ struct IndicatorQuickSheet: View {
}
}
@ViewBuilder
private var labPresetSection: some View {
VStack(alignment: .leading, spacing: 8) {
sectionLabel(String(appLoc: "化验项快捷(不进趋势)"))
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
ForEach(labPresets) { p in
chip(p.name, selected: selectedLabPreset == p) {
applyLab(p)
// :()
if !(isSearchingMetrics && filteredLabPresets.isEmpty) {
VStack(alignment: .leading, spacing: 8) {
sectionLabel(String(appLoc: "化验项快捷(不进趋势)"))
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
ForEach(filteredLabPresets) { p in
chip(p.name, selected: selectedLabPreset == p) {
applyLab(p)
}
}
}
}
@@ -941,6 +1035,29 @@ struct IndicatorQuickSheet: View {
// MARK: - apply preset
/// :seriesKey / ( + ),
/// name/unit/range
private func applyPrefillIfNeeded() {
guard !didApplyPrefill, let p = prefill else { return }
didApplyPrefill = true
if let key = p.seriesKey {
if let m = MonitorMetric.allCases.first(where: { metric in
metric.fields.contains { $0.seriesKey == key }
}) {
applyMonitor(m)
return
}
if let cm = customMetrics.first(where: { $0.seriesKey == key }) {
applyCustom(cm)
return
}
}
// seriesKey ( / / ):, seriesKey,
name = p.name
unit = p.unit
range = p.range
}
private func applyMonitor(_ m: MonitorMetric) {
if selectedMonitor == m {
//

View File

@@ -0,0 +1,39 @@
import SwiftUI
extension IndicatorQuickSheet.Prefill {
/// :
/// seriesKey / ( + ), name/unit/range
init(indicator i: Indicator) {
self.init(seriesKey: i.seriesKey, name: i.name, unit: i.unit, range: i.range)
}
}
/// / :(,)
/// ,`TimelineEntryDetailView` `IndicatorSeriesDetailView`
struct RecordAnotherButton: View {
/// ()
let name: String
///
let prefill: IndicatorQuickSheet.Prefill
@State private var showSheet = false
var body: some View {
Button { showSheet = true } label: {
Label(String(appLoc: "再记一条「\(name)"), systemImage: "plus.circle.fill")
.font(.tjScaled( 13, weight: .semibold))
.foregroundStyle(Tj.Palette.ink)
.frame(maxWidth: .infinity)
.padding(.vertical, 12)
.background(
RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)
.fill(Tj.Palette.leaf.opacity(0.16))
)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.sheet(isPresented: $showSheet) {
IndicatorQuickSheet(prefill: prefill)
}
}
}

View File

@@ -10,13 +10,20 @@ struct CustomReminderEditSheet: View {
/// nil =
let reminder: CustomReminder?
/// (,:+ )
/// (reminder != nil)
private let prefillTitle: String
private let prefillNote: String
@State private var title = ""
@State private var note = ""
@State private var pickedTime: Date = .now
@State private var frequency: CustomReminder.Frequency = .daily
/// (/// )
@State private var frequencies: Set<CustomReminder.Frequency> = [.daily]
@State private var weekdays: Set<Int> = Set(1...7)
@State private var dayOfMonth = 1
/// (1...31)
@State private var monthDays: Set<Int> = [1]
@State private var dayOfMonth = 1 //
@State private var month = 1
@State private var hydrated = false
@State private var showAuthDeniedAlert = false
@@ -24,8 +31,10 @@ struct CustomReminderEditSheet: View {
/// (, ): / / /
private let timePresets: [(h: Int, m: Int)] = [(8, 0), (12, 0), (18, 0), (22, 0)]
init(reminder: CustomReminder? = nil) {
init(reminder: CustomReminder? = nil, prefillTitle: String = "", prefillNote: String = "") {
self.reminder = reminder
self.prefillTitle = prefillTitle
self.prefillNote = prefillNote
}
private var isEditing: Bool { reminder != nil }
@@ -33,8 +42,9 @@ struct CustomReminderEditSheet: View {
title.trimmingCharacters(in: .whitespacesAndNewlines)
}
private var canSave: Bool {
guard !trimmedTitle.isEmpty else { return false }
if frequency == .weekly { return !weekdays.isEmpty }
guard !trimmedTitle.isEmpty, !frequencies.isEmpty else { return false }
if frequencies.contains(.weekly) && weekdays.isEmpty { return false }
if frequencies.contains(.monthly) && monthDays.isEmpty { return false }
return true
}
@@ -51,18 +61,12 @@ struct CustomReminderEditSheet: View {
}
Section {
Picker(String(appLoc: "重复"), selection: $frequency) {
Text(String(appLoc: "每日")).tag(CustomReminder.Frequency.daily)
Text(String(appLoc: "每周")).tag(CustomReminder.Frequency.weekly)
Text(String(appLoc: "每月")).tag(CustomReminder.Frequency.monthly)
Text(String(appLoc: "每年")).tag(CustomReminder.Frequency.yearly)
}
.pickerStyle(.segmented)
.listRowBackground(Color.clear)
frequencyChips
frequencyDetail
} header: {
Text("重复")
} footer: {
Text("可多选:如同时勾选「每周一三五」+「每月1日」,两种节奏都会提醒。")
}
Section {
@@ -109,23 +113,60 @@ struct CustomReminderEditSheet: View {
}
}
// MARK: -
// MARK: - chip
private static let freqOrder: [CustomReminder.Frequency] = [.daily, .weekly, .monthly, .yearly]
private func freqLabel(_ f: CustomReminder.Frequency) -> String {
switch f {
case .daily: return String(appLoc: "每日")
case .weekly: return String(appLoc: "每周")
case .monthly: return String(appLoc: "每月")
case .yearly: return String(appLoc: "每年")
}
}
private var frequencyChips: some View {
HStack(spacing: 8) {
ForEach(Self.freqOrder, id: \.self) { f in
let on = frequencies.contains(f)
Button {
if on { frequencies.remove(f) } else { frequencies.insert(f) }
} label: {
Text(freqLabel(f))
.font(.tjScaled( 13, weight: on ? .semibold : .regular))
.foregroundStyle(on ? Tj.Palette.paper : Tj.Palette.text)
.frame(maxWidth: .infinity, minHeight: 32)
.background(
RoundedRectangle(cornerRadius: 8, style: .continuous)
.fill(on ? Tj.Palette.ink : Tj.Palette.paper)
)
.overlay(
RoundedRectangle(cornerRadius: 8, style: .continuous)
.strokeBorder(Tj.Palette.line, lineWidth: on ? 0 : 1)
)
}
.buttonStyle(.plain)
}
}
.listRowBackground(Color.clear)
}
// MARK: - (,)
@ViewBuilder
private var frequencyDetail: some View {
switch frequency {
case .daily:
EmptyView()
case .weekly:
if frequencies.contains(.weekly) {
subCaption(String(appLoc: "每周 · 选星期几"))
weekdayRow
case .monthly:
Picker(String(appLoc: "日期"), selection: $dayOfMonth) {
ForEach(1...31, id: \.self) { d in
Text(String(appLoc: "\(d)")).tag(d)
}
}
if dayOfMonth >= 29 { skipHint }
case .yearly:
}
if frequencies.contains(.monthly) {
subCaption(String(appLoc: "每月 · 选日期(可多选)"))
monthDayGrid
if monthDays.contains(where: { $0 >= 29 }) { skipHint }
}
if frequencies.contains(.yearly) {
subCaption(String(appLoc: "每年 · 选月/日"))
Picker(String(appLoc: "月份"), selection: $month) {
ForEach(1...12, id: \.self) { mo in
Text(String(appLoc: "\(mo)")).tag(mo)
@@ -140,6 +181,41 @@ struct CustomReminderEditSheet: View {
}
}
private func subCaption(_ text: String) -> some View {
Text(text)
.font(.tjScaled( 11, weight: .semibold))
.foregroundStyle(Tj.Palette.text3)
.frame(maxWidth: .infinity, alignment: .leading)
.listRowBackground(Color.clear)
}
/// (1...31,7 )
private var monthDayGrid: some View {
LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 6), count: 7), spacing: 6) {
ForEach(1...31, id: \.self) { d in
let on = monthDays.contains(d)
Button {
if on { monthDays.remove(d) } else { monthDays.insert(d) }
} label: {
Text("\(d)")
.font(.tjScaled( 12, weight: on ? .semibold : .regular))
.foregroundStyle(on ? Tj.Palette.paper : Tj.Palette.text)
.frame(maxWidth: .infinity, minHeight: 30)
.background(
RoundedRectangle(cornerRadius: 6, style: .continuous)
.fill(on ? Tj.Palette.ink : Tj.Palette.paper)
)
.overlay(
RoundedRectangle(cornerRadius: 6, style: .continuous)
.strokeBorder(Tj.Palette.line, lineWidth: on ? 0 : 1)
)
}
.buttonStyle(.plain)
}
}
.listRowBackground(Color.clear)
}
private var skipHint: some View {
Text(String(appLoc: "部分月份无此日,该月将跳过"))
.font(.tjScaled( 11))
@@ -229,13 +305,18 @@ struct CustomReminderEditSheet: View {
if let r = reminder {
title = r.title
note = r.note
frequency = r.frequency
frequencies = r.frequencies
weekdays = Set(r.weekdays)
monthDays = Set(r.monthlyDays)
dayOfMonth = r.dayOfMonth
month = r.month
pickedTime = Calendar.current.date(
bySettingHour: r.hour, minute: r.minute, second: 0, of: .now
) ?? .now
} else {
// :( / )
title = prefillTitle
note = prefillNote
}
}
@@ -245,6 +326,7 @@ struct CustomReminderEditSheet: View {
let hour = cal.component(.hour, from: pickedTime)
let minute = cal.component(.minute, from: pickedTime)
let sortedDays = weekdays.sorted()
let sortedMonthDays = monthDays.sorted()
let target: CustomReminder
if let r = reminder {
@@ -253,8 +335,9 @@ struct CustomReminderEditSheet: View {
r.hour = hour
r.minute = minute
r.weekdays = sortedDays
r.frequency = frequency
r.dayOfMonth = dayOfMonth
r.frequencies = frequencies // frequenciesRaw(+ frequencyRaw)
r.monthlyDays = sortedMonthDays // monthDays
r.dayOfMonth = dayOfMonth //
r.month = month
r.updatedAt = .now
target = r
@@ -265,10 +348,11 @@ struct CustomReminderEditSheet: View {
hour: hour,
minute: minute,
weekdays: sortedDays,
frequency: frequency,
dayOfMonth: dayOfMonth,
month: month
)
new.frequencies = frequencies
new.monthlyDays = sortedMonthDays
ctx.insert(new)
target = new
}

View File

@@ -282,6 +282,6 @@ struct MeView: View {
.modelContainer(for: [
UserProfile.self, Indicator.self, Report.self, DiaryEntry.self,
Asset.self, ChatTurn.self, Symptom.self, MetricReminder.self,
CustomMonitorMetric.self,
CustomMonitorMetric.self, Medication.self,
], inMemory: true)
}

View File

@@ -0,0 +1,383 @@
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?
private var isEditing: Bool { existing != nil }
private var canSave: Bool {
!name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
}
var body: some View {
NavigationStack {
Form {
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)
}
}
}
}
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)
}

View File

@@ -2,28 +2,41 @@ import SwiftUI
import SwiftData
import UIKit
/// :/ Vision OCR LLM
/// :+ · · · ·
/// `MedicationArchiver`:(线)+
/// ,/(§1)
/// :/( 5 ,) Vision OCR LLM ()
/// : · · ·
/// `MedicationArchiver`: `Medication`(),
/// · `medicationTag` DiaryEntry,/(§1)
///
/// ( QuickRegionCaptureFlow ):
/// :
/// ```
/// idle(/) recognizing(OCR + LLM) confirm() onSave
/// / confirm( + ,)
/// idle(/) 1 collecting(:/5//)
///
///
/// recognizing( OCR + LLM) confirm() onSave
/// / confirm( + )
/// ```
struct MedicationScanFlow: View {
/// (, " 80mg · ")
let onSave: ([String]) -> Void
/// (, )( 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 recognizing(image: UIImage)
case collecting
case recognizing
case confirm(items: [EditableMedication], warning: String?)
}
@@ -35,6 +48,8 @@ struct MedicationScanFlow: View {
var include: Bool = true
}
private var remainingSlots: Int { max(0, Self.maxImages - images.count) }
var body: some View {
content
.background(Tj.Palette.sand.ignoresSafeArea())
@@ -45,10 +60,14 @@ struct MedicationScanFlow: View {
switch phase {
case .idle:
// ignoresSafeArea:,
captureEntry
initialCaptureEntry
case .recognizing(let image):
recognizingView(image: image)
case .collecting:
collectingView
.fullScreenCover(isPresented: $showMoreCapture) { moreCaptureSheet }
case .recognizing:
recognizingView
case .confirm(let items, let warning):
NavigationStack {
@@ -56,7 +75,7 @@ struct MedicationScanFlow: View {
items: items,
warning: warning,
onSave: { saveItems($0) },
onRetake: { phase = .idle }
onRetake: { images = []; phase = .idle }
)
.navigationTitle("核对药品")
.navigationBarTitleDisplayMode(.inline)
@@ -72,31 +91,160 @@ struct MedicationScanFlow: View {
// MARK: - :()/ ()
/// :/ collecting
@ViewBuilder
private var captureEntry: some View {
private var initialCaptureEntry: some View {
#if targetEnvironment(simulator)
PhotoPickerSheet(
onFinish: { images in
if let first = images.first { startRecognition(first) } else { onClose() }
onFinish: { picked in
appendImages(picked)
if images.isEmpty { onClose() } else { phase = .collecting }
},
onCancel: onClose
)
#else
SingleShotCameraView(
onCapture: { startRecognition($0) },
onCapture: { appendImages([$0]); phase = .collecting },
onCancel: onClose
)
#endif
}
private func recognizingView(image: UIImage) -> some View {
/// 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) {
Image(uiImage: image)
.resizable()
.scaledToFit()
.frame(maxHeight: 320)
.clipShape(RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous))
.padding(.horizontal, 24)
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))
@@ -108,31 +256,37 @@ struct MedicationScanFlow: View {
.frame(maxWidth: .infinity, maxHeight: .infinity)
// 退,(§3.2 )
.overlay(alignment: .topLeading) {
Button {
flowCancelButton {
recognitionTask?.cancel()
onClose()
} label: {
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 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)
}
private func startRecognition(_ image: UIImage) {
phase = .recognizing(image: image)
// 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(image)
let (items, warning) = await recognize(target)
guard !Task.isCancelled else { return } // : phase
await MainActor.run {
// :(§3.2 退线)
@@ -148,13 +302,15 @@ struct MedicationScanFlow: View {
private func recognize(_ image: UIImage) async -> (items: [EditableMedication], warning: String?) {
do {
let text = try await OCRService.recognizeText(in: image)
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed.isEmpty {
//
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: trimmed)
let items = parsed.map {
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)
@@ -172,34 +328,56 @@ struct MedicationScanFlow: View {
// MARK: -
private func saveItems(_ items: [EditableMedication]) {
let entries = items
let meds = items
.filter { $0.include && !$0.name.trimmingCharacters(in: .whitespaces).isEmpty }
.map {
ParsedMedication(name: $0.name, strength: $0.strength, usage: $0.usage).entryText
ParsedMedication(name: $0.name.trimmingCharacters(in: .whitespaces),
strength: $0.strength.trimmingCharacters(in: .whitespaces),
usage: $0.usage.trimmingCharacters(in: .whitespaces))
}
onSave(entries)
// (),
onSave(meds, images)
onClose()
}
}
// MARK: - (MainActor,SwiftData View ctx ,§3.1)
// MARK: - (MainActor,SwiftData View ctx ,§3.1)
/// ,:
/// 1. tag DiaryEntry 线
/// 2. UserProfile.currentMedications() AI / prompt
/// ,( · ):
/// `Medication`(), name+strength ;** currentMedications**
/// · `DiaryEntry.medicationTag`
@MainActor
enum MedicationArchiver {
static func archive(entries: [String], in ctx: ModelContext) {
guard !entries.isEmpty else { return }
let diary = DiaryEntry(content: entries.joined(separator: "\n"),
tags: [DiaryEntry.medicationTag])
ctx.insert(diary)
static func archive(medications: [ParsedMedication], images: [UIImage] = [], in ctx: ModelContext) {
guard !medications.isEmpty else { return }
let profile = UserProfileStore.loadOrCreate(in: ctx)
for entry in entries where !profile.currentMedications.contains(entry) {
profile.currentMedications.append(entry)
// Vault(§5/§6: Application Support/Vault,)
// , JPEG Asset
// cascade
let savedAssets = images
.prefix(MedicationScanFlow.maxImages)
.compactMap { try? FileVault.shared.writeJPEG($0) }
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)
}
profile.updatedAt = .now
try? ctx.save()
}
}
@@ -231,13 +409,8 @@ private struct MedicationConfirmView: View {
ForEach($items) { $item in
Section {
HStack {
TextField(String(appLoc: "药品名,如:缬沙坦胶囊"), text: $item.name)
.foregroundStyle(Tj.Palette.text)
Toggle("", isOn: $item.include)
.labelsHidden()
.tint(Tj.Palette.ink)
}
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)
@@ -246,12 +419,6 @@ private struct MedicationConfirmView: View {
}
Section {
Button {
items.append(.init(name: "", strength: "", usage: ""))
} label: {
Label("再加一种", systemImage: "plus.circle")
.foregroundStyle(Tj.Palette.ink)
}
Button {
onRetake()
} label: {
@@ -259,7 +426,7 @@ private struct MedicationConfirmView: View {
.foregroundStyle(Tj.Palette.ink)
}
} footer: {
Text("将记入健康日记(记录页可查),并同步到「当前用药」供 AI 解读参考。不提供任何用药建议。")
Text("一次记一种药,多张照片都会作为这种药的原图存入药品库,供查看与 AI 解读参考。不提供任何用药建议。")
}
}
.scrollContentBackground(.hidden)
@@ -267,7 +434,7 @@ private struct MedicationConfirmView: View {
Button {
onSave(items)
} label: {
Text("保存用药记录")
Text("存入药品库")
.frame(maxWidth: .infinity)
}
.buttonStyle(TjPrimaryButton())
@@ -281,5 +448,5 @@ private struct MedicationConfirmView: View {
}
#Preview {
MedicationScanFlow(onSave: { print($0) }, onClose: {})
MedicationScanFlow(onSave: { _, _ in }, onClose: {})
}

View File

@@ -38,7 +38,6 @@ private struct ProfileEditForm: View {
@State private var healthImportDraft: HealthProfileImportDraft?
@State private var healthImportError: String?
@State private var isImportingHealthProfile = false
@State private var showMedicationScan = false
var body: some View {
Form {
@@ -88,9 +87,6 @@ private struct ProfileEditForm: View {
items: $profile.allergies)
StringListSection(title: String(appLoc: "家族史"), placeholder: String(appLoc: "如:母亲 高血压"),
items: $profile.familyHistory)
StringListSection(title: String(appLoc: "当前用药"), placeholder: String(appLoc: "如:缬沙坦 80mg qd"),
items: $profile.currentMedications,
onScan: { showMedicationScan = true })
}
.navigationTitle("个人资料")
.navigationBarTitleDisplayMode(.inline)
@@ -100,16 +96,6 @@ private struct ProfileEditForm: View {
profile.updatedAt = .now
try? ctx.save()
}
.fullScreenCover(isPresented: $showMedicationScan) {
// OCR + LLM :
// (线)+ ()
MedicationScanFlow(
onSave: { entries in
MedicationArchiver.archive(entries: entries, in: ctx)
},
onClose: { showMedicationScan = false }
)
}
.sheet(item: $healthImportDraft) { draft in
HealthProfileImportPreviewSheet(
draft: draft,
@@ -468,27 +454,10 @@ private struct StringListSection: View {
let title: String
let placeholder: String
@Binding var items: [String]
/// nil ()
var onScan: (() -> Void)? = nil
@State private var newInput = ""
var body: some View {
Section(title) {
if let onScan {
Button(action: onScan) {
HStack(spacing: 10) {
Image(systemName: "camera.viewfinder")
.foregroundStyle(Tj.Palette.ink)
VStack(alignment: .leading, spacing: 2) {
Text("拍药盒自动识别")
.foregroundStyle(Tj.Palette.text)
Text("拍药盒或说明书,本地识别药名与规格")
.font(.tjScaled( 12))
.foregroundStyle(Tj.Palette.text3)
}
}
}
}
ForEach(items, id: \.self) { item in
HStack {
Text(item)

View File

@@ -1,13 +1,15 @@
import SwiftUI
enum RecordKind: String, Identifiable, CaseIterable {
case quick, indicator, healthExport, archive, diary, symptom, reminder
case quick, indicator, healthExport, archive, diary, symptom, reminder, medicationLibrary
var id: String { rawValue }
/// RecordSheet () enum ,
/// :`.quick`() `.indicator`();
/// `.symptom`() `.diary`(),
static let displayOrder: [RecordKind] = [.diary, .reminder, .indicator, .healthExport, .archive]
/// `.symptom`() `.diary`(),;
/// `.medicationLibrary`()/,Tab ,
/// (,)
static let displayOrder: [RecordKind] = [.diary, .reminder, .indicator, .healthExport, .archive, .medicationLibrary]
/// pill( subtitle,"/")
/// :,( ProfileEditView presets )
@@ -24,6 +26,7 @@ enum RecordKind: String, Identifiable, CaseIterable {
case .diary: return String(appLoc: "健康日记")
case .symptom: return String(appLoc: "记录症状")
case .reminder: return String(appLoc: "开启一个提醒")
case .medicationLibrary: return String(appLoc: "药品库")
}
}
var subtitle: String {
@@ -35,6 +38,7 @@ enum RecordKind: String, Identifiable, CaseIterable {
case .diary: return String(appLoc: "写日记或拍药盒记录用药 · 可让 AI 辅助")
case .symptom: return String(appLoc: "开始一个持续症状,结束时再点结束")
case .reminder: return String(appLoc: "管理用药、复查、监测的周期提醒")
case .medicationLibrary: return String(appLoc: "管理常用药清单 · 拍药盒或手动添加")
}
}
var icon: String {
@@ -46,6 +50,7 @@ enum RecordKind: String, Identifiable, CaseIterable {
case .diary: return "heart.text.square"
case .symptom: return "waveform.path.ecg"
case .reminder: return "bell.badge"
case .medicationLibrary: return "pills.fill"
}
}
var accent: Color {
@@ -57,6 +62,7 @@ enum RecordKind: String, Identifiable, CaseIterable {
case .diary: return Tj.Palette.leaf
case .symptom: return Tj.Palette.amber
case .reminder: return Tj.Palette.leaf
case .medicationLibrary: return Tj.Palette.ink
}
}
}
@@ -83,7 +89,7 @@ struct RecordSheet: View {
}
.padding(.bottom, 14)
// ScrollView :6 detent ,
// ScrollView : detent ,
ScrollView {
VStack(spacing: 10) {
ForEach(RecordKind.displayOrder) { kind in

View File

@@ -140,6 +140,7 @@ struct IndicatorSeriesDetailView: View {
} else {
pages
pager
recordAnotherRow
if bucket != nil { trendButton }
}
}
@@ -311,6 +312,30 @@ struct IndicatorSeriesDetailView: View {
.disabled(!enabled)
}
// MARK: - ( RecordAnotherButton )
/// :, name/unit/range/seriesKey
@ViewBuilder
private var recordAnotherRow: some View {
if records.indices.contains(currentIndex) {
switch records[currentIndex] {
case .single(let i):
RecordAnotherButton(name: i.name, prefill: .init(indicator: i))
.padding(.horizontal, 20)
.padding(.bottom, bucket == nil ? 20 : 10)
case .bp(let sys, _):
RecordAnotherButton(
name: String(appLoc: "血压"),
prefill: .init(seriesKey: sys.seriesKey ?? "bp.systolic",
name: String(appLoc: "血压"),
unit: "mmHg", range: sys.range)
)
.padding(.horizontal, 20)
.padding(.bottom, bucket == nil ? 20 : 10)
}
}
}
// MARK: - /
private var trendButton: some View {

View File

@@ -42,10 +42,12 @@ struct TimelineEntry: Identifiable, Hashable {
let kind: TimelineKind
let date: Date
let title: String
let subtitle: String
var subtitle: String
let trailing: String?
let trailingIsAlert: Bool
let isOngoing: Bool
/// (>1 N ) 1
var aggregateCount: Int = 1
static func from(indicator i: Indicator) -> TimelineEntry {
TimelineEntry(
@@ -87,6 +89,34 @@ struct TimelineEntry: Identifiable, Hashable {
return entries
}
/// / :(),,
/// (`aggregateCount`,>1 N )
/// `IndicatorSeriesDetailView` /
/// (`IndicatorGroup`):(bp.*) seriesKey key seriesKey name+unit
static func aggregatedIndicators(_ indicators: [Indicator]) -> [TimelineEntry] {
var order: [String] = []
var groups: [String: [Indicator]] = [:]
for i in indicators {
let key = IndicatorGroup.of(i).id
if groups[key] == nil { order.append(key) }
groups[key, default: []].append(i)
}
return order.compactMap { key -> TimelineEntry? in
guard let members = groups[key] else { return nil }
// ( sys/dia),
guard var rep = from(indicators: members).max(by: { $0.date < $1.date }) else { return nil }
// :(bp.systolic ),
let count = key == IndicatorGroup.bloodPressure.id
? members.filter { $0.seriesKey == "bp.systolic" }.count
: members.count
rep.aggregateCount = count
if count > 1 {
rep.subtitle += " · " + String(appLoc: "\(count)")
}
return rep
}
}
private static func mergedBP(systolic sys: Indicator, diastolic dia: Indicator) -> TimelineEntry {
let abnormal = sys.status != .normal || dia.status != .normal
// status : /;

View File

@@ -54,6 +54,27 @@ struct TimelineEntryDetailView: View {
@State private var showDeleteConfirm = false
@State private var evidenceTarget: Indicator?
@State private var reminderPrefill: ReminderPrefill?
///
private struct ReminderPrefill: Identifiable {
let id = UUID()
let title: String
let note: String
}
///
@State private var reportPhotoStart: ReportPhotoPage?
private struct ReportPhotoPage: Identifiable {
let id = UUID()
let index: Int
}
/// ,
private var reportEntry: Report? {
if case .report(let r) = detail { return r }
return nil
}
var body: some View {
VStack(spacing: 0) {
@@ -84,6 +105,15 @@ struct TimelineEntryDetailView: View {
EvidenceImagePreview(report: report, indicator: indicator)
}
}
.sheet(item: $reminderPrefill) { prefill in
// (/// + ;)
CustomReminderEditSheet(prefillTitle: prefill.title, prefillNote: prefill.note)
}
.sheet(item: $reportPhotoStart) { start in
if let r = reportEntry {
ReportImagesViewer(assets: r.assets, startIndex: start.index)
}
}
}
// MARK: - (:SwiftData + Vault unlink, CLAUDE.md §6)
@@ -120,6 +150,10 @@ struct TimelineEntryDetailView: View {
for p in paths { try? FileVault.shared.remove(relativePath: p) }
ctx.delete(r)
case .diary(let d):
// ;cascade Asset ,Vault JPEG unlink
for p in Set(d.assets.map(\.relativePath)) {
try? FileVault.shared.remove(relativePath: p)
}
ctx.delete(d)
case .symptom(let s):
ctx.delete(s)
@@ -167,7 +201,7 @@ struct TimelineEntryDetailView: View {
case .indicator: return String(appLoc: "指标详情")
case .bloodPressure: return String(appLoc: "血压详情")
case .report: return String(appLoc: "报告详情")
case .diary: return String(appLoc: "日记详情")
case .diary(let d): return d.isMedicationLog ? String(appLoc: "用药详情") : String(appLoc: "日记详情")
case .symptom: return String(appLoc: "症状详情")
}
}
@@ -186,28 +220,31 @@ struct TimelineEntryDetailView: View {
// 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)
VStack(alignment: .leading, spacing: 16) {
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) }
}
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) }
RecordAnotherButton(name: i.name, prefill: .init(indicator: i))
}
}
@@ -217,21 +254,28 @@ struct TimelineEntryDetailView: 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)
return VStack(alignment: .leading, spacing: 16) {
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))
}
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))
// :seriesKey bp.systolic MonitorMetric.bloodPressure
RecordAnotherButton(name: String(appLoc: "血压"),
prefill: .init(seriesKey: sys.seriesKey ?? "bp.systolic",
name: String(appLoc: "血压"),
unit: "mmHg", range: sys.range))
}
}
@@ -248,16 +292,16 @@ struct TimelineEntryDetailView: View {
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 !r.assets.isEmpty {
reportPhotosCard(r.assets)
}
ReportSummaryCard(report: r)
if !r.indicators.isEmpty {
@@ -286,26 +330,146 @@ struct TimelineEntryDetailView: View {
}
}
// 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: " "))
/// : ,,
private func reportPhotosCard(_ assets: [Asset]) -> some View {
card {
HStack {
Text(String(appLoc: "原图\(assets.count)"))
.font(.tjScaled( 12, weight: .semibold)).foregroundStyle(Tj.Palette.text2)
Spacer()
Text(String(appLoc: "点图放大")).font(.tjScaled( 11)).foregroundStyle(Tj.Palette.text3)
}
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 10) {
ForEach(Array(assets.enumerated()), id: \.offset) { idx, asset in
Button {
reportPhotoStart = ReportPhotoPage(index: idx)
} label: {
reportThumb(asset)
}
.buttonStyle(.plain)
}
}
}
}
}
private func reportThumb(_ asset: Asset) -> some View {
VaultImage(relativePath: asset.relativePath, maxPixel: 400) { img in
Image(uiImage: img).resizable().scaledToFill()
} placeholder: { isLoading in
if isLoading {
Tj.Palette.paper
} else {
TjPlaceholder(label: String(appLoc: "原图无法读取"))
}
}
.frame(width: 96, height: 120)
.clipped()
.clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: 8, style: .continuous)
.strokeBorder(Tj.Palette.line, lineWidth: 1)
)
}
// MARK: -
@ViewBuilder
private func diaryBody(_ d: DiaryEntry) -> some View {
if d.isMedicationLog {
medicationBody(d)
} else {
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: - 使(// + )
/// 使(tag ): [] · + ,
/// ,/(CLAUDE.md §1§10)
private func medicationBody(_ d: DiaryEntry) -> some View {
let lines = Self.medicationLines(d.content)
return VStack(alignment: .leading, spacing: 16) {
card {
Text(Self.dateTimeText(d.createdAt))
.font(.tjScaled( 12)).foregroundStyle(Tj.Palette.text3)
if lines.isEmpty {
Text(d.content)
.font(.tjScaled( 15)).foregroundStyle(Tj.Palette.text)
.textSelection(.enabled)
.frame(maxWidth: .infinity, alignment: .leading)
.fixedSize(horizontal: false, vertical: true)
} else {
ForEach(Array(lines.enumerated()), id: \.offset) { idx, line in
if idx > 0 { divider }
Text(line)
.font(.tjScaled( 15)).foregroundStyle(Tj.Palette.text)
.textSelection(.enabled)
.frame(maxWidth: .infinity, alignment: .leading)
.fixedSize(horizontal: false, vertical: true)
}
}
}
medicationActionRow(d)
Text("「设置提醒」只到点提示,不提供任何用药或剂量建议。")
.font(.tjScaled( 11)).foregroundStyle(Tj.Palette.text3)
.frame(maxWidth: .infinity, alignment: .leading)
.fixedSize(horizontal: false, vertical: true)
}
}
/// :(, + ),
private func medicationActionRow(_ d: DiaryEntry) -> some View {
HStack(spacing: 10) {
medAction(title: String(appLoc: "设置提醒"), icon: "bell.badge") {
let lines = Self.medicationLines(d.content)
if lines.count <= 1 {
let f = Self.medicationReminderFields(forLine: lines.first ?? d.content)
reminderPrefill = ReminderPrefill(title: f.title, note: f.note)
} else {
// :,,/
reminderPrefill = ReminderPrefill(title: String(appLoc: "服药提醒"),
note: lines.joined(separator: "\n"))
}
}
}
}
private func medAction(title: String, icon: String, action: @escaping () -> Void) -> some View {
Button(action: action) {
VStack(spacing: 6) {
Image(systemName: icon).font(.tjScaled( 18, weight: .medium))
Text(title).font(.tjScaled( 12, weight: .semibold))
}
.foregroundStyle(Tj.Palette.ink)
.frame(maxWidth: .infinity)
.padding(.vertical, 12)
.background(
RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)
.fill(Tj.Palette.amber.opacity(0.14))
)
.contentShape(RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous))
}
.buttonStyle(.plain)
}
// MARK: -
private func symptomBody(_ s: Symptom) -> some View {
@@ -412,6 +576,76 @@ struct TimelineEntryDetailView: View {
private nonisolated static func dateText(_ d: Date) -> String {
d.formatted(.dateTime.year().month().day())
}
// MARK: - (,便)
/// content ,
nonisolated static func medicationLines(_ content: String) -> [String] {
content.split(whereSeparator: \.isNewline)
.map { $0.trimmingCharacters(in: .whitespaces) }
.filter { !$0.isEmpty }
}
/// ( 80mg · ):
/// =:<+>, = (" · " ,/)
nonisolated static func medicationReminderFields(forLine line: String) -> (title: String, note: String) {
let parts = line.components(separatedBy: " · ")
let head = (parts.first ?? line).trimmingCharacters(in: .whitespaces)
let usage = parts.count > 1
? parts.dropFirst().joined(separator: " · ").trimmingCharacters(in: .whitespaces)
: ""
let name = head.isEmpty ? line.trimmingCharacters(in: .whitespaces) : head
return (title: String(appLoc: "吃药:") + name, note: usage)
}
}
/// (,)
private struct ReportImagesViewer: 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 {
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))
}
Text("原图 · 第 \(selection + 1)/\(assets.count)")
.font(.tjScaled( 14, weight: .semibold))
.foregroundStyle(Tj.Palette.text)
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(assets.enumerated()), id: \.offset) { index, asset in
EvidenceImagePage(asset: asset, highlight: nil)
.tag(index)
.padding(16)
}
}
.tabViewStyle(.page(indexDisplayMode: assets.count > 1 ? .automatic : .never))
}
.background(Tj.Palette.sand.ignoresSafeArea())
.presentationDetents([.large])
.presentationDragIndicator(.visible)
.presentationBackground(Tj.Palette.sand)
}
}
/// ( + ),
@@ -479,19 +713,16 @@ 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 {
VaultImage(relativePath: asset.relativePath, maxPixel: 2000) { image in
ZStack {
Image(uiImage: image)
.resizable()
.scaledToFit()
.frame(width: geo.size.width, height: geo.size.height)
if let highlight {
// ,imageSize letterbox ,
EvidenceHighlightOverlay(imageSize: image.size, normalizedRect: highlight)
}
}
@@ -502,9 +733,14 @@ private struct EvidenceImagePage: View {
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)
} placeholder: { isLoading in
if isLoading {
ProgressView()
.frame(width: geo.size.width, height: geo.size.height)
} else {
TjPlaceholder(label: String(appLoc: "原图无法读取"))
.frame(width: geo.size.width, height: geo.size.height)
}
}
}
}

View File

@@ -12,6 +12,10 @@ struct TrendsView: View {
private var profile: UserProfile? { profiles.first }
/// :,(bucket.title)
@State private var searching = false
@State private var query = ""
private var seriesBuckets: [SeriesBucket] {
SeriesBucket.build(from: indicators,
profile: profile,
@@ -25,6 +29,14 @@ struct TrendsView: View {
seriesBuckets.filter { $0.kind == .lab }
}
private func filtered(_ buckets: [SeriesBucket]) -> [SeriesBucket] {
let q = query.trimmingCharacters(in: .whitespaces)
guard !q.isEmpty else { return buckets }
return buckets.filter { $0.title.localizedCaseInsensitiveContains(q) }
}
private var filteredMonitor: [SeriesBucket] { filtered(monitorBuckets) }
private var filteredLab: [SeriesBucket] { filtered(labBuckets) }
var body: some View {
NavigationStack {
ScrollView(showsIndicators: false) {
@@ -32,12 +44,14 @@ struct TrendsView: View {
header.padding(.top, 4)
if seriesBuckets.isEmpty {
emptyState
} else if filteredMonitor.isEmpty && filteredLab.isEmpty {
noMatchState
} else {
if !monitorBuckets.isEmpty {
section(title: String(appLoc: "长期监测"), buckets: monitorBuckets)
if !filteredMonitor.isEmpty {
section(title: String(appLoc: "长期监测"), buckets: filteredMonitor)
}
if !labBuckets.isEmpty {
section(title: String(appLoc: "化验指标趋势"), buckets: labBuckets)
if !filteredLab.isEmpty {
section(title: String(appLoc: "化验指标趋势"), buckets: filteredLab)
}
}
}
@@ -51,9 +65,73 @@ struct TrendsView: View {
}
private var header: some View {
Text("趋势")
.font(.tjTitle(26))
.foregroundStyle(Tj.Palette.text)
VStack(alignment: .leading, spacing: 12) {
HStack(alignment: .lastTextBaseline) {
Text("趋势")
.font(.tjTitle(26))
.foregroundStyle(Tj.Palette.text)
Spacer()
searchToggle
}
if searching { searchField }
}
}
private var searchToggle: some View {
Button {
withAnimation(.easeInOut(duration: 0.18)) {
searching.toggle()
if !searching { query = "" }
}
} label: {
Image(systemName: searching ? "xmark" : "magnifyingglass")
.font(.tjScaled( 15, weight: .semibold))
.foregroundStyle(Tj.Palette.text)
.frame(width: 36, height: 36)
.background(Circle().fill(Tj.Palette.sand2))
}
.buttonStyle(.plain)
.accessibilityLabel(searching ? String(appLoc: "关闭搜索") : String(appLoc: "搜索指标"))
}
private var searchField: some View {
HStack(spacing: 8) {
Image(systemName: "magnifyingglass")
.font(.tjScaled( 13))
.foregroundStyle(Tj.Palette.text3)
TextField(String(appLoc: "搜索指标名"), text: $query)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.foregroundStyle(Tj.Palette.text)
.tint(Tj.Palette.ink)
if !query.isEmpty {
Button { query = "" } label: {
Image(systemName: "xmark.circle.fill")
.foregroundStyle(Tj.Palette.text3)
}
.buttonStyle(.plain)
}
}
.padding(.horizontal, 12)
.padding(.vertical, 10)
.background(
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
.fill(Tj.Palette.paper)
)
.overlay(
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
.strokeBorder(Tj.Palette.line, lineWidth: 1)
)
}
private var noMatchState: some View {
VStack(spacing: 12) {
TjPlaceholder(label: String(appLoc: "没有匹配「\(query)」的指标"))
.frame(height: 120)
.frame(maxWidth: 260)
}
.frame(maxWidth: .infinity)
.padding(.top, 60)
}
private func section(title: String, buckets: [SeriesBucket]) -> some View {

View File

@@ -189,6 +189,9 @@
}
}
}
},
"「设置提醒」只到点提示,不提供任何用药或剂量建议。" : {
},
"/" : {
@@ -909,6 +912,9 @@
}
}
}
},
"📷 %lld" : {
},
"1 项偏低" : {
"extractionState" : "stale",
@@ -1497,6 +1503,7 @@
}
},
"VL 模型未就绪" : {
"extractionState" : "stale",
"localizations" : {
"en" : {
"stringUnit" : {
@@ -1519,6 +1526,7 @@
}
},
"VL 模型未就绪,先手动录入" : {
"extractionState" : "stale",
"localizations" : {
"en" : {
"stringUnit" : {
@@ -1541,6 +1549,7 @@
}
},
"VL 输出无法解析:%@" : {
"extractionState" : "stale",
"localizations" : {
"en" : {
"stringUnit" : {
@@ -1628,6 +1637,9 @@
}
}
}
},
"一次记一种药,多张照片都会作为这种药的原图存入药品库,供查看与 AI 解读参考。不提供任何用药建议。" : {
},
"三" : {
"localizations" : {
@@ -2031,6 +2043,9 @@
}
}
}
},
"仅作清单记录,不提供任何用药或剂量建议。" : {
},
"仅供参考,不构成医疗建议" : {
"extractionState" : "stale",
@@ -2214,6 +2229,9 @@
}
}
}
},
"从药品库删除" : {
},
"任何健康决策(是否就医、用药、调整治疗方案等)请咨询专业医疗人员,并以其意见为准。" : {
"localizations" : {
@@ -2841,9 +2859,6 @@
}
}
}
},
"保存用药记录" : {
},
"偏低" : {
"localizations" : {
@@ -3195,6 +3210,9 @@
}
}
}
},
"共 %lld 次" : {
},
"共 %lld 页" : {
"localizations" : {
@@ -3306,6 +3324,9 @@
}
}
}
},
"关闭搜索" : {
},
"其他" : {
"localizations" : {
@@ -3350,9 +3371,6 @@
}
}
}
},
"再加一种" : {
},
"再拍一项" : {
"extractionState" : "stale",
@@ -3376,6 +3394,9 @@
}
}
}
},
"再记一条「%@」" : {
},
"再说一次" : {
@@ -3680,6 +3701,12 @@
}
}
}
},
"剂量" : {
},
"剂量,如:1 片 / 80mg" : {
},
"前往设置" : {
@@ -3913,6 +3940,16 @@
}
}
},
"原图 · 第 %lld/%lld 页" : {
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "new",
"value" : "原图 · 第 %1$lld/%2$lld 页"
}
}
}
},
"原图%lld张" : {
},
@@ -3937,6 +3974,9 @@
}
}
}
},
"原图已加密保存,详情页随时可翻看放大。系统只识别报告日期与机构作为标签,不逐项录入数值。" : {
},
"原图无法读取" : {
@@ -4125,6 +4165,9 @@
},
"只读取生日、性别、身高、血型" : {
},
"可多选:如同时勾选「每周一三五」+「每月1日」,两种节奏都会提醒。" : {
},
"可选开启 Face ID 启动锁,进一步保护隐私。" : {
"localizations" : {
@@ -4169,6 +4212,15 @@
}
}
}
},
"右上角拍药盒或 + 手动添加" : {
},
"吃了哪个药" : {
},
"吃药:" : {
},
"各引擎实测对比" : {
@@ -4378,6 +4430,7 @@
}
},
"图片保存失败,手动录入并保留文本" : {
"extractionState" : "stale",
"localizations" : {
"en" : {
"stringUnit" : {
@@ -4398,6 +4451,9 @@
}
}
}
},
"图片保存失败,请重试" : {
},
"在「+ 新建 → 指标记录 → %@」记录一次" : {
"localizations" : {
@@ -4879,6 +4935,7 @@
}
},
"如:缬沙坦 80mg qd" : {
"extractionState" : "stale",
"localizations" : {
"en" : {
"stringUnit" : {
@@ -4955,6 +5012,9 @@
},
"字号放大 60%" : {
},
"存入药品库" : {
},
"完成" : {
"localizations" : {
@@ -5142,9 +5202,6 @@
},
"导出历史" : {
},
"将记入健康日记(记录页可查),并同步到「当前用药」供 AI 解读参考。不提供任何用药建议。" : {
},
"将追加:" : {
"localizations" : {
@@ -5441,6 +5498,16 @@
}
}
},
"已拍 %lld/%lld 张 · 可拍正面、背面、说明书" : {
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "new",
"value" : "已拍 %1$lld/%2$lld 张 · 可拍正面、背面、说明书"
}
}
}
},
"已拍 1 页" : {
"extractionState" : "stale",
"localizations" : {
@@ -5979,6 +6046,9 @@
}
}
}
},
"开始识别" : {
},
"开始说话…" : {
@@ -6078,6 +6148,7 @@
},
"当前用药" : {
"extractionState" : "stale",
"localizations" : {
"en" : {
"stringUnit" : {
@@ -6513,6 +6584,9 @@
},
"或手动填写" : {
},
"或手动输入药名" : {
},
"或者自己写" : {
"localizations" : {
@@ -6606,6 +6680,9 @@
},
"手动填写,或拍照自动识别" : {
},
"手动添加" : {
},
"手动记录" : {
@@ -6877,10 +6954,10 @@
"拍药盒" : {
},
"拍药盒或说明书,本地识别药名与规格" : {
"拍药盒或手动添加常用药" : {
},
"拍药盒自动识别" : {
"拍药盒添加" : {
},
"拖动方框对准要识别的指标,可拖右下角缩放" : {
@@ -7419,6 +7496,18 @@
}
}
}
},
"搜索指标" : {
},
"搜索指标 / 报告 / 症状名" : {
},
"搜索指标名" : {
},
"搜索记录" : {
},
"摘要" : {
@@ -8124,6 +8213,9 @@
},
"月份" : {
},
"服药提醒" : {
},
"未下载" : {
"localizations" : {
@@ -8212,6 +8304,12 @@
}
}
}
},
"未能自动识别报告信息,可手动填写" : {
},
"未能自动识别报告信息,已保存原图,可手动填写日期 / 机构" : {
},
"未设置" : {
"localizations" : {
@@ -8940,6 +9038,9 @@
}
}
}
},
"核对报告信息" : {
},
"核对指标" : {
@@ -8948,6 +9049,7 @@
},
"核对识别结果" : {
"extractionState" : "stale",
"localizations" : {
"en" : {
"stringUnit" : {
@@ -9282,6 +9384,9 @@
}
}
}
},
"每周 · 选星期几" : {
},
"每天" : {
"localizations" : {
@@ -9307,6 +9412,9 @@
},
"每年" : {
},
"每年 · 选月/日" : {
},
"每年%lld月%lld日" : {
"localizations" : {
@@ -9324,7 +9432,7 @@
"每月" : {
},
"每月%lld日" : {
"每月 · 选日期(可多选)" : {
},
"比如:记一下血压 / 我头疼 / 拍个药盒" : {
@@ -9404,6 +9512,15 @@
},
"没听清,再试一次" : {
},
"没有匹配「%@」的指标" : {
},
"没有匹配「%@」的记录" : {
},
"没有匹配的长期监测指标" : {
},
"没有指标 — 点上方「加一项」补一行,或直接保存只存图片" : {
"localizations" : {
@@ -9513,6 +9630,15 @@
},
"添加快捷问答" : {
},
"添加药品" : {
},
"点图放大" : {
},
"点图片可放大查看。原图均存在本机加密目录,不上传。" : {
},
"点底部 + 号可以补一条" : {
"localizations" : {
@@ -9535,6 +9661,9 @@
}
}
}
},
"点照片选「识别此张」· 一次记一种药" : {
},
"点这里再开一次" : {
"localizations" : {
@@ -9873,6 +10002,9 @@
},
"用药记录" : {
},
"用药详情" : {
},
"甲状腺疾病" : {
"localizations" : {
@@ -10175,6 +10307,9 @@
}
}
}
},
"管理常用药清单 · 拍药盒或手动添加" : {
},
"管理用药、复查、监测的周期提醒" : {
"localizations" : {
@@ -10507,6 +10642,9 @@
}
}
}
},
"继续拍" : {
},
"继续拍下一项" : {
"extractionState" : "stale",
@@ -10646,6 +10784,9 @@
}
}
}
},
"编辑药品" : {
},
"腹痛" : {
"localizations" : {
@@ -10854,9 +10995,27 @@
}
}
}
},
"药名,如:缬沙坦胶囊" : {
},
"药品名,如:缬沙坦胶囊" : {
},
"药品库" : {
},
"药品库 · %lld 种常用药" : {
},
"药品库是你的常用药清单。记录某次服用请到「写日记 · 用药」,可填剂量和时间。" : {
},
"药品库还是空的" : {
},
"药品库还没有药,可在「记录 · 药品库」拍药盒或手动添加。这里直接手输也行。" : {
},
"血压" : {
"localizations" : {
@@ -10976,6 +11135,9 @@
}
}
}
},
"记剂量与时间" : {
},
"记录" : {
"localizations" : {
@@ -11093,6 +11255,9 @@
},
"记录时间" : {
},
"记录用药" : {
},
"记录症状" : {
"localizations" : {
@@ -11207,6 +11372,12 @@
}
}
}
},
"设置提醒" : {
},
"识别入药品库" : {
},
"识别全程在本地,图片不会上传" : {
"localizations" : {
@@ -11260,8 +11431,12 @@
},
"识别框内指标" : {
},
"识别此张" : {
},
"识别没有读出指标,请手动补充" : {
"extractionState" : "stale",
"localizations" : {
"en" : {
"stringUnit" : {
@@ -11306,13 +11481,17 @@
}
}
},
"识别用药" : {
"识别超时,已保存原图,请手动填写信息" : {
},
"识别超时,已保留原图" : {
},
"识别超时,挪一下框再试或手动补充" : {
},
"识别超时(>%llds)" : {
"extractionState" : "stale",
"localizations" : {
"en" : {
"stringUnit" : {
@@ -11335,6 +11514,7 @@
}
},
"识别超时(>%llds),保留旧编辑" : {
"extractionState" : "stale",
"localizations" : {
"en" : {
"stringUnit" : {
@@ -11357,6 +11537,7 @@
}
},
"识别超时(>%llds),先手动录入" : {
"extractionState" : "stale",
"localizations" : {
"en" : {
"stringUnit" : {
@@ -12387,6 +12568,7 @@
}
},
"重新识别没有读出新指标" : {
"extractionState" : "stale",
"localizations" : {
"en" : {
"stringUnit" : {

View File

@@ -171,6 +171,12 @@ final class DiaryEntry {
var createdAt: Date
var tags: [String]
/// ( 5 ://)
/// ( swiftdata-rebuild-data-loss)
/// cascade: Asset ;Vault JPEG unlink
@Relationship(deleteRule: .cascade)
var assets: [Asset] = []
init(content: String, createdAt: Date = .now, tags: [String] = []) {
self.content = content
self.createdAt = createdAt
@@ -204,6 +210,45 @@ final class Asset {
}
}
/// : master ()
/// 使( `DiaryEntry.medicationTag` , + ):
/// / / / ,
/// @Model SwiftData ( KangkangApp )
@Model
final class Medication {
var name: String // (,), ParsedMedication.name
var strength: String // , "80mg×7"; ""
var usage: String // , ","; ""
var note: String? // ()
var createdAt: Date
var updatedAt: Date
/// ( / / , 5 )
/// cascade: Asset ;Vault JPEG unlink( DiaryEntry.assets )
@Relationship(deleteRule: .cascade)
var assets: [Asset] = []
init(name: String,
strength: String = "",
usage: String = "",
note: String? = nil,
createdAt: Date = .now) {
self.name = name
self.strength = strength
self.usage = usage
self.note = note
self.createdAt = createdAt
self.updatedAt = createdAt
}
/// / :"80mg×7 · "()
var detailLine: String {
[strength, usage]
.filter { !$0.trimmingCharacters(in: .whitespaces).isEmpty }
.joined(separator: " · ")
}
}
@Model
final class Symptom {
var name: String
@@ -353,9 +398,13 @@ final class CustomReminder {
var hour: Int // 0...23
var minute: Int // 0...59
var weekdays: [Int] // iOS Calendar :1=, 2=, ..., 7= 7 =
var frequencyRaw: String = "daily" // CustomReminder.Frequency
var dayOfMonth: Int = 1 // monthly / yearly ,1...31
var frequencyRaw: String = "daily" // :; frequenciesRaw
var dayOfMonth: Int = 1 // yearly + monthly ,1...31
var month: Int = 1 // yearly ,1...12
/// (["daily","weekly",...]) = ,退 frequency
var frequenciesRaw: [String] = []
/// (1...31) = ,退 dayOfMonth
var monthDays: [Int] = []
var enabled: Bool
var createdAt: Date
var updatedAt: Date
@@ -392,10 +441,41 @@ final class CustomReminder {
set { frequencyRaw = newValue.rawValue }
}
/// : / / 15 / 315
/// ()frequenciesRaw 退 frequency( / init)
var frequencies: Set<Frequency> {
get {
let parsed = Set(frequenciesRaw.compactMap { Frequency(rawValue: $0) })
return parsed.isEmpty ? [frequency] : parsed
}
set {
frequenciesRaw = newValue.map(\.rawValue).sorted()
// , frequency
if let rep = newValue.map(\.rawValue).sorted().first { frequencyRaw = rep }
}
}
/// (,1...31)monthDays 退 dayOfMonth()
/// : dayOfMonth yearly ,+
var monthlyDays: [Int] {
get { monthDays.isEmpty ? [dayOfMonth] : monthDays.sorted() }
set { monthDays = Set(newValue.map { max(1, min(31, $0)) }).sorted() }
}
/// : · , · 1·15
/// ()
var frequencyLabel: String {
if !enabled { return String(appLoc: "已关闭") }
switch frequency {
let active = frequencies
if active.contains(.daily) { return String(appLoc: "每天") }
// weekly 7
if active == [.weekly] && isEveryDay { return String(appLoc: "每天") }
let order: [Frequency] = [.weekly, .monthly, .yearly]
let parts = order.filter { active.contains($0) }.map { freqPartLabel($0) }
return parts.isEmpty ? String(appLoc: "未选日") : parts.joined(separator: " · ")
}
private func freqPartLabel(_ f: Frequency) -> String {
switch f {
case .daily:
return String(appLoc: "每天")
case .weekly:
@@ -404,7 +484,9 @@ final class CustomReminder {
let names = [String(appLoc: ""), String(appLoc: ""), String(appLoc: ""), String(appLoc: ""), String(appLoc: ""), String(appLoc: ""), String(appLoc: "")]
return String(appLoc: "每周 ") + weekdays.sorted().map { names[$0 - 1] }.joined()
case .monthly:
return String(appLoc: "每月\(dayOfMonth)")
let days = monthlyDays
if days.isEmpty { return String(appLoc: "未选日") }
return String(appLoc: "每月") + days.map { String($0) }.joined(separator: "·") + String(appLoc: "")
case .yearly:
return String(appLoc: "每年\(month)\(dayOfMonth)")
}
@@ -420,12 +502,17 @@ final class CustomReminder {
func occurs(on date: Date, calendar: Calendar = .current) -> Bool {
guard enabled else { return false }
let c = calendar.dateComponents([.weekday, .day, .month], from: date)
switch frequency {
case .daily: return true
case .weekly: return weekdays.contains(c.weekday ?? -1)
case .monthly: return dayOfMonth == (c.day ?? -1)
case .yearly: return month == (c.month ?? -1) && dayOfMonth == (c.day ?? -1)
let wd = c.weekday ?? -1, day = c.day ?? -1, mo = c.month ?? -1
// :
for f in frequencies {
switch f {
case .daily: return true
case .weekly: if weekdays.contains(wd) { return true }
case .monthly: if monthlyDays.contains(day) { return true }
case .yearly: if month == mo && dayOfMonth == day { return true }
}
}
return false
}
}

View File

@@ -1,5 +1,6 @@
import Foundation
import UIKit
import ImageIO
enum FileVaultError: Error {
case readFailed
@@ -10,7 +11,10 @@ enum FileVaultError: Error {
/// `@unchecked Sendable`:rootURL let, I/O (线),
/// actor / Task 访 `nonisolated`, ModelStore
final class FileVault: @unchecked Sendable {
/// `nonisolated`: `SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor`,
/// `thumbnailCache`( Sendable NSCache) MainActor, nonisolated I/O /
/// 访; I/O + , MainActor actor , nonisolated
nonisolated final class FileVault: @unchecked Sendable {
nonisolated static let shared: FileVault = {
do {
let appSupport = try FileManager.default.url(
@@ -28,6 +32,17 @@ final class FileVault: @unchecked Sendable {
let rootURL: URL
/// NSCache 线;
/// key = "@", TabView /
/// ( KB),
/// `nonisolated(unsafe)`: MainActor , Sendable NSCache 便
/// nonisolated MainActor, nonisolated I/O 访;NSCache 线, unsafe
private nonisolated(unsafe) let thumbnailCache: NSCache<NSString, UIImage> = {
let cache = NSCache<NSString, UIImage>()
cache.countLimit = 40
return cache
}()
init(rootURL: URL) throws {
self.rootURL = rootURL
try FileManager.default.createDirectory(
@@ -81,6 +96,33 @@ final class FileVault: @unchecked Sendable {
return image
}
/// ImageIO ,****:
/// 4000×3000 ~48MB RGBA, jetsam; 2000px MB
/// EXIF ,/
/// ( / ) readFailed, loadImage ,UI
nonisolated func loadDownsampledImage(relativePath: String, maxPixelSize: CGFloat) throws -> UIImage {
let cacheKey = "\(relativePath)@\(Int(maxPixelSize))" as NSString
if let cached = thumbnailCache.object(forKey: cacheKey) { return cached }
let url = try resolveSafePath(relativePath)
let srcOptions: [CFString: Any] = [kCGImageSourceShouldCache: false]
guard let src = CGImageSourceCreateWithURL(url as CFURL, srcOptions as CFDictionary) else {
throw FileVaultError.readFailed
}
let thumbOptions: [CFString: Any] = [
kCGImageSourceCreateThumbnailFromImageAlways: true,
kCGImageSourceCreateThumbnailWithTransform: true, // EXIF ,
kCGImageSourceShouldCacheImmediately: true, // 线,线
kCGImageSourceThumbnailMaxPixelSize: maxPixelSize
]
guard let cg = CGImageSourceCreateThumbnailAtIndex(src, 0, thumbOptions as CFDictionary) else {
throw FileVaultError.decodeFailed
}
let image = UIImage(cgImage: cg)
thumbnailCache.setObject(image, forKey: cacheKey)
return image
}
nonisolated func remove(relativePath: String) throws {
let url = try resolveSafePath(relativePath)
do {
@@ -88,6 +130,8 @@ final class FileVault: @unchecked Sendable {
} catch {
throw FileVaultError.removeFailed
}
// ,(,)
thumbnailCache.removeAllObjects()
}
/// Vault (/),;
@@ -99,6 +143,7 @@ final class FileVault: @unchecked Sendable {
try? fm.removeItem(at: url)
}
let remaining = (try? fm.contentsOfDirectory(at: rootURL, includingPropertiesForKeys: nil)) ?? []
thumbnailCache.removeAllObjects()
if !remaining.isEmpty {
throw FileVaultError.removeFailed
}

View File

@@ -53,6 +53,8 @@ struct RootView: View {
@State private var showVoiceCommand = false
/// :RootView MedicationScanFlow, sheet
@State private var showMedicationScan = false
/// · :sheet + NavigationStack
@State private var showMedicationLibrary = false
/// ( RecordSheet onPick )
private func route(_ intent: VoiceIntent) {
@@ -112,6 +114,7 @@ struct RootView: View {
case .indicator: showIndicator = true
case .reminder: showReminders = true
case .healthExport: showHealthExport = true
case .medicationLibrary: showMedicationLibrary = true
}
}
}
@@ -135,6 +138,9 @@ struct RootView: View {
// NavigationStack ;sheet
NavigationStack { RemindersListView(presentedAsSheet: true) }
}
.sheet(isPresented: $showMedicationLibrary) {
NavigationStack { MedicationLibraryView(presentedAsSheet: true) }
}
.fullScreenCover(isPresented: $showHealthExport) {
HealthExportSheet()
}
@@ -156,8 +162,8 @@ struct RootView: View {
}
.fullScreenCover(isPresented: $showMedicationScan) {
MedicationScanFlow(
onSave: { entries in
MedicationArchiver.archive(entries: entries, in: ctx)
onSave: { meds, images in
MedicationArchiver.archive(medications: meds, images: images, in: ctx)
},
onClose: { showMedicationScan = false }
)

View File

@@ -33,7 +33,9 @@ struct ParsedReport: Sendable {
var isEmpty: Bool { indicators.isEmpty }
/// ,退 UI
static func empty(date: Date = .now) -> ParsedReport {
/// nonisolated: MainActor , CaptureService(actor) extractReportMeta
/// actor , nonisolated (Swift 6)
nonisolated static func empty(date: Date = .now) -> ParsedReport {
ParsedReport(
title: "",
typeRaw: ReportType.other.rawValue,
@@ -78,6 +80,40 @@ actor CaptureService {
try await runVL(on: assets)
}
/// meta :**,**
/// 2B OOM(jetsam = ),
/// :Vision OCR(,<1s/) LLM {title,type,date,institution}(~50 token)
/// :OCR / / ( meta, recognized:false),(§3.2)
/// indicators
func extractReportMeta(assets: [FileVault.SavedAsset]) async -> (meta: ParsedReport, recognized: Bool) {
let urls = assets.map { FileVault.shared.rootURL.appendingPathComponent($0.relativePath) }
let ocr = await Self.ocrReference(for: urls)
guard !ocr.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
return (.empty(), false)
}
do {
try await AIRuntime.shared.prepare() // LLM();OOM VL
} catch {
return (.empty(), false)
}
var collected = ""
do {
// meta ,256 token , 2048
let stream = await AIRuntime.shared.generate(prompt: VLPrompts.reportMetaFromText(ocr),
maxTokens: 256)
for try await chunk in stream { collected += chunk.text }
} catch {
return (.empty(), false)
}
let cleaned = CaptureService.stripThink(collected)
guard var parsed = try? CaptureService.parseReportJSON(cleaned, pageCount: assets.count) else {
return (.empty(), false)
}
// meta + ,
parsed.indicators = []
return (parsed, true)
}
/// OCR : Vision OCR LLM(Qwen3-1.7B)
/// Report; `CaptureError`,UI 退(§3.2)
/// (MainActor) OCR,OCR actor, UIImage actor
@@ -169,8 +205,17 @@ actor CaptureService {
private static func ocrReference(for urls: [URL]) async -> String {
var pages: [String] = []
for (idx, url) in urls.prefix(4).enumerated() {
guard let src = CGImageSourceCreateWithURL(url as CFURL, nil),
let cg = CGImageSourceCreateImageAtIndex(src, 0, nil) else { continue }
guard let src = CGImageSourceCreateWithURL(url as CFURL, nil) else { continue }
// OCR : 4000px 48MB, VL ,
// jetsam 3000px Vision,;
// VL ,OCR ,
let thumbOptions: [CFString: Any] = [
kCGImageSourceCreateThumbnailFromImageAlways: true,
kCGImageSourceCreateThumbnailWithTransform: true,
kCGImageSourceShouldCacheImmediately: true,
kCGImageSourceThumbnailMaxPixelSize: 3000
]
guard let cg = CGImageSourceCreateThumbnailAtIndex(src, 0, thumbOptions as CFDictionary) else { continue }
guard let text = try? await OCRService.recognizeText(in: cg),
!text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { continue }
pages.append(urls.count > 1 ? "【第 \(idx + 1) 页】\n\(text)" : text)

View File

@@ -450,6 +450,8 @@ struct HealthExportService {
var reports: [Report]
var diaries: [DiaryEntry]
var profile: UserProfile
/// () AI current_meds
var medications: [Medication] = []
/// (, LLM) ##
var trends: [ExportTrend] = []
}
@@ -530,6 +532,9 @@ struct HealthExportService {
// Profile()
let profile = UserProfileStore.loadOrCreate(in: ctx)
// (, AI current_meds)
let medications = (try? ctx.fetch(FetchDescriptor<Medication>())) ?? []
// (, LLM)
// in-window ; indicators series
let trends = ExportTrendBuilder.build(
@@ -546,6 +551,7 @@ struct HealthExportService {
reports: reports,
diaries: diaries,
profile: profile,
medications: medications,
trends: trends
)
}
@@ -561,6 +567,7 @@ struct HealthExportService {
let indicators = (try? ctx.fetch(indicatorDesc)) ?? []
let diaries = (try? ctx.fetch(diaryDesc)) ?? []
let profile = UserProfileStore.loadOrCreate(in: ctx)
let medications = (try? ctx.fetch(FetchDescriptor<Medication>())) ?? []
let dates = indicators.map(\.capturedAt) + diaries.map(\.createdAt)
let fromDate = dates.min() ?? Date()
@@ -581,6 +588,7 @@ struct HealthExportService {
reports: [],
diaries: diaries,
profile: profile,
medications: medications,
trends: trends
)
}
@@ -611,7 +619,11 @@ struct HealthExportService {
if !profile.allergies.isEmpty { profDict["allergies"] = profile.allergies }
if !profile.chronicConditions.isEmpty { profDict["chronic"] = profile.chronicConditions }
if !profile.familyHistory.isEmpty { profDict["family_history"] = profile.familyHistory }
if !profile.currentMedications.isEmpty { profDict["current_meds"] = profile.currentMedications }
// current_meds (Medication); profile.currentMedications
let medNames = snapshot.medications.map { m in
m.detailLine.isEmpty ? m.name : "\(m.name) \(m.detailLine)"
}
if !medNames.isEmpty { profDict["current_meds"] = medNames }
root["profile"] = profDict
// symptoms
@@ -681,7 +693,8 @@ struct HealthExportService {
/// :///, profile
/// LLM,,
static func isEffectivelyEmpty(_ s: Snapshot) -> Bool {
guard s.symptoms.isEmpty, s.indicators.isEmpty, s.reports.isEmpty, s.diaries.isEmpty else {
guard s.symptoms.isEmpty, s.indicators.isEmpty, s.reports.isEmpty,
s.diaries.isEmpty, s.medications.isEmpty else {
return false
}
let p = s.profile
@@ -693,7 +706,6 @@ struct HealthExportService {
&& p.allergies.isEmpty
&& p.chronicConditions.isEmpty
&& p.familyHistory.isEmpty
&& p.currentMedications.isEmpty
}
/// :6 ,,

View File

@@ -80,20 +80,24 @@ enum ReminderService {
let title = reminder.title.trimmingCharacters(in: .whitespacesAndNewlines)
let body = reminder.note.trimmingCharacters(in: .whitespacesAndNewlines)
let h = reminder.hour, m = reminder.minute
let slots: [Slot]
switch reminder.frequency {
case .daily:
slots = [Slot(suffix: "daily", dc: DateComponents(hour: h, minute: m))]
case .weekly:
slots = reminder.weekdays.map { wd in
Slot(suffix: "w\(wd)", dc: DateComponents(hour: h, minute: m, weekday: wd))
// :,(suffix ,)
var slots: [Slot] = []
for f in reminder.frequencies {
switch f {
case .daily:
slots.append(Slot(suffix: "daily", dc: DateComponents(hour: h, minute: m)))
case .weekly:
slots += reminder.weekdays.map { wd in
Slot(suffix: "w\(wd)", dc: DateComponents(hour: h, minute: m, weekday: wd))
}
case .monthly:
slots += reminder.monthlyDays.map { d in
Slot(suffix: "m\(d)", dc: DateComponents(day: d, hour: h, minute: m))
}
case .yearly:
slots.append(Slot(suffix: "yearly",
dc: DateComponents(month: reminder.month, day: reminder.dayOfMonth, hour: h, minute: m)))
}
case .monthly:
slots = [Slot(suffix: "monthly",
dc: DateComponents(day: reminder.dayOfMonth, hour: h, minute: m))]
case .yearly:
slots = [Slot(suffix: "yearly",
dc: DateComponents(month: reminder.month, day: reminder.dayOfMonth, hour: h, minute: m))]
}
await schedule(
idBase: "\(customIdPrefix)\(reminder.id.uuidString)",
@@ -146,11 +150,13 @@ enum ReminderService {
}
}
/// idBase pending (daily/monthly/yearly + 7 weekday,)
/// idBase pending ,:
/// daily / yearly / monthly + 7 weekday(w1...w7)+ 31 (m1...m31)
private static func cancelBase(_ idBase: String) {
let center = UNUserNotificationCenter.current()
var ids = ["\(idBase).daily", "\(idBase).monthly", "\(idBase).yearly"]
ids += (1...7).map { "\(idBase).w\($0)" }
ids += (1...31).map { "\(idBase).m\($0)" }
center.removePendingNotificationRequests(withIdentifiers: ids)
}
}

View File

@@ -25,6 +25,16 @@ final class SpeechDictationService {
}
}
/// `prefix` `partial` ,便
/// :;;(/),
/// ,
static func merge(prefix: String, partial: String) -> String {
if partial.isEmpty { return prefix }
if prefix.isEmpty { return partial }
if prefix.last?.isWhitespace == true { return prefix + partial }
return prefix + " " + partial
}
/// ;(demo 使)
private static func makeRecognizer() -> SFSpeechRecognizer? {
if let r = SFSpeechRecognizer(locale: .current), r.supportsOnDeviceRecognition {