缺少代码差异信息,无法生成具体的commit message。请提供code differences内容以便分析并生成符合Angular规范的提交信息。

当您提供代码差异后,我将按照以下格式生成:

```
<type>(<scope>): <subject>

<body>
```

其中type会根据更改类型选择(feat、fix、docs、style、refactor等),scope表示影响范围,subject简要描述变更内容,body详细说明修改内容。
This commit is contained in:
link2026
2026-06-07 14:17:18 +08:00
parent 074d99715d
commit 77a4ee1c37
66 changed files with 2676 additions and 548 deletions

View File

@@ -114,4 +114,72 @@ enum HealthExportPrompts {
/no_think
"""
}
// MARK: -
/// , prompt
/// + ,/,
static func dialogueAnswer(latestQuestion: String,
transcript: String,
dataJSON: String) -> String {
"""
你是康康的本地健康档案助手。请根据【本地健康记录】回答用户最新问题。
铁律:
- 只能使用【本地健康记录】和【多轮对话】里已有的信息。
- 禁止诊断、禁止用药/剂量建议、禁止急诊判断。
- 数据里没有的信息,直接说「记录里没有」,不要编造。
- 重点围绕指标和健康日记做大白话解释,回答要短,最多 5 条要点。
- 如果用户的目标是给医生看,可以提示稍后点击「生成整理报告」。
【本地健康记录】:
\(dataJSON)
【多轮对话】:
\(transcript.isEmpty ? "" : transcript)
【用户最新问题】:
\(latestQuestion)
直接输出中文回答,不要 Markdown 标题,不要 <think>:
/no_think
"""
}
/// , Markdown
static func dialogueReportGeneration(transcript: String,
dataJSON: String) -> String {
"""
你是健康数据整理员。请把【多轮对话】和【本地健康记录】整理成一份给医生看的摘要报告。
这是抽取 / 搬运任务,不是医疗诊断。
铁律:
- 只能使用【本地健康记录】和【多轮对话】里真实出现的信息。
- 禁止编造数字、日期、症状、药物、检查结果、诊断。
- 禁止给诊断意见、用药建议、剂量建议或急诊判断。
- JSON 里没有的信息,对应小节写「无记录」。
- 指标 status 为 high/low/abnormal 的项目前加 ⚠️。
输出要求:
- 严格 Markdown,不要 markdown 围栏,不要输出 JSON。
- 中文,简洁,医生 30 秒能扫完。
- 严格按以下段落:
# 就诊摘要
## 本次想解决的问题
## 相关健康日记
## 相关指标
## 已知背景
## 患者关心的问题
## 可带给医生确认的要点
【本地健康记录】:
\(dataJSON)
【多轮对话】:
\(transcript.isEmpty ? "" : transcript)
直接输出 Markdown,不要思考过程,不要 <think>:
/no_think
"""
}
}

View File

@@ -20,7 +20,9 @@ enum VLPrompts {
/// "value": "3.84",
/// "unit": "mmol/L",
/// "range": "< 3.40",
/// "status": "high|low|normal"
/// "status": "high|low|normal",
/// "source_page": 1,
/// "source_box": [0.18, 0.42, 0.68, 0.49]
/// }
/// ]
/// }
@@ -56,7 +58,9 @@ JSON schema(严格):
"value": string,
"unit": string,
"range": string,
"status": "high" | "low" | "normal"
"status": "high" | "low" | "normal",
"source_page": number,
"source_box": [number, number, number, number]
}
]
}
@@ -68,16 +72,18 @@ JSON schema(严格):
- report_date 必须从图片中识别;实在看不清就填上面给出的「今天」({{TODAY}})。下面示例里的日期只是格式参考,不要直接抄。
- 不要发明指标。数值看不清的整行跳过;但**没有参考范围不是跳过的理由**,结论页叙述式文字(如「总胆红素: 23.0(μmol/L)↑」)同样要提取,range 填 "",status 按箭头/「偏高」等标记判断。
- 化验单一般 type = "lab",体检套餐 = "checkup"
- source_page 是该指标所在图片页码,从 1 开始。
- source_box 是该指标整行在该页图片里的归一化矩形 [x,y,width,height],左上角为 (0,0),右下角为 (1,1)。尽量框住指标名、数值、单位、参考范围和异常标记所在整行;不确定位置时填 [0,0,0,0]。
示例 1(化验单 · 单项):
输入: 一张化验单照片,只能看清「低密度脂蛋白 3.84 mmol/L 参考 <3.40」
输出:
{"title":"","type":"lab","report_date":"2026-05-25","institution":"","page_count":1,"summary":"","indicators":[{"name":"","value":"3.84","unit":"mmol/L","range":"< 3.40","status":"high"}]}
{"title":"","type":"lab","report_date":"2026-05-25","institution":"","page_count":1,"summary":"","indicators":[{"name":"","value":"3.84","unit":"mmol/L","range":"< 3.40","status":"high","source_page":1,"source_box":[0.18,0.42,0.68,0.08]}]}
示例 2(体检 · 多项):
输入: 一份春季体检,3 项可读
输出:
{"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"},{"name":"","value":"32","unit":"U/L","range":"9 - 50","status":"normal"},{"name":"","value":"5.2","unit":"mmol/L","range":"3.9 - 6.1","status":"normal"}]}
{"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]}]}
现在请识别图片并输出 JSON:
"""#
@@ -138,5 +144,59 @@ JSON schema(严格):
{"indicators":[{"name":"","value":"23.0","unit":"μmol/L","range":"","status":"high"}]}
现在请识别这张局部照片并输出 JSON:
"""#
// MARK: - OCR (LLM , VL)
/// : Vision OCR , Qwen3-1.7B
/// 3B VL OCR (//)
static func indicatorsFromText(_ 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 indicatorsFromTextTemplate
.replacingOccurrences(of: "{{TODAY}}", with: todayStr)
.replacingOccurrences(of: "{{OCR_TEXT}}", with: ocrText)
}
private static let indicatorsFromTextTemplate: String = #"""
你是医学化验单/体检报告的结构化助手。下面是对一张报告做 OCR 得到的纯文本,可能有错字、错位、多余符号或换行混乱。
请从中提取所有「指标名 + 数值」,只输出一段合法 JSON,不要解释、不要 markdown 围栏、不要任何前后缀文字。
今天的日期是 {{TODAY}}。
JSON schema(严格):
{
"indicators": [
{
"name": string,
"value": string,
"unit": string,
"range": string,
"status": "high" | "low" | "normal"
}
]
}
规则:
- 只提取「有明确数值」的检验/体检指标;页眉、医院名、医生签名、采样时间、栏目标题、OCR 噪声一律忽略。
- status 判断优先级:① 文本里的箭头/标记(↑/H/偏高 → "high",↓/L/偏低 → "low")最优先;② 没有标记时用 value 与 range 比较;③ 都没有 → "normal"
- range 保留原文(如 "3.9 - 6.1""< 3.40""208 - 428");OCR 把破折号写成 "--" / "~" 都归一成 " - ";没有参考范围就填 ""
- 单位识别不出就填 "",不要编造;不要发明指标;同一指标只输出一次。
- name 用规范中文指标名(行内重复的去掉,英文缩写括注可保留)。
- 数值明显是 OCR 乱码(字母混入数字)且无法判断的,跳过该行。
示例 OCR 文本:
淋巴细胞数 3.0 1.8 -- 6.3 X10^9/L
尿酸 486 208-428 μmol/L
总胆红素(TB): 23.0 (μmol/L) ↑
对应输出:
{"indicators":[{"name":"","value":"3.0","unit":"X10^9/L","range":"1.8 - 6.3","status":"normal"},{"name":"尿","value":"486","unit":"μmol/L","range":"208 - 428","status":"high"},{"name":"","value":"23.0","unit":"μmol/L","range":"","status":"high"}]}
现在请解析下面这段 OCR 文本,只输出 JSON。/no_think
OCR 文本:
{{OCR_TEXT}}
"""#
}

View File

@@ -0,0 +1,64 @@
import SwiftUI
/// / : App
/// `Font.tjScaled` / `Font.tjTitle` ( App )
enum FontScale: String, CaseIterable, Identifiable {
case standard, large, extraLarge, huge
var id: String { rawValue }
/// ,
var multiplier: CGFloat {
switch self {
case .standard: return 1.0
case .large: return 1.2
case .extraLarge: return 1.4
case .huge: return 1.6
}
}
var label: String {
switch self {
case .standard: return String(appLoc: "标准")
case .large: return String(appLoc: "")
case .extraLarge: return String(appLoc: "特大")
case .huge: return String(appLoc: "超大")
}
}
var detail: String {
switch self {
case .standard: return String(appLoc: "默认字号")
case .large: return String(appLoc: "字号放大 20%")
case .extraLarge: return String(appLoc: "字号放大 40%")
case .huge: return String(appLoc: "字号放大 60%")
}
}
}
/// App ; `.id` ()
@Observable
final class FontScaleManager {
static let shared = FontScaleManager()
private let storageKey = "appFontScale"
private(set) var scale: FontScale
private init() {
let saved = UserDefaults.standard.string(forKey: storageKey)
scale = FontScale(rawValue: saved ?? "") ?? .standard
appFontScale = scale.multiplier
}
func set(_ newScale: FontScale) {
guard newScale != scale else { return }
scale = newScale
UserDefaults.standard.set(newScale.rawValue, forKey: storageKey)
appFontScale = newScale.multiplier
}
}
/// nonisolated :`Font.tjScaled` static func,
/// `FontScaleManager`(MainActor);,( Localization appLocBundle )
nonisolated(unsafe) var appFontScale: CGFloat = 1.0

View File

@@ -4,6 +4,7 @@ import SwiftData
@main
struct KangkangApp: App {
@State private var lang = LanguageManager.shared
@State private var fontScale = FontScaleManager.shared
init() {
// MLX , entitlement + LLM/VL jetsam OOM
@@ -98,7 +99,8 @@ struct KangkangApp: App {
AppLockContainer {
RootView()
.environment(\.locale, lang.locale)
.id(lang.current) // ,
// / ,( tjScaled )
.id("\(lang.current.rawValue)-\(fontScale.scale.rawValue)")
}
}
.modelContainer(sharedModelContainer)

View File

@@ -4,9 +4,9 @@ struct TjLockChip: View {
var body: some View {
HStack(spacing: 4) {
Image(systemName: "lock.fill")
.font(.system(size: 9, weight: .semibold))
.font(.tjScaled( 9, weight: .semibold))
Text("本地加密")
.font(.system(size: 10))
.font(.tjScaled( 10))
.tracking(0.5)
}
.foregroundStyle(Tj.Palette.paper)
@@ -44,7 +44,7 @@ struct TjBadge: View {
var style: TjBadgeStyle = .neutral
var body: some View {
Text(text)
.font(.system(size: 10, weight: .semibold))
.font(.tjScaled( 10, weight: .semibold))
.tracking(0.3)
.foregroundStyle(style.fg)
.padding(.horizontal, 7)
@@ -66,7 +66,7 @@ struct TjPlaceholder: View {
DiagonalStripes(spacing: 7, color: dark ? Color.white.opacity(0.04) : Color.black.opacity(0.05))
.clipShape(RoundedRectangle(cornerRadius: radius, style: .continuous))
Text(label)
.font(.system(size: 11, design: .monospaced))
.font(.tjScaled( 11, design: .monospaced))
.tracking(0.5)
.foregroundStyle(dark ? Color.white.opacity(0.5) : Tj.Palette.text3)
.multilineTextAlignment(.center)
@@ -101,7 +101,7 @@ struct TjPrimaryButton: ButtonStyle {
func makeBody(configuration: Configuration) -> some View {
configuration.label
.font(.system(size: fontSize, weight: .semibold))
.font(.tjScaled( fontSize, weight: .semibold))
.tracking(1)
.foregroundStyle(Tj.Palette.paper)
.padding(.horizontal, horizontalPadding)
@@ -118,7 +118,7 @@ struct TjGhostButton: ButtonStyle {
func makeBody(configuration: Configuration) -> some View {
configuration.label
.font(.system(size: fontSize, weight: .semibold))
.font(.tjScaled( fontSize, weight: .semibold))
.tracking(1)
.foregroundStyle(Tj.Palette.ink)
.padding(.horizontal, horizontalPadding)

View File

@@ -39,10 +39,18 @@ enum Tj {
}
extension Font {
static func tjTitle(_ size: CGFloat = 30) -> Font { .system(size: size, weight: .bold, design: .default) }
static func tjH2(_ size: CGFloat = 18) -> Font { .system(size: size, weight: .bold, design: .default) }
static func tjMono(_ size: CGFloat = 11) -> Font { .system(size: size, weight: .regular, design: .monospaced) }
static func tjSerifBody(_ size: CGFloat = 17) -> Font { .system(size: size, weight: .regular, design: .default) }
/// App `appFontScale` (/)
/// `.system(size:)` `.tjScaled(` ; +
static func tjScaled(_ size: CGFloat,
weight: Font.Weight = .regular,
design: Font.Design = .default) -> Font {
.system(size: size * appFontScale, weight: weight, design: design)
}
static func tjTitle(_ size: CGFloat = 30) -> Font { .tjScaled(size, weight: .bold) }
static func tjH2(_ size: CGFloat = 18) -> Font { .tjScaled(size, weight: .bold) }
static func tjMono(_ size: CGFloat = 11) -> Font { .tjScaled(size, design: .monospaced) }
static func tjSerifBody(_ size: CGFloat = 17) -> Font { .tjScaled(size) }
}
extension View {

View File

@@ -30,7 +30,6 @@ struct ArchiveListView: View {
@State private var filter: TimelineKind? = nil
@State private var endingSymptom: Symptom?
@State private var selectedEntry: TimelineEntry?
@State private var showExportSheet = false
@State private var route: Route?
@MainActor
@@ -110,9 +109,6 @@ struct ArchiveListView: View {
TimelineEntryDetailView(detail: d)
}
}
.fullScreenCover(isPresented: $showExportSheet) {
HealthExportSheet()
}
}
@ViewBuilder
@@ -150,35 +146,23 @@ struct ArchiveListView: View {
.font(.tjTitle(26))
.foregroundStyle(Tj.Palette.text)
Text(totalCount == 0 ? "" : String(appLoc: "\(totalCount)"))
.font(.system(size: 12))
.font(.tjScaled( 12))
.foregroundStyle(Tj.Palette.text3)
Spacer()
Menu {
Button {
showExportSheet = true
} label: {
Label("生成新导出", systemImage: "doc.text.below.ecg")
}
if !exports.isEmpty {
Button {
route = .exports
} label: {
Label("我的导出 · \(exports.count)", systemImage: "clock.arrow.circlepath")
if !exports.isEmpty {
Button { route = .exports } label: {
HStack(spacing: 6) {
Image(systemName: "clock.arrow.circlepath")
.font(.tjScaled( 12, weight: .semibold))
Text("导出历史")
.font(.tjScaled( 13, weight: .semibold))
}
.foregroundStyle(Tj.Palette.paper)
.padding(.horizontal, 12)
.padding(.vertical, 7)
.background(Capsule().fill(Tj.Palette.ink))
}
} label: {
HStack(spacing: 6) {
Image(systemName: "doc.text.below.ecg")
.font(.system(size: 12, weight: .semibold))
Text("导出身体档案")
.font(.system(size: 13, weight: .semibold))
Image(systemName: "chevron.down")
.font(.system(size: 9, weight: .semibold))
}
.foregroundStyle(Tj.Palette.paper)
.padding(.horizontal, 12)
.padding(.vertical, 7)
.background(Capsule().fill(Tj.Palette.ink))
.buttonStyle(.plain)
}
}
}
@@ -217,19 +201,19 @@ struct ArchiveListView: View {
ZStack {
Circle().fill(reminderEnabledCount > 0 ? Tj.Palette.amber.opacity(0.25) : Tj.Palette.sand2)
Image(systemName: "bell.fill")
.font(.system(size: 16))
.font(.tjScaled( 16))
.foregroundStyle(reminderEnabledCount > 0 ? Tj.Palette.ink : Tj.Palette.text3)
}
.frame(width: 36, height: 36)
VStack(alignment: .leading, spacing: 2) {
Text(reminderCountLabel)
.font(.system(size: 15, weight: .semibold))
.font(.tjScaled( 15, weight: .semibold))
.foregroundStyle(Tj.Palette.text)
.lineLimit(1)
if !reminderTitlePreview.isEmpty {
Text(reminderTitleLine)
.font(.system(size: 12))
.font(.tjScaled( 12))
.foregroundStyle(Tj.Palette.text3)
.lineLimit(1)
}
@@ -238,7 +222,7 @@ struct ArchiveListView: View {
Spacer(minLength: 0)
Image(systemName: "chevron.right")
.font(.system(size: 12, weight: .semibold))
.font(.tjScaled( 12, weight: .semibold))
.foregroundStyle(Tj.Palette.text3)
}
.padding(14)
@@ -265,7 +249,7 @@ struct ArchiveListView: View {
private func chip(label: String, selected: Bool, action: @escaping () -> Void) -> some View {
Button(action: action) {
Text(label)
.font(.system(size: 13, weight: selected ? .semibold : .regular))
.font(.tjScaled( 13, weight: selected ? .semibold : .regular))
.foregroundStyle(selected ? Tj.Palette.paper : Tj.Palette.text)
.padding(.horizontal, 14)
.padding(.vertical, 8)
@@ -282,14 +266,14 @@ struct ArchiveListView: View {
private func sectionHeader(_ section: DateSection, count: Int) -> some View {
HStack {
Text(section.label)
.font(.system(size: 12, weight: .semibold))
.font(.tjScaled( 12, weight: .semibold))
.tracking(0.5)
.foregroundStyle(Tj.Palette.text2)
Rectangle()
.fill(Tj.Palette.lineSoft)
.frame(height: 1)
Text("\(count)")
.font(.system(size: 11, design: .monospaced))
.font(.tjScaled( 11, design: .monospaced))
.foregroundStyle(Tj.Palette.text3)
}
.padding(.horizontal, 20)
@@ -303,7 +287,7 @@ struct ArchiveListView: View {
TjPlaceholder(label: String(appLoc: "还没有任何记录\n点底部 + 号开始"))
.frame(width: 240, height: 140)
Text(filter == nil ? String(appLoc: "记录会按时间归类显示") : String(appLoc: "这个类别下没有记录"))
.font(.system(size: 13))
.font(.tjScaled( 13))
.foregroundStyle(Tj.Palette.text3)
Spacer()
}

View File

@@ -52,7 +52,7 @@ struct HealthExportDetailView: View {
HStack(alignment: .center, spacing: 12) {
Button { dismiss() } label: {
Image(systemName: "xmark")
.font(.system(size: 16, weight: .semibold))
.font(.tjScaled( 16, weight: .semibold))
.foregroundStyle(Tj.Palette.text)
.frame(width: 32, height: 32)
.background(Circle().fill(Tj.Palette.sand2))
@@ -62,7 +62,7 @@ struct HealthExportDetailView: View {
.font(.tjH2())
.foregroundStyle(Tj.Palette.text)
Text(Self.absoluteDate(export.createdAt))
.font(.system(size: 11))
.font(.tjScaled( 11))
.foregroundStyle(Tj.Palette.text3)
}
Spacer()
@@ -81,13 +81,13 @@ struct HealthExportDetailView: View {
TjBadge(text: export.modelTag, style: .neutral)
if export.decodeRate > 0 {
Text(String(format: "%.1f tok/s", export.decodeRate))
.font(.system(size: 11, design: .monospaced))
.font(.tjScaled( 11, design: .monospaced))
.foregroundStyle(Tj.Palette.leaf)
}
Spacer()
if let from = export.inferredTimeFromDate, let to = export.inferredTimeToDate {
Text("\(Self.shortDate(from))\(Self.shortDate(to))")
.font(.system(size: 11, design: .monospaced))
.font(.tjScaled( 11, design: .monospaced))
.foregroundStyle(Tj.Palette.text3)
}
}
@@ -96,10 +96,10 @@ struct HealthExportDetailView: View {
private var promptBlock: some View {
HStack(alignment: .top, spacing: 8) {
Image(systemName: "quote.opening")
.font(.system(size: 12))
.font(.tjScaled( 12))
.foregroundStyle(Tj.Palette.text3)
Text(export.prompt)
.font(.system(size: 13))
.font(.tjScaled( 13))
.foregroundStyle(Tj.Palette.text2)
}
.padding(12)
@@ -119,7 +119,7 @@ struct HealthExportDetailView: View {
ShareLink(item: export.content) {
Label("分享", systemImage: "square.and.arrow.up")
.font(.system(size: 13, weight: .semibold))
.font(.tjScaled( 13, weight: .semibold))
.tracking(1)
.foregroundStyle(Tj.Palette.ink)
.padding(.horizontal, 14)
@@ -134,7 +134,7 @@ struct HealthExportDetailView: View {
showDeleteConfirm = true
} label: {
Image(systemName: "trash")
.font(.system(size: 15, weight: .medium))
.font(.tjScaled( 15, weight: .medium))
.foregroundStyle(Tj.Palette.brick)
.frame(width: 44, height: 44)
.background(Circle().strokeBorder(Tj.Palette.brick.opacity(0.4), lineWidth: 1))

View File

@@ -57,7 +57,7 @@ struct HealthExportListView: View {
.font(.tjTitle(24))
.foregroundStyle(Tj.Palette.text)
Text(exports.isEmpty ? "" : String(appLoc: "\(exports.count)"))
.font(.system(size: 12))
.font(.tjScaled( 12))
.foregroundStyle(Tj.Palette.text3)
Spacer()
TjLockChip()
@@ -88,22 +88,22 @@ struct HealthExportRow: View {
VStack(alignment: .leading, spacing: 6) {
HStack(alignment: .top) {
Text(export.promptPreview)
.font(.system(size: 14, weight: .semibold))
.font(.tjScaled( 14, weight: .semibold))
.foregroundStyle(Tj.Palette.text)
.lineLimit(2)
.multilineTextAlignment(.leading)
Spacer()
Image(systemName: "chevron.right")
.font(.system(size: 12, weight: .medium))
.font(.tjScaled( 12, weight: .medium))
.foregroundStyle(Tj.Palette.text3)
}
HStack(spacing: 8) {
Text(Self.relativeDate(export.createdAt))
.font(.system(size: 11))
.font(.tjScaled( 11))
.foregroundStyle(Tj.Palette.text3)
if export.decodeRate > 0 {
Text(String(format: "%.1f tok/s", export.decodeRate))
.font(.system(size: 10, design: .monospaced))
.font(.tjScaled( 10, design: .monospaced))
.foregroundStyle(Tj.Palette.leaf)
}
Spacer()

View File

@@ -2,7 +2,7 @@ import SwiftUI
import SwiftData
/// sheet
/// :idle running(extractingIntent retrieving generating) completed / failed
/// : running(retrieving generating) completed / failed
struct HealthExportSheet: View {
@Environment(\.modelContext) private var ctx
@Environment(\.dismiss) private var dismiss
@@ -10,7 +10,8 @@ struct HealthExportSheet: View {
/// :(,W3 )
let initialPrompt: String
@State private var prompt: String = ""
@State private var turns: [HealthExportDialogueTurn] = []
@State private var draftQuestion: String = ""
@State private var phase: HealthExportService.Phase?
@State private var content: String = ""
@State private var rate: Double = 0
@@ -18,14 +19,25 @@ struct HealthExportSheet: View {
@State private var error: Error?
@State private var completed: Bool = false
@State private var copiedFlash: Bool = false
@FocusState private var promptFocused: Bool
@State private var answeringTurnID: UUID?
@FocusState private var questionFocused: Bool
init(initialPrompt: String = "") {
self.initialPrompt = initialPrompt
}
private var isRunning: Bool { phase != nil && !completed && error == nil }
private var isInputMode: Bool { phase == nil && !completed && error == nil }
private var isGeneratingReport: Bool { phase != nil && !completed && error == nil }
private var isAnswering: Bool { answeringTurnID != nil }
private var canAsk: Bool {
!isAnswering &&
!isGeneratingReport &&
!draftQuestion.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
}
private var canGenerateReport: Bool {
!isAnswering &&
!isGeneratingReport &&
turns.contains(where: { $0.role == .user && !$0.text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty })
}
var body: some View {
VStack(spacing: 0) {
@@ -33,28 +45,20 @@ struct HealthExportSheet: View {
ScrollViewReader { proxy in
ScrollView {
VStack(alignment: .leading, spacing: 18) {
if isInputMode {
inputSection
} else {
promptEcho
if isRunning { phaseIndicator }
if !content.isEmpty {
MarkdownView(text: content)
.padding(16)
.frame(maxWidth: .infinity, alignment: .leading)
.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)
)
}
if let err = error { errorRow(err) }
// ,
Color.clear.frame(height: 1).id("bottom")
introSection
ForEach(turns) { turn in
dialogueBubble(turn)
}
if isGeneratingReport { phaseIndicator }
if !content.isEmpty {
reportCard
}
if let err = error { errorRow(err) }
Color.clear.frame(height: 1).id("bottom")
}
.padding(.horizontal, 20)
.padding(.vertical, 16)
@@ -64,13 +68,24 @@ struct HealthExportSheet: View {
proxy.scrollTo("bottom", anchor: .bottom)
}
}
.onChange(of: turns) { _, _ in
withAnimation(.easeOut(duration: 0.12)) {
proxy.scrollTo("bottom", anchor: .bottom)
}
}
}
if completed {
actionRow
} else {
composer
}
if completed { actionRow }
}
.background(Tj.Palette.sand.ignoresSafeArea())
.onAppear {
if prompt.isEmpty { prompt = initialPrompt }
if isInputMode { promptFocused = true }
if !initialPrompt.isEmpty, draftQuestion.isEmpty, turns.isEmpty {
draftQuestion = initialPrompt
}
questionFocused = true
}
.onDisappear { task?.cancel() }
}
@@ -81,17 +96,17 @@ struct HealthExportSheet: View {
HStack(alignment: .center, spacing: 12) {
Button { close() } label: {
Image(systemName: "xmark")
.font(.system(size: 16, weight: .semibold))
.font(.tjScaled( 16, weight: .semibold))
.foregroundStyle(Tj.Palette.text)
.frame(width: 32, height: 32)
.background(Circle().fill(Tj.Palette.sand2))
}
VStack(alignment: .leading, spacing: 2) {
Text("导出身体档案")
Text("身体档案")
.font(.tjH2())
.foregroundStyle(Tj.Palette.text)
Text("给医生看的就诊摘要")
.font(.system(size: 11))
Text("先问清楚,再整理给医生")
.font(.tjScaled( 11))
.foregroundStyle(Tj.Palette.text3)
}
Spacer()
@@ -105,82 +120,92 @@ struct HealthExportSheet: View {
}
}
// MARK: - Input section (idle)
// MARK: - Dialogue
private var inputSection: some View {
private var introSection: some View {
VStack(alignment: .leading, spacing: 14) {
Text("说说你想给医生看什么")
.font(.system(size: 13, weight: .semibold))
Text("围绕你的指标和健康日记提问")
.font(.tjScaled( 13, weight: .semibold))
.foregroundStyle(Tj.Palette.text2)
VStack(alignment: .leading, spacing: 6) {
Text("例:我感冒3天了,把最近一个月的健康情况给医生看")
.font(.system(size: 12))
Text("例:最近血压波动大吗?")
.font(.tjScaled( 12))
.foregroundStyle(Tj.Palette.text3)
Text("例:最近血糖好像不稳,把过去三个月的化验单整理一下")
.font(.system(size: 12))
Text("例:把我最近头晕、睡眠和指标变化整理给医生")
.font(.tjScaled( 12))
.foregroundStyle(Tj.Palette.text3)
}
ZStack(alignment: .topLeading) {
if prompt.isEmpty {
Text("在这里输入主诉……")
.font(.system(size: 15))
.foregroundStyle(Tj.Palette.text3)
.padding(.horizontal, 14)
.padding(.vertical, 14)
.allowsHitTesting(false)
Text("上下文:全部记录指标 + 健康日记 · 本地 RAG · 不上传任何数据")
.font(.tjScaled( 11))
.foregroundStyle(Tj.Palette.text3)
}
.padding(14)
.frame(maxWidth: .infinity, alignment: .leading)
.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)
)
}
private func dialogueBubble(_ turn: HealthExportDialogueTurn) -> some View {
let isUser = turn.role == .user
return HStack(alignment: .top, spacing: 8) {
if isUser { Spacer(minLength: 44) }
VStack(alignment: .leading, spacing: 6) {
Text(turn.role.transcriptLabel)
.font(.tjScaled( 11, weight: .semibold))
.foregroundStyle(isUser ? Tj.Palette.paper.opacity(0.8) : Tj.Palette.text3)
if turn.id == answeringTurnID && turn.text.isEmpty {
HStack(spacing: 8) {
ProgressView()
Text("正在查看本地记录…")
.font(.tjScaled( 13))
.foregroundStyle(Tj.Palette.text3)
}
} else {
Text(turn.text)
.font(.tjScaled( 14))
.lineSpacing(3)
.foregroundStyle(isUser ? Tj.Palette.paper : Tj.Palette.text)
.fixedSize(horizontal: false, vertical: true)
}
TextEditor(text: $prompt)
.font(.system(size: 15))
.foregroundStyle(Tj.Palette.text)
.scrollContentBackground(.hidden)
.padding(.horizontal, 10)
.padding(.vertical, 8)
.frame(minHeight: 130)
.focused($promptFocused)
}
.padding(12)
.frame(maxWidth: 300, alignment: .leading)
.background(
RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)
.fill(Tj.Palette.paper)
.fill(isUser ? Tj.Palette.ink : Tj.Palette.paper)
)
.overlay(
RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)
.strokeBorder(Tj.Palette.lineSoft, lineWidth: 1)
.strokeBorder(isUser ? Color.clear : Tj.Palette.lineSoft, lineWidth: 1)
)
HStack {
Text("本地 RAG · Qwen3 1.7B · 不上传任何数据")
.font(.system(size: 11))
.foregroundStyle(Tj.Palette.text3)
Spacer()
Button { start() } label: {
Text("生成报告")
}
.buttonStyle(TjPrimaryButton(height: 44, fontSize: 14))
.disabled(prompt.trimmingCharacters(in: .whitespaces).isEmpty)
.opacity(prompt.trimmingCharacters(in: .whitespaces).isEmpty ? 0.5 : 1)
}
if !isUser { Spacer(minLength: 44) }
}
}
// MARK: - Prompt echo (after start)
private var promptEcho: some View {
HStack(alignment: .top, spacing: 8) {
Image(systemName: "quote.opening")
.font(.system(size: 12))
.foregroundStyle(Tj.Palette.text3)
Text(prompt)
.font(.system(size: 13))
private var reportCard: some View {
VStack(alignment: .leading, spacing: 10) {
Text("整理好的报告")
.font(.tjScaled( 13, weight: .semibold))
.foregroundStyle(Tj.Palette.text2)
.lineLimit(3)
MarkdownView(text: content)
}
.padding(12)
.padding(16)
.frame(maxWidth: .infinity, alignment: .leading)
.background(
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
.fill(Tj.Palette.sand2)
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)
)
}
@@ -197,11 +222,11 @@ struct HealthExportSheet: View {
}
if phase == .generating && rate > 0 {
Text(String(format: String(appLoc: "本地推理 · %.1f tok/s"), rate))
.font(.system(size: 11, design: .monospaced))
.font(.tjScaled( 11, design: .monospaced))
.foregroundStyle(Tj.Palette.leaf)
} else {
Text(phase?.label ?? "")
.font(.system(size: 11))
.font(.tjScaled( 11))
.foregroundStyle(Tj.Palette.text3)
}
}
@@ -213,7 +238,7 @@ struct HealthExportSheet: View {
let fill = active ? Tj.Palette.ink : (done ? Tj.Palette.leaf : Tj.Palette.sand2)
let fg = (active || done) ? Tj.Palette.paper : Tj.Palette.text3
return Text(p.label)
.font(.system(size: 11, weight: active ? .semibold : .regular))
.font(.tjScaled( 11, weight: active ? .semibold : .regular))
.foregroundStyle(fg)
.padding(.horizontal, 10)
.padding(.vertical, 5)
@@ -222,7 +247,7 @@ struct HealthExportSheet: View {
private var arrow: some View {
Image(systemName: "chevron.right")
.font(.system(size: 10, weight: .semibold))
.font(.tjScaled( 10, weight: .semibold))
.foregroundStyle(Tj.Palette.text3)
}
@@ -243,7 +268,7 @@ struct HealthExportSheet: View {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundStyle(Tj.Palette.brick)
Text(err.localizedDescription)
.font(.system(size: 13))
.font(.tjScaled( 13))
.foregroundStyle(Tj.Palette.text)
}
Button { reset() } label: { Text("返回修改") }
@@ -268,7 +293,7 @@ struct HealthExportSheet: View {
ShareLink(item: content) {
Label("分享", systemImage: "square.and.arrow.up")
.font(.system(size: 13, weight: .semibold))
.font(.tjScaled( 13, weight: .semibold))
.tracking(1)
.foregroundStyle(Tj.Palette.ink)
.padding(.horizontal, 14)
@@ -279,7 +304,7 @@ struct HealthExportSheet: View {
Spacer()
Button { regenerate() } label: {
Label("重新生成", systemImage: "arrow.clockwise")
Label("重新整理", systemImage: "arrow.clockwise")
}
.buttonStyle(TjPrimaryButton(height: 44, fontSize: 13, horizontalPadding: 16))
}
@@ -291,19 +316,100 @@ struct HealthExportSheet: View {
}
}
private var composer: some View {
VStack(spacing: 10) {
HStack(spacing: 8) {
TextField("继续提问或补充情况…", text: $draftQuestion, axis: .vertical)
.font(.tjScaled( 14))
.lineLimit(1...4)
.padding(.horizontal, 12)
.padding(.vertical, 10)
.background(
RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)
.fill(Tj.Palette.sand2)
)
.focused($questionFocused)
.disabled(isAnswering || isGeneratingReport)
Button { sendQuestion() } label: {
Image(systemName: "arrow.up")
.font(.tjScaled( 15, weight: .bold))
.foregroundStyle(Tj.Palette.paper)
.frame(width: 40, height: 40)
.background(Circle().fill(canAsk ? Tj.Palette.ink : Tj.Palette.line))
}
.disabled(!canAsk)
.accessibilityLabel("发送问题")
}
Button { startReportGeneration() } label: {
Label("生成整理报告", systemImage: "doc.text.below.ecg")
}
.buttonStyle(TjPrimaryButton(height: 44, fontSize: 14))
.disabled(!canGenerateReport)
.opacity(canGenerateReport ? 1 : 0.45)
}
.padding(.horizontal, 20)
.padding(.vertical, 12)
.background(Tj.Palette.paper)
.overlay(alignment: .top) {
Rectangle().fill(Tj.Palette.lineSoft).frame(height: 1)
}
}
// MARK: - Actions
private func start() {
let p = prompt.trimmingCharacters(in: .whitespacesAndNewlines)
guard !p.isEmpty else { return }
promptFocused = false
private func sendQuestion() {
let question = draftQuestion.trimmingCharacters(in: .whitespacesAndNewlines)
guard !question.isEmpty, !isAnswering, !isGeneratingReport else { return }
draftQuestion = ""
questionFocused = false
let userTurn = HealthExportDialogueTurn.user(question)
let assistantTurn = HealthExportDialogueTurn.assistant("")
turns.append(userTurn)
turns.append(assistantTurn)
answeringTurnID = assistantTurn.id
let conversationForPrompt = turns.filter { $0.id != assistantTurn.id }
let stream = HealthExportService.shared.answer(
question: question,
conversation: conversationForPrompt,
in: ctx
)
task?.cancel()
task = Task { @MainActor in
do {
for try await chunk in stream {
appendToTurn(id: assistantTurn.id, text: chunk.text)
if chunk.decodeRate > 0 { rate = chunk.decodeRate }
}
answeringTurnID = nil
questionFocused = true
} catch {
answeringTurnID = nil
appendToTurn(id: assistantTurn.id, text: error.localizedDescription)
questionFocused = true
}
}
}
private func appendToTurn(id: UUID, text: String) {
guard let idx = turns.firstIndex(where: { $0.id == id }) else { return }
turns[idx].text += text
}
private func startReportGeneration() {
guard canGenerateReport else { return }
questionFocused = false
content = ""
rate = 0 // , tok/s
error = nil
completed = false
phase = .extractingIntent
phase = .retrieving
let stream = HealthExportService.shared.export(prompt: p, in: ctx)
let stream = HealthExportService.shared.export(conversation: turns, in: ctx)
task?.cancel()
task = Task { @MainActor in
do {
for try await event in stream {
@@ -326,7 +432,7 @@ struct HealthExportSheet: View {
private func regenerate() {
completed = false
start()
startReportGeneration()
}
private func reset() {
@@ -337,7 +443,8 @@ struct HealthExportSheet: View {
rate = 0
error = nil
completed = false
promptFocused = true
answeringTurnID = nil
questionFocused = true
}
private func copy() {
@@ -377,7 +484,7 @@ struct MarkdownView: View {
case .h1(let s):
VStack(alignment: .leading, spacing: 8) {
Text(inline(s))
.font(.system(size: 22, weight: .bold))
.font(.tjScaled( 22, weight: .bold))
.foregroundStyle(Tj.Palette.text)
.fixedSize(horizontal: false, vertical: true)
Rectangle()
@@ -394,7 +501,7 @@ struct MarkdownView: View {
.fill(Tj.Palette.brick)
.frame(width: 3, height: 16)
Text(inline(s))
.font(.system(size: 16, weight: .semibold))
.font(.tjScaled( 16, weight: .semibold))
.foregroundStyle(Tj.Palette.text)
}
.padding(.top, 10)
@@ -404,10 +511,10 @@ struct MarkdownView: View {
if let abnormalText = Self.extractAbnormal(s) {
HStack(alignment: .firstTextBaseline, spacing: 8) {
Image(systemName: "exclamationmark.triangle.fill")
.font(.system(size: 11))
.font(.tjScaled( 11))
.foregroundStyle(Tj.Palette.brick)
Text(inline(abnormalText))
.font(.system(size: 14, weight: .medium))
.font(.tjScaled( 14, weight: .medium))
.foregroundStyle(Tj.Palette.text)
.fixedSize(horizontal: false, vertical: true)
Spacer(minLength: 0)
@@ -431,7 +538,7 @@ struct MarkdownView: View {
.frame(width: 4, height: 4)
.padding(.top, 6)
Text(inline(s))
.font(.system(size: 14))
.font(.tjScaled( 14))
.foregroundStyle(Tj.Palette.text)
.fixedSize(horizontal: false, vertical: true)
}
@@ -440,7 +547,7 @@ struct MarkdownView: View {
case .body(let s):
Text(inline(s))
.font(.system(size: 14))
.font(.tjScaled( 14))
.lineSpacing(3)
.foregroundStyle(Tj.Palette.text)
.fixedSize(horizontal: false, vertical: true)

View File

@@ -82,7 +82,7 @@ struct CalendarOverviewView: View {
}
} label: {
Text("回到今天")
.font(.system(size: 13))
.font(.tjScaled( 13))
.foregroundStyle(Tj.Palette.text3)
}
}
@@ -90,7 +90,7 @@ struct CalendarOverviewView: View {
if let onClose {
Button(action: onClose) {
Text("完成")
.font(.system(size: 15, weight: .semibold))
.font(.tjScaled( 15, weight: .semibold))
.foregroundStyle(Tj.Palette.text)
}
}
@@ -136,7 +136,7 @@ struct CalendarOverviewView: View {
}
} label: {
Text(m.label)
.font(.system(size: 13, weight: mode == m ? .semibold : .regular))
.font(.tjScaled( 13, weight: mode == m ? .semibold : .regular))
.foregroundStyle(mode == m ? Tj.Palette.paper : Tj.Palette.text)
.frame(maxWidth: .infinity)
.padding(.vertical, 9)
@@ -157,7 +157,7 @@ struct CalendarOverviewView: View {
HStack {
Button { shiftAnchor(-1) } label: {
Image(systemName: "chevron.left")
.font(.system(size: 16, weight: .semibold))
.font(.tjScaled( 16, weight: .semibold))
.foregroundStyle(Tj.Palette.text)
.frame(width: 36, height: 36)
.background(Circle().fill(Tj.Palette.paper))
@@ -177,7 +177,7 @@ struct CalendarOverviewView: View {
Button { shiftAnchor(1) } label: {
Image(systemName: "chevron.right")
.font(.system(size: 16, weight: .semibold))
.font(.tjScaled( 16, weight: .semibold))
.foregroundStyle(Tj.Palette.text)
.frame(width: 36, height: 36)
.background(Circle().fill(Tj.Palette.paper))
@@ -230,7 +230,7 @@ struct CalendarOverviewView: View {
private var legend: some View {
VStack(alignment: .leading, spacing: 8) {
Text("图例")
.font(.system(size: 11, weight: .semibold))
.font(.tjScaled( 11, weight: .semibold))
.tracking(0.5)
.foregroundStyle(Tj.Palette.text3)
HStack(spacing: 14) {
@@ -249,7 +249,7 @@ struct CalendarOverviewView: View {
.fill(color)
.frame(width: 14, height: 6)
Text(label)
.font(.system(size: 11))
.font(.tjScaled( 11))
.foregroundStyle(Tj.Palette.text2)
}
}

View File

@@ -40,7 +40,7 @@ struct CaptureReviewForm: View {
.foregroundStyle(Tj.Palette.amber)
VStack(alignment: .leading, spacing: 8) {
Text(text)
.font(.system(size: 12))
.font(.tjScaled( 12))
.foregroundStyle(Tj.Palette.text2)
.fixedSize(horizontal: false, vertical: true)
if let onReanalyze {
@@ -48,7 +48,7 @@ struct CaptureReviewForm: View {
onReanalyze()
} label: {
Label("重新识别", systemImage: "arrow.clockwise")
.font(.system(size: 12, weight: .semibold))
.font(.tjScaled( 12, weight: .semibold))
}
.buttonStyle(.plain)
.foregroundStyle(Tj.Palette.ink)
@@ -131,7 +131,7 @@ struct CaptureReviewForm: View {
private func labeledField<C: View>(_ label: String, @ViewBuilder content: () -> C) -> some View {
VStack(alignment: .leading, spacing: 4) {
Text(label)
.font(.system(size: 11, weight: .medium))
.font(.tjScaled( 11, weight: .medium))
.foregroundStyle(Tj.Palette.text3)
content()
}
@@ -150,14 +150,14 @@ struct CaptureReviewForm: View {
)
} label: {
Label("加一项", systemImage: "plus.circle")
.font(.system(size: 12, weight: .medium))
.font(.tjScaled( 12, weight: .medium))
}
.buttonStyle(.plain)
.foregroundStyle(Tj.Palette.ink)
}
if parsed.indicators.isEmpty {
Text("没有指标 — 点上方「加一项」补一行,或直接保存只存图片")
.font(.system(size: 12))
.font(.tjScaled( 12))
.foregroundStyle(Tj.Palette.text3)
.padding(.vertical, 8)
} else {
@@ -175,7 +175,7 @@ struct CaptureReviewForm: View {
return VStack(spacing: 8) {
HStack(spacing: 8) {
TextField("指标名", text: binding.name)
.font(.system(size: 14, weight: .medium))
.font(.tjScaled( 14, weight: .medium))
Button(role: .destructive) {
parsed.indicators.removeAll { $0.id == id }
} label: {
@@ -187,7 +187,7 @@ struct CaptureReviewForm: View {
HStack(spacing: 8) {
TextField("数值", text: binding.value)
.keyboardType(.decimalPad)
.font(.system(size: 14, weight: .semibold, design: .monospaced))
.font(.tjScaled( 14, weight: .semibold, design: .monospaced))
.frame(maxWidth: 90)
TextField("单位", text: binding.unit)
.frame(maxWidth: 80)
@@ -247,7 +247,7 @@ struct CaptureReviewForm: View {
private func sectionLabel(_ t: String) -> some View {
Text(t)
.font(.system(size: 12, weight: .semibold))
.font(.tjScaled( 12, weight: .semibold))
.tracking(0.3)
.foregroundStyle(Tj.Palette.text2)
}

View File

@@ -13,10 +13,10 @@ struct PhotoPickerSheet: View {
var body: some View {
VStack(spacing: 20) {
Image(systemName: "photo.on.rectangle.angled")
.font(.system(size: 56))
.font(.tjScaled( 56))
.foregroundStyle(Tj.Palette.text3)
Text("模拟器没有摄像头,从相册选一张化验单/体检报告")
.font(.system(size: 13))
.font(.tjScaled( 13))
.foregroundStyle(Tj.Palette.text2)
.multilineTextAlignment(.center)
@@ -24,7 +24,7 @@ struct PhotoPickerSheet: View {
maxSelectionCount: 5,
matching: .images) {
Text("从相册选 ≤5 张")
.font(.system(size: 14, weight: .semibold))
.font(.tjScaled( 14, weight: .semibold))
.frame(maxWidth: .infinity)
.padding(.vertical, 12)
.background(Tj.Palette.ink)

View File

@@ -300,7 +300,12 @@ struct UnifiedCaptureFlow: View {
status: ind.status,
capturedAt: final.reportDate,
report: report,
source: .report
source: .report,
sourcePageIndex: ind.sourcePageIndex,
sourceBoxX: ind.sourceBoxX,
sourceBoxY: ind.sourceBoxY,
sourceBoxWidth: ind.sourceBoxWidth,
sourceBoxHeight: ind.sourceBoxHeight
)
ctx.insert(i)
}
@@ -346,16 +351,16 @@ private struct AnalyzingView: View {
.font(.tjH2())
.foregroundStyle(Tj.Palette.text)
Text("\(images.count) 页 · 100% 本地推理 · 已用 \(elapsed)s")
.font(.system(size: 12))
.font(.tjScaled( 12))
.foregroundStyle(Tj.Palette.text3)
if elapsed >= timeoutSeconds - 5 {
Text("快超时了,>\(timeoutSeconds)s 会自动转为手动录入")
.font(.system(size: 11))
.font(.tjScaled( 11))
.foregroundStyle(Tj.Palette.amber)
}
}
Button("取消识别 · 改为手动录入", action: onCancel)
.font(.system(size: 13, weight: .medium))
.font(.tjScaled( 13, weight: .medium))
.foregroundStyle(Tj.Palette.text3)
.padding(.top, 4)
Spacer()
@@ -375,7 +380,7 @@ private struct CaptureTipSheet: View {
VStack(alignment: .leading, spacing: 16) {
HStack(spacing: 10) {
Image(systemName: "doc.viewfinder")
.font(.system(size: 28))
.font(.tjScaled( 28))
.foregroundStyle(Tj.Palette.ink)
Text("拍报告的小贴士")
.font(.tjH2())

View File

@@ -62,12 +62,12 @@ struct DiaryQuickSheet: View {
.font(.tjH2())
.foregroundStyle(Tj.Palette.text)
Text("记录身体状态 · 可让 AI 多轮辅助查漏补缺")
.font(.system(size: 11))
.font(.tjScaled( 11))
.foregroundStyle(Tj.Palette.text3)
}
Spacer()
Text("本机保存")
.font(.system(size: 12))
.font(.tjScaled( 12))
.foregroundStyle(Tj.Palette.text3)
}
.padding(.horizontal, 20)
@@ -154,18 +154,18 @@ struct DiaryQuickSheet: View {
// section header
HStack(spacing: 6) {
Image(systemName: "sparkles")
.font(.system(size: 11, weight: .semibold))
.font(.tjScaled( 11, weight: .semibold))
.foregroundStyle(Tj.Palette.brick)
sectionLabel(String(appLoc: "AI 辅助 · 医生角度查漏补缺"))
Spacer()
if hasQuestions {
Text("\(questions.count) 个建议")
.font(.system(size: 10, design: .monospaced))
.font(.tjScaled( 10, design: .monospaced))
.foregroundStyle(Tj.Palette.text3)
}
if lastRate > 0 {
Text(String(format: "%.1f tok/s", lastRate))
.font(.system(size: 10, design: .monospaced))
.font(.tjScaled( 10, design: .monospaced))
.foregroundStyle(Tj.Palette.leaf)
}
}
@@ -187,10 +187,10 @@ struct DiaryQuickSheet: View {
if exhaustedNote {
HStack(spacing: 6) {
Image(systemName: "checkmark.seal.fill")
.font(.system(size: 11))
.font(.tjScaled( 11))
.foregroundStyle(Tj.Palette.leaf)
Text("已覆盖主要问诊维度;补充原文后可再追问")
.font(.system(size: 11))
.font(.tjScaled( 11))
.foregroundStyle(Tj.Palette.text3)
Spacer(minLength: 0)
}
@@ -219,11 +219,11 @@ struct DiaryQuickSheet: View {
HStack(spacing: 10) {
ProgressView().controlSize(.small)
Text("AI 思考中… 本地推理,通常 5-10 秒")
.font(.system(size: 13))
.font(.tjScaled( 13))
.foregroundStyle(Tj.Palette.text2)
Spacer()
Button("取消") { cancelSuggestions() }
.font(.system(size: 12, weight: .semibold))
.font(.tjScaled( 12, weight: .semibold))
.foregroundStyle(Tj.Palette.text3)
}
.padding(.vertical, 11)
@@ -253,13 +253,13 @@ struct DiaryQuickSheet: View {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundStyle(Tj.Palette.brick)
Text(err.localizedDescription)
.font(.system(size: 12))
.font(.tjScaled( 12))
.foregroundStyle(Tj.Palette.text)
Spacer()
}
Button { requestSuggestions() } label: {
Text("重试")
.font(.system(size: 12, weight: .semibold))
.font(.tjScaled( 12, weight: .semibold))
.foregroundStyle(Tj.Palette.ink)
}
.buttonStyle(.plain)
@@ -282,7 +282,7 @@ struct DiaryQuickSheet: View {
Image(systemName: icon)
Text(label)
}
.font(.system(size: 13, weight: .semibold))
.font(.tjScaled( 13, weight: .semibold))
.foregroundStyle(enabled ? Tj.Palette.ink : Tj.Palette.text3)
.frame(maxWidth: .infinity)
.padding(.vertical, 11)
@@ -315,12 +315,12 @@ struct DiaryQuickSheet: View {
HStack(spacing: 8) {
HStack(spacing: 6) {
Image(systemName: round == 1 ? "1.circle.fill" : "arrow.triangle.2.circlepath")
.font(.system(size: 11, weight: .semibold))
.font(.tjScaled( 11, weight: .semibold))
.foregroundStyle(Tj.Palette.brick)
Text(round == 1
? String(appLoc: "第 1 轮 · \(count)")
: String(appLoc: "\(round) 轮 · 基于你刚才更新的文本 · \(count)"))
.font(.system(size: 11, weight: .semibold))
.font(.tjScaled( 11, weight: .semibold))
.tracking(0.3)
.foregroundStyle(Tj.Palette.text2)
}
@@ -344,10 +344,10 @@ struct DiaryQuickSheet: View {
return VStack(alignment: .leading, spacing: 6) {
HStack(alignment: .top, spacing: 8) {
Text("\(index).")
.font(.system(size: 13, weight: .semibold, design: .monospaced))
.font(.tjScaled( 13, weight: .semibold, design: .monospaced))
.foregroundStyle(adopted ? Tj.Palette.text3 : Tj.Palette.brick)
Text(question.q)
.font(.system(size: 13, weight: .medium))
.font(.tjScaled( 13, weight: .medium))
.foregroundStyle(adopted ? Tj.Palette.text3 : Tj.Palette.text)
.strikethrough(adopted, color: Tj.Palette.text3)
.fixedSize(horizontal: false, vertical: true)
@@ -356,9 +356,9 @@ struct DiaryQuickSheet: View {
if adopted {
HStack(spacing: 4) {
Image(systemName: "checkmark")
.font(.system(size: 10, weight: .bold))
.font(.tjScaled( 10, weight: .bold))
Text("已采纳")
.font(.system(size: 11, weight: .semibold))
.font(.tjScaled( 11, weight: .semibold))
}
.foregroundStyle(Tj.Palette.leaf)
.padding(.horizontal, 8)
@@ -368,9 +368,9 @@ struct DiaryQuickSheet: View {
Button { adopt(question) } label: {
HStack(spacing: 4) {
Image(systemName: "plus.circle.fill")
.font(.system(size: 12))
.font(.tjScaled( 12))
Text("采纳")
.font(.system(size: 12, weight: .semibold))
.font(.tjScaled( 12, weight: .semibold))
}
.foregroundStyle(Tj.Palette.paper)
.padding(.horizontal, 10)
@@ -390,10 +390,10 @@ struct DiaryQuickSheet: View {
} else if !question.fill.isEmpty && !adopted {
HStack(alignment: .top, spacing: 4) {
Text("将追加:")
.font(.system(size: 11))
.font(.tjScaled( 11))
.foregroundStyle(Tj.Palette.text3)
Text(question.fill)
.font(.system(size: 11))
.font(.tjScaled( 11))
.foregroundStyle(Tj.Palette.text2)
.fixedSize(horizontal: false, vertical: true)
}
@@ -416,7 +416,7 @@ struct DiaryQuickSheet: View {
private func sectionLabel(_ text: String) -> some View {
Text(text)
.font(.system(size: 12, weight: .semibold))
.font(.tjScaled( 12, weight: .semibold))
.tracking(0.3)
.foregroundStyle(Tj.Palette.text2)
}

View File

@@ -99,7 +99,7 @@ struct QuestionFillPanel: View {
VStack(alignment: .leading, spacing: 10) {
// :,线
previewText
.font(.system(size: 13))
.font(.tjScaled( 13))
.fixedSize(horizontal: false, vertical: true)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(10)
@@ -115,7 +115,7 @@ struct QuestionFillPanel: View {
HStack(spacing: 8) {
Button(action: onCancel) {
Text("取消")
.font(.system(size: 13, weight: .semibold))
.font(.tjScaled( 13, weight: .semibold))
.foregroundStyle(Tj.Palette.text2)
.frame(maxWidth: .infinity)
.padding(.vertical, 9)
@@ -134,9 +134,9 @@ struct QuestionFillPanel: View {
} label: {
HStack(spacing: 5) {
Image(systemName: "text.append")
.font(.system(size: 12, weight: .semibold))
.font(.tjScaled( 12, weight: .semibold))
Text("加入记录")
.font(.system(size: 13, weight: .semibold))
.font(.tjScaled( 13, weight: .semibold))
}
.foregroundStyle(Tj.Palette.paper)
.frame(maxWidth: .infinity)
@@ -180,7 +180,7 @@ struct QuestionFillPanel: View {
private func slotEditor(index: Int, label: String, options: [String]) -> some View {
VStack(alignment: .leading, spacing: 6) {
Text(label)
.font(.system(size: 11, weight: .semibold))
.font(.tjScaled( 11, weight: .semibold))
.foregroundStyle(Tj.Palette.text3)
if !options.isEmpty {
@@ -189,7 +189,7 @@ struct QuestionFillPanel: View {
let picked = bindingValue(index) == opt
Button { values[index] = opt } label: {
Text(opt)
.font(.system(size: 12, weight: picked ? .semibold : .regular))
.font(.tjScaled( 12, weight: picked ? .semibold : .regular))
.foregroundStyle(picked ? Tj.Palette.paper : Tj.Palette.text)
.padding(.horizontal, 10)
.padding(.vertical, 5)
@@ -208,7 +208,7 @@ struct QuestionFillPanel: View {
}
TextField(String(appLoc: "填写\(label)"), text: binding(index))
.font(.system(size: 13))
.font(.tjScaled( 13))
.padding(.horizontal, 12)
.padding(.vertical, 9)
.background(

View File

@@ -85,10 +85,10 @@ struct HomeCalendarCard: View {
Spacer()
HStack(spacing: 3) {
Text(summaryLine)
.font(.system(size: 12))
.font(.tjScaled( 12))
.foregroundStyle(Tj.Palette.text3)
Image(systemName: "chevron.right")
.font(.system(size: 11, weight: .semibold))
.font(.tjScaled( 11, weight: .semibold))
.foregroundStyle(Tj.Palette.text3)
}
}
@@ -118,7 +118,7 @@ struct HomeCalendarCard: View {
} label: {
VStack(spacing: 5) {
Text(weekdayLabel(day))
.font(.system(size: 10, weight: .medium))
.font(.tjScaled( 10, weight: .medium))
.foregroundStyle(Tj.Palette.text3)
ZStack {
RoundedRectangle(cornerRadius: 9, style: .continuous)
@@ -128,7 +128,7 @@ struct HomeCalendarCard: View {
.strokeBorder(Tj.Palette.ink, lineWidth: 1.2)
}
Text("\(calendar.component(.day, from: day))")
.font(.system(size: 14, weight: isToday ? .bold : .regular))
.font(.tjScaled( 14, weight: isToday ? .bold : .regular))
.foregroundStyle(isToday ? Tj.Palette.ink : Tj.Palette.text)
}
.frame(height: 38)

View File

@@ -71,7 +71,7 @@ struct HomeView: View {
HStack(alignment: .top) {
VStack(alignment: .leading, spacing: 4) {
Text(todayLine)
.font(.system(size: 12))
.font(.tjScaled( 12))
.tracking(1)
.foregroundStyle(Tj.Palette.text3)
Text(greetingWord)
@@ -106,7 +106,7 @@ struct HomeView: View {
Spacer()
Button(action: onTapArchive) {
Text("全部 ")
.font(.system(size: 12))
.font(.tjScaled( 12))
.foregroundStyle(Tj.Palette.text3)
}
.buttonStyle(.plain)
@@ -119,7 +119,7 @@ struct HomeView: View {
ForEach(recentGrouped, id: \.section) { group in
VStack(alignment: .leading, spacing: 8) {
Text(group.section.label)
.font(.system(size: 11, weight: .semibold))
.font(.tjScaled( 11, weight: .semibold))
.tracking(0.5)
.foregroundStyle(Tj.Palette.text3)
VStack(spacing: 10) {
@@ -148,7 +148,7 @@ struct HomeView: View {
private var emptyRecent: some View {
HStack {
Text("还没有任何记录,点底部 + 号开始第一条")
.font(.system(size: 13))
.font(.tjScaled( 13))
.foregroundStyle(Tj.Palette.text3)
Spacer()
}
@@ -167,15 +167,15 @@ struct HomeView: View {
.frame(width: 56, height: 56)
VStack(alignment: .leading, spacing: 2) {
Text("我的报告档案")
.font(.system(size: 14, weight: .semibold))
.font(.tjScaled( 14, weight: .semibold))
.foregroundStyle(Tj.Palette.text)
Text("\(reports.count) 份 · \(indicators.count) 项指标 · 端侧加密")
.font(.system(size: 11))
.font(.tjScaled( 11))
.foregroundStyle(Tj.Palette.text3)
}
Spacer()
Image(systemName: "chevron.right")
.font(.system(size: 14, weight: .medium))
.font(.tjScaled( 14, weight: .medium))
.foregroundStyle(Tj.Palette.text3)
}
.padding(14)

View File

@@ -34,12 +34,12 @@ struct RecentItemRow: View {
VStack(alignment: .leading, spacing: 2) {
Text("\(date) · \(type)")
.font(.system(size: 11))
.font(.tjScaled( 11))
.tracking(0.3)
.foregroundStyle(Tj.Palette.text3)
.lineLimit(1)
Text(name)
.font(.system(size: 14, weight: .medium))
.font(.tjScaled( 14, weight: .medium))
.foregroundStyle(Tj.Palette.text)
.lineLimit(1)
.truncationMode(.tail)
@@ -47,7 +47,7 @@ struct RecentItemRow: View {
Spacer(minLength: 8)
if let value {
Text(value)
.font(.system(size: 12, weight: .semibold, design: .monospaced))
.font(.tjScaled( 12, weight: .semibold, design: .monospaced))
.foregroundStyle(status.valueColor)
.lineLimit(1)
.fixedSize()

View File

@@ -61,12 +61,12 @@ struct TodayRemindersCard: View {
.font(.tjH2())
.foregroundStyle(Tj.Palette.text)
Text("\(count)")
.font(.system(size: 12))
.font(.tjScaled( 12))
.foregroundStyle(Tj.Palette.text3)
Spacer()
Button { showingCenter = true } label: {
Text("全部 ")
.font(.system(size: 12))
.font(.tjScaled( 12))
.foregroundStyle(Tj.Palette.text3)
}
.buttonStyle(.plain)
@@ -77,14 +77,14 @@ struct TodayRemindersCard: View {
let isPast = item.isPast(now: tick)
return HStack(spacing: 12) {
Text(item.timeLabel)
.font(.system(size: 14, weight: .semibold).monospacedDigit())
.font(.tjScaled( 14, weight: .semibold).monospacedDigit())
.foregroundStyle(isPast ? Tj.Palette.text3 : Tj.Palette.ink)
.frame(width: 46, alignment: .leading)
Image(systemName: "bell.fill")
.font(.system(size: 12))
.font(.tjScaled( 12))
.foregroundStyle(isPast ? Tj.Palette.text3 : Tj.Palette.amber)
Text(item.title)
.font(.system(size: 15, weight: .medium))
.font(.tjScaled( 15, weight: .medium))
.foregroundStyle(isPast ? Tj.Palette.text3 : Tj.Palette.text)
.lineLimit(1)
Spacer(minLength: 0)

View File

@@ -125,7 +125,7 @@ struct CustomMetricEditor: View {
Spacer()
if existing == nil {
Text("保存后会出现在录入选项里")
.font(.system(size: 11))
.font(.tjScaled( 11))
.foregroundStyle(Tj.Palette.text3)
}
}
@@ -147,10 +147,10 @@ struct CustomMetricEditor: View {
if nameConflict != .none {
HStack(spacing: 6) {
Image(systemName: "exclamationmark.triangle.fill")
.font(.system(size: 11))
.font(.tjScaled( 11))
.foregroundStyle(Tj.Palette.amber)
Text(nameConflict.warningText)
.font(.system(size: 11))
.font(.tjScaled( 11))
.foregroundStyle(Tj.Palette.amber)
.fixedSize(horizontal: false, vertical: true)
Spacer(minLength: 0)
@@ -175,7 +175,7 @@ struct CustomMetricEditor: View {
sectionLabel(String(appLoc: "参考范围(可选)"))
Spacer()
Text("用于自动判定 正常/偏高/偏低")
.font(.system(size: 10))
.font(.tjScaled( 10))
.foregroundStyle(Tj.Palette.text3)
}
HStack(spacing: 12) {
@@ -188,10 +188,10 @@ struct CustomMetricEditor: View {
private func rangeField(label: String, value: Binding<String>, placeholder: String) -> some View {
VStack(alignment: .leading, spacing: 4) {
Text(label).font(.system(size: 11)).foregroundStyle(Tj.Palette.text3)
Text(label).font(.tjScaled( 11)).foregroundStyle(Tj.Palette.text3)
TextField(placeholder, text: value)
.keyboardType(.decimalPad)
.font(.system(size: 16, weight: .medium, design: .monospaced))
.font(.tjScaled( 16, weight: .medium, design: .monospaced))
.padding(.horizontal, 12).padding(.vertical, 10)
.background(fieldBg).overlay(fieldBorder)
}
@@ -207,7 +207,7 @@ struct CustomMetricEditor: View {
icon = sf
} label: {
Image(systemName: sf)
.font(.system(size: 20, weight: .medium))
.font(.tjScaled( 20, weight: .medium))
.foregroundStyle(icon == sf ? Tj.Palette.paper : Tj.Palette.ink)
.frame(maxWidth: .infinity, minHeight: 44)
.background(
@@ -239,7 +239,7 @@ struct CustomMetricEditor: View {
Image(systemName: "trash")
Text("删除这项自定义指标")
}
.font(.system(size: 13, weight: .semibold))
.font(.tjScaled( 13, weight: .semibold))
.foregroundStyle(Tj.Palette.brick)
.frame(maxWidth: .infinity)
.padding(.vertical, 12)
@@ -282,7 +282,7 @@ struct CustomMetricEditor: View {
.strokeBorder(Tj.Palette.line, lineWidth: 1)
}
private func sectionLabel(_ t: String) -> some View {
Text(t).font(.system(size: 12, weight: .semibold)).tracking(0.3)
Text(t).font(.tjScaled( 12, weight: .semibold)).tracking(0.3)
.foregroundStyle(Tj.Palette.text2)
}

View File

@@ -27,6 +27,10 @@ private let labPresets: [IndicatorPreset] = [
/// seriesKey, Trends
/// 3. **** name/value/unit/range ,status
struct IndicatorQuickSheet: View {
/// RootView : QuickRegionCaptureFlow(VL)
/// nil ( Preview)
var onRequestCamera: (() -> Void)? = nil
@Environment(\.modelContext) private var ctx
@Environment(\.dismiss) private var dismiss
@Query private var profiles: [UserProfile]
@@ -103,6 +107,7 @@ struct IndicatorQuickSheet: View {
ScrollView(showsIndicators: false) {
VStack(alignment: .leading, spacing: 20) {
cameraEntrySection
monitorGridSection
labPresetSection
Divider().padding(.vertical, 4)
@@ -161,13 +166,69 @@ struct IndicatorQuickSheet: View {
.foregroundStyle(Tj.Palette.text)
Spacer()
Text("本地处理 · 永不上传")
.font(.system(size: 12))
.font(.tjScaled( 12))
.foregroundStyle(Tj.Palette.text3)
}
.padding(.horizontal, 20)
.padding(.bottom, 16)
}
/// : RootView VL
@ViewBuilder
private var cameraEntrySection: some View {
if let onRequestCamera {
VStack(alignment: .leading, spacing: 10) {
Button {
onRequestCamera()
} label: {
HStack(spacing: 12) {
ZStack {
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
.fill(Tj.Palette.brick)
Image(systemName: "camera.fill")
.font(.tjScaled(18, weight: .medium))
.foregroundStyle(Tj.Palette.paper)
}
.frame(width: 44, height: 44)
VStack(alignment: .leading, spacing: 2) {
Text("拍照识别")
.font(.tjScaled(15, weight: .semibold))
.foregroundStyle(Tj.Palette.text)
Text("拍化验单,VL 自动读出数值")
.font(.tjScaled(12))
.foregroundStyle(Tj.Palette.text3)
}
Spacer()
Image(systemName: "chevron.right")
.font(.tjScaled(14, weight: .medium))
.foregroundStyle(Tj.Palette.text3)
}
.padding(14)
.frame(maxWidth: .infinity)
.tjCard(bordered: true)
}
.buttonStyle(.plain)
HStack(spacing: 8) {
line
Text("或手动填写")
.font(.tjScaled(11))
.foregroundStyle(Tj.Palette.text3)
.fixedSize()
line
}
}
}
}
private var line: some View {
Rectangle()
.fill(Tj.Palette.lineSoft)
.frame(height: 1)
.frame(maxWidth: .infinity)
}
private var monitorGridSection: some View {
VStack(alignment: .leading, spacing: 8) {
HStack {
@@ -217,18 +278,18 @@ struct IndicatorQuickSheet: View {
} label: {
HStack(spacing: 10) {
Image(systemName: cm.icon)
.font(.system(size: 18, weight: .medium))
.font(.tjScaled( 18, weight: .medium))
.foregroundStyle(selected ? Tj.Palette.paper : Tj.Palette.ink)
.frame(width: 32, height: 32)
.background(Circle().fill(selected ? Tj.Palette.ink : Tj.Palette.leafSoft))
VStack(alignment: .leading, spacing: 1) {
Text(cm.name)
.font(.system(size: 14, weight: selected ? .semibold : .medium))
.font(.tjScaled( 14, weight: selected ? .semibold : .medium))
.foregroundStyle(selected ? Tj.Palette.paper : Tj.Palette.text)
.lineLimit(1)
Text("自定义")
.font(.system(size: 9, design: .monospaced))
.font(.tjScaled( 9, design: .monospaced))
.foregroundStyle(selected ? Tj.Palette.paper.opacity(0.7) : Tj.Palette.text3)
}
Spacer()
@@ -260,14 +321,14 @@ struct IndicatorQuickSheet: View {
} label: {
HStack(spacing: 10) {
Image(systemName: "plus")
.font(.system(size: 18, weight: .semibold))
.font(.tjScaled( 18, weight: .semibold))
.foregroundStyle(Tj.Palette.text2)
.frame(width: 32, height: 32)
.background(
Circle().strokeBorder(Tj.Palette.line, lineWidth: 1, antialiased: true)
)
Text("自定义")
.font(.system(size: 14, weight: .medium))
.font(.tjScaled( 14, weight: .medium))
.foregroundStyle(Tj.Palette.text2)
Spacer()
}
@@ -293,13 +354,13 @@ struct IndicatorQuickSheet: View {
} label: {
HStack(spacing: 10) {
Image(systemName: m.icon)
.font(.system(size: 18, weight: .medium))
.font(.tjScaled( 18, weight: .medium))
.foregroundStyle(selected ? Tj.Palette.paper : Tj.Palette.ink)
.frame(width: 32, height: 32)
.background(Circle().fill(selected ? Tj.Palette.ink : Tj.Palette.amber.opacity(0.25)))
Text(m.displayName)
.font(.system(size: 14, weight: selected ? .semibold : .medium))
.font(.tjScaled( 14, weight: selected ? .semibold : .medium))
.foregroundStyle(selected ? Tj.Palette.paper : Tj.Palette.text)
Spacer()
}
@@ -348,7 +409,7 @@ struct IndicatorQuickSheet: View {
}
HStack(spacing: 12) {
bpField(label: String(appLoc: "收缩压"), value: $systolic, placeholder: "120")
Text("/").font(.system(size: 22, weight: .light)).foregroundStyle(Tj.Palette.text3)
Text("/").font(.tjScaled( 22, weight: .light)).foregroundStyle(Tj.Palette.text3)
bpField(label: String(appLoc: "舒张压"), value: $diastolic, placeholder: "80")
Text("mmHg").foregroundStyle(Tj.Palette.text3)
}
@@ -358,10 +419,10 @@ struct IndicatorQuickSheet: View {
private func bpField(label: String, value: Binding<String>, placeholder: String) -> some View {
VStack(alignment: .leading, spacing: 4) {
Text(label).font(.system(size: 11)).foregroundStyle(Tj.Palette.text3)
Text(label).font(.tjScaled( 11)).foregroundStyle(Tj.Palette.text3)
TextField(placeholder, text: value)
.keyboardType(.decimalPad)
.font(.system(size: 20, weight: .semibold, design: .monospaced))
.font(.tjScaled( 20, weight: .semibold, design: .monospaced))
.multilineTextAlignment(.center)
.padding(.vertical, 10)
.frame(width: 90)
@@ -380,11 +441,11 @@ struct IndicatorQuickSheet: View {
let rangeText = "\(formatRange(sysRange)) / \(formatRange(diasRange))"
return HStack(spacing: 4) {
Text(rangeText)
.font(.system(size: 11, design: .monospaced))
.font(.tjScaled( 11, design: .monospaced))
.foregroundStyle(Tj.Palette.text3)
if personalized, let age = profile?.age {
Text("· 按\(age)岁调整")
.font(.system(size: 10))
.font(.tjScaled( 10))
.foregroundStyle(Tj.Palette.amber)
}
}
@@ -427,7 +488,7 @@ struct IndicatorQuickSheet: View {
sectionLabel(String(appLoc: "数值"))
TextField(monitorFieldPlaceholder, text: $value)
.keyboardType(.decimalPad)
.font(.system(size: 18, weight: .semibold, design: .monospaced))
.font(.tjScaled( 18, weight: .semibold, design: .monospaced))
.padding(.horizontal, 14)
.padding(.vertical, 12)
.background(fieldBg)
@@ -475,7 +536,7 @@ struct IndicatorQuickSheet: View {
return HStack(spacing: 4) {
if personalized, let age = profile?.age {
Text("\(age)岁调整")
.font(.system(size: 10))
.font(.tjScaled( 10))
.foregroundStyle(Tj.Palette.amber)
}
}
@@ -500,7 +561,7 @@ struct IndicatorQuickSheet: View {
statusBadge(s.label, color: s.color)
} else {
Text("待输入")
.font(.system(size: 12))
.font(.tjScaled( 12))
.foregroundStyle(Tj.Palette.text3)
}
}
@@ -546,7 +607,7 @@ struct IndicatorQuickSheet: View {
VStack(alignment: .leading, spacing: 12) {
HStack {
Text("时间")
.font(.system(size: 13))
.font(.tjScaled( 13))
.foregroundStyle(Tj.Palette.text2)
Spacer()
DatePicker("", selection: $reminderTime,
@@ -558,11 +619,11 @@ struct IndicatorQuickSheet: View {
VStack(alignment: .leading, spacing: 6) {
HStack {
Text("频率")
.font(.system(size: 13))
.font(.tjScaled( 13))
.foregroundStyle(Tj.Palette.text2)
Spacer()
Text(reminderFrequencyLabel)
.font(.system(size: 12))
.font(.tjScaled( 12))
.foregroundStyle(Tj.Palette.text3)
}
weekdayPickerRow
@@ -581,11 +642,11 @@ struct IndicatorQuickSheet: View {
if notifAuthBlocked {
Text("⚠️ 通知权限已关闭,去「设置 → 康康 → 通知」打开")
.font(.system(size: 11))
.font(.tjScaled( 11))
.foregroundStyle(Tj.Palette.brick)
} else {
Text("本机提醒 · 不发任何数据")
.font(.system(size: 11))
.font(.tjScaled( 11))
.foregroundStyle(Tj.Palette.text3)
}
}
@@ -625,7 +686,7 @@ struct IndicatorQuickSheet: View {
}
} label: {
Text(names[idx])
.font(.system(size: 13,
.font(.tjScaled( 13,
weight: reminderWeekdays.contains(w) ? .semibold : .regular))
.foregroundStyle(reminderWeekdays.contains(w) ? Tj.Palette.paper : Tj.Palette.text)
.frame(maxWidth: .infinity, minHeight: 32)
@@ -647,7 +708,7 @@ struct IndicatorQuickSheet: View {
private func quickFreqChip(_ label: String, action: @escaping () -> Void) -> some View {
Button(action: action) {
Text(label)
.font(.system(size: 11))
.font(.tjScaled( 11))
.foregroundStyle(Tj.Palette.text2)
.padding(.horizontal, 10)
.padding(.vertical, 4)
@@ -755,7 +816,7 @@ struct IndicatorQuickSheet: View {
private func sectionLabel(_ text: String) -> some View {
Text(text)
.font(.system(size: 12, weight: .semibold))
.font(.tjScaled( 12, weight: .semibold))
.tracking(0.3)
.foregroundStyle(Tj.Palette.text2)
}
@@ -763,7 +824,7 @@ struct IndicatorQuickSheet: View {
private func chip(_ label: String, selected: Bool, action: @escaping () -> Void) -> some View {
Button(action: action) {
Text(label)
.font(.system(size: 13, weight: selected ? .semibold : .regular))
.font(.tjScaled( 13, weight: selected ? .semibold : .regular))
.foregroundStyle(selected ? Tj.Palette.paper : Tj.Palette.text)
.padding(.horizontal, 14)
.padding(.vertical, 8)
@@ -779,7 +840,7 @@ struct IndicatorQuickSheet: View {
manualStatus = value
} label: {
Text(label)
.font(.system(size: 13, weight: selected ? .semibold : .regular))
.font(.tjScaled( 13, weight: selected ? .semibold : .regular))
.foregroundStyle(selected ? Tj.Palette.paper : color)
.padding(.horizontal, 14)
.padding(.vertical, 8)
@@ -792,7 +853,7 @@ struct IndicatorQuickSheet: View {
private func statusBadge(_ label: String, color: Color) -> some View {
Text(label)
.font(.system(size: 11, weight: .semibold))
.font(.tjScaled( 11, weight: .semibold))
.foregroundStyle(color)
.padding(.horizontal, 10)
.padding(.vertical, 4)
@@ -832,9 +893,9 @@ struct IndicatorQuickSheet: View {
} label: {
HStack(spacing: 3) {
Text("已隐藏 \(hiddenSet.count)")
.font(.system(size: 11, weight: .medium))
.font(.tjScaled( 11, weight: .medium))
Image(systemName: "chevron.right")
.font(.system(size: 9, weight: .semibold))
.font(.tjScaled( 9, weight: .semibold))
}
.foregroundStyle(Tj.Palette.text2)
.padding(.horizontal, 10)
@@ -1121,7 +1182,7 @@ private struct HiddenMonitorRestoreSheet: View {
.foregroundStyle(Tj.Palette.text)
Spacer()
Button("完成") { dismiss() }
.font(.system(size: 14))
.font(.tjScaled( 14))
.foregroundStyle(Tj.Palette.ink)
}
.padding(.horizontal, 20)
@@ -1146,13 +1207,13 @@ private struct HiddenMonitorRestoreSheet: View {
private func row(_ m: MonitorMetric) -> some View {
HStack(spacing: 12) {
Image(systemName: m.icon)
.font(.system(size: 16, weight: .medium))
.font(.tjScaled( 16, weight: .medium))
.foregroundStyle(Tj.Palette.ink)
.frame(width: 32, height: 32)
.background(Circle().fill(Tj.Palette.amber.opacity(0.25)))
Text(m.displayName)
.font(.system(size: 15, weight: .medium))
.font(.tjScaled( 15, weight: .medium))
.foregroundStyle(Tj.Palette.text)
Spacer()
@@ -1160,7 +1221,7 @@ private struct HiddenMonitorRestoreSheet: View {
Button("显示") {
onRestore(m)
}
.font(.system(size: 13, weight: .semibold))
.font(.tjScaled( 13, weight: .semibold))
.foregroundStyle(Tj.Palette.paper)
.padding(.horizontal, 14)
.padding(.vertical, 6)

View File

@@ -70,12 +70,12 @@ struct AboutView: View {
}
Text("康康 · 本地优先的健康档案 · \(versionText)")
.font(.system(size: 12))
.font(.tjScaled( 12))
.foregroundStyle(Tj.Palette.text3)
.padding(.top, 4)
Text("本 App 仅供健康信息记录与参考,不能替代专业医疗意见。")
.font(.system(size: 11))
.font(.tjScaled( 11))
.foregroundStyle(Tj.Palette.text3)
.multilineTextAlignment(.center)
.fixedSize(horizontal: false, vertical: true)
@@ -98,7 +98,7 @@ struct AboutView: View {
RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)
.fill(Tj.Palette.sand2)
Image(systemName: "heart.text.square.fill")
.font(.system(size: 34))
.font(.tjScaled( 34))
.foregroundStyle(Tj.Palette.brick)
}
.frame(width: 72, height: 72)
@@ -108,7 +108,7 @@ struct AboutView: View {
.foregroundStyle(Tj.Palette.text)
Text("本地优先的个人健康随记")
.font(.system(size: 13))
.font(.tjScaled( 13))
.foregroundStyle(Tj.Palette.text2)
Text(versionText)
@@ -133,10 +133,10 @@ struct AboutView: View {
VStack(alignment: .leading, spacing: 10) {
HStack(spacing: 8) {
Image(systemName: icon)
.font(.system(size: 15, weight: .semibold))
.font(.tjScaled( 15, weight: .semibold))
.foregroundStyle(tint)
Text(title)
.font(.system(size: 16, weight: .semibold))
.font(.tjScaled( 16, weight: .semibold))
.foregroundStyle(Tj.Palette.text)
}
content()
@@ -148,7 +148,7 @@ struct AboutView: View {
@ViewBuilder private func paragraph(_ text: String) -> some View {
Text(text)
.font(.system(size: 14))
.font(.tjScaled( 14))
.foregroundStyle(Tj.Palette.text2)
.lineSpacing(5)
.fixedSize(horizontal: false, vertical: true)
@@ -161,7 +161,7 @@ struct AboutView: View {
.frame(width: 5, height: 5)
.padding(.top, 7)
Text(text)
.font(.system(size: 14))
.font(.tjScaled( 14))
.foregroundStyle(Tj.Palette.text2)
.lineSpacing(5)
.fixedSize(horizontal: false, vertical: true)

View File

@@ -41,7 +41,7 @@ struct CustomMetricsListView: View {
editingTarget = CustomMetricEditTarget(metric: nil)
} label: {
Image(systemName: "plus")
.font(.system(size: 16, weight: .semibold))
.font(.tjScaled( 16, weight: .semibold))
}
}
}
@@ -57,7 +57,7 @@ struct CustomMetricsListView: View {
Image(systemName: "info.circle.fill")
.foregroundStyle(Tj.Palette.text3)
Text("自定义指标会出现在「+ 指标记录 → 长期监测」的 grid 里,可设提醒、进趋势")
.font(.system(size: 12))
.font(.tjScaled( 12))
.foregroundStyle(Tj.Palette.text2)
.fixedSize(horizontal: false, vertical: true)
Spacer(minLength: 0)
@@ -75,7 +75,7 @@ struct CustomMetricsListView: View {
TjPlaceholder(label: String(appLoc: "还没有自定义指标"))
.frame(width: 220, height: 130)
Text("右上角 + 新建一个")
.font(.system(size: 12))
.font(.tjScaled( 12))
.foregroundStyle(Tj.Palette.text3)
Spacer()
}
@@ -88,28 +88,28 @@ struct CustomMetricsListView: View {
ZStack {
Circle().fill(Tj.Palette.leafSoft)
Image(systemName: m.icon)
.font(.system(size: 17, weight: .medium))
.font(.tjScaled( 17, weight: .medium))
.foregroundStyle(Tj.Palette.ink)
}
.frame(width: 40, height: 40)
VStack(alignment: .leading, spacing: 3) {
Text(m.name)
.font(.system(size: 15, weight: .semibold))
.font(.tjScaled( 15, weight: .semibold))
.foregroundStyle(Tj.Palette.text)
.lineLimit(1)
HStack(spacing: 6) {
if !m.unit.isEmpty {
Text(m.unit)
.font(.system(size: 11, design: .monospaced))
.font(.tjScaled( 11, design: .monospaced))
.foregroundStyle(Tj.Palette.text3)
}
if !m.rangeText.isEmpty {
Text("·")
.font(.system(size: 11))
.font(.tjScaled( 11))
.foregroundStyle(Tj.Palette.text3)
Text(m.rangeText)
.font(.system(size: 11, design: .monospaced))
.font(.tjScaled( 11, design: .monospaced))
.foregroundStyle(Tj.Palette.text3)
}
}
@@ -119,10 +119,10 @@ struct CustomMetricsListView: View {
VStack(alignment: .trailing, spacing: 2) {
Text(count == 0 ? String(appLoc: "未使用") : String(appLoc: "\(count)"))
.font(.system(size: 11, weight: count > 0 ? .semibold : .regular))
.font(.tjScaled( 11, weight: count > 0 ? .semibold : .regular))
.foregroundStyle(count > 0 ? Tj.Palette.ink : Tj.Palette.text3)
Image(systemName: "chevron.right")
.font(.system(size: 11, weight: .medium))
.font(.tjScaled( 11, weight: .medium))
.foregroundStyle(Tj.Palette.text3)
}
}

View File

@@ -142,7 +142,7 @@ struct CustomReminderEditSheet: View {
private var skipHint: some View {
Text(String(appLoc: "部分月份无此日,该月将跳过"))
.font(.system(size: 11))
.font(.tjScaled( 11))
.foregroundStyle(Tj.Palette.text3)
}
@@ -169,7 +169,7 @@ struct CustomReminderEditSheet: View {
second: 0, of: pickedTime) ?? pickedTime
} label: {
Text(String(format: "%d:%02d", preset.h, preset.m))
.font(.system(size: 13, weight: on ? .semibold : .regular))
.font(.tjScaled( 13, weight: on ? .semibold : .regular))
.foregroundStyle(on ? Tj.Palette.paper : Tj.Palette.text)
.frame(maxWidth: .infinity, minHeight: 30)
.background(
@@ -203,7 +203,7 @@ struct CustomReminderEditSheet: View {
if on { weekdays.remove(w) } else { weekdays.insert(w) }
} label: {
Text(names[idx])
.font(.system(size: 13, weight: on ? .semibold : .regular))
.font(.tjScaled( 13, weight: on ? .semibold : .regular))
.foregroundStyle(on ? Tj.Palette.paper : Tj.Palette.text)
.frame(maxWidth: .infinity, minHeight: 30)
.background(

View File

@@ -0,0 +1,81 @@
import SwiftUI
/// · / : App
/// (,,)
/// ,便
struct FontSettingsView: View {
@State private var manager = FontScaleManager.shared
/// (,,)
private let sampleBase: CGFloat = 17
var body: some View {
ScrollView {
VStack(spacing: 10) {
ForEach(FontScale.allCases) { option in
row(option)
}
Text("放大后整个 App 的文字立即变大,无需重启。设置会被记住。")
.font(.tjScaled(12))
.foregroundStyle(Tj.Palette.text3)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 4)
.padding(.top, 6)
}
.padding(.horizontal, 16)
.padding(.vertical, 20)
}
.background(Tj.Palette.sand.ignoresSafeArea())
.navigationTitle("字体大小")
.navigationBarTitleDisplayMode(.inline)
}
private func row(_ option: FontScale) -> some View {
let selected = manager.scale == option
return Button {
manager.set(option)
} label: {
HStack(spacing: 14) {
VStack(alignment: .leading, spacing: 4) {
HStack(spacing: 8) {
Text(option.label)
.font(.system(size: 15, weight: selected ? .semibold : .regular))
.foregroundStyle(Tj.Palette.text)
Text(option.detail)
.font(.system(size: 11))
.foregroundStyle(Tj.Palette.text3)
}
// :,
Text("健康档案 Aa 123")
.font(.system(size: sampleBase * option.multiplier, weight: .medium))
.foregroundStyle(Tj.Palette.text2)
.lineLimit(1)
.minimumScaleFactor(0.5)
}
Spacer(minLength: 8)
ZStack {
Circle()
.strokeBorder(selected ? Tj.Palette.ink : Tj.Palette.line, lineWidth: selected ? 0 : 1.5)
.background(Circle().fill(selected ? Tj.Palette.ink : Color.clear))
.frame(width: 24, height: 24)
if selected {
Image(systemName: "checkmark")
.font(.system(size: 12, weight: .bold))
.foregroundStyle(Tj.Palette.paper)
}
}
}
.padding(14)
.frame(maxWidth: .infinity, alignment: .leading)
.tjCard()
}
.buttonStyle(.plain)
}
}
#Preview {
NavigationStack { FontSettingsView() }
}

View File

@@ -12,7 +12,7 @@ struct LanguageSettingsView: View {
}
Text("切换后整个 App 立即生效,无需重启。")
.font(.system(size: 12))
.font(.tjScaled( 12))
.foregroundStyle(Tj.Palette.text3)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 4)
@@ -40,14 +40,14 @@ struct LanguageSettingsView: View {
.frame(width: 40, height: 40)
Text(option.displayName)
.font(.system(size: 15, weight: selected ? .semibold : .regular))
.font(.tjScaled( 15, weight: selected ? .semibold : .regular))
.foregroundStyle(Tj.Palette.text)
Spacer()
if selected {
Image(systemName: "checkmark")
.font(.system(size: 14, weight: .semibold))
.font(.tjScaled( 14, weight: .semibold))
.foregroundStyle(Tj.Palette.ink)
}
}
@@ -64,11 +64,11 @@ struct LanguageSettingsView: View {
switch option.pickerIcon {
case .symbol(let name):
Image(systemName: name)
.font(.system(size: 16))
.font(.tjScaled( 16))
.foregroundStyle(fg)
case .glyph(let g):
Text(verbatim: g)
.font(.system(size: 17, weight: .semibold))
.font(.tjScaled( 17, weight: .semibold))
.foregroundStyle(fg)
}
}

View File

@@ -9,6 +9,7 @@ struct MeView: View {
@State private var downloadService = ModelDownloadService.shared
@State private var appLock = AppLock.shared
@State private var lang = LanguageManager.shared
@State private var fontScale = FontScaleManager.shared
// key AppLock.enabledKey
@AppStorage("faceIDLockEnabled") private var lockEnabled = false
@@ -37,6 +38,7 @@ struct MeView: View {
customMetricsCard
modelManagementCard
languageCard
fontScaleCard
faceIDCard
NavigationLink {
AboutView()
@@ -74,23 +76,23 @@ struct MeView: View {
Circle()
.fill(Tj.Palette.amber.opacity(0.25))
Image(systemName: "person.crop.circle.fill")
.font(.system(size: 22))
.font(.tjScaled( 22))
.foregroundStyle(Tj.Palette.ink)
}
.frame(width: 44, height: 44)
VStack(alignment: .leading, spacing: 2) {
Text("个人资料")
.font(.system(size: 15, weight: .semibold))
.font(.tjScaled( 15, weight: .semibold))
.foregroundStyle(Tj.Palette.text)
Text(profileLine)
.font(.system(size: 12))
.font(.tjScaled( 12))
.foregroundStyle(Tj.Palette.text3)
.lineLimit(1)
}
Spacer()
Image(systemName: "chevron.right")
.font(.system(size: 13, weight: .medium))
.font(.tjScaled( 13, weight: .medium))
.foregroundStyle(Tj.Palette.text3)
}
.padding(14)
@@ -108,23 +110,23 @@ struct MeView: View {
Circle()
.fill(customMetrics.isEmpty ? Tj.Palette.sand2 : Tj.Palette.leafSoft)
Image(systemName: "slider.horizontal.3")
.font(.system(size: 18))
.font(.tjScaled( 18))
.foregroundStyle(customMetrics.isEmpty ? Tj.Palette.text2 : Tj.Palette.ink)
}
.frame(width: 44, height: 44)
VStack(alignment: .leading, spacing: 2) {
Text("自定义指标")
.font(.system(size: 15, weight: .semibold))
.font(.tjScaled( 15, weight: .semibold))
.foregroundStyle(Tj.Palette.text)
Text(customMetricsLine)
.font(.system(size: 12))
.font(.tjScaled( 12))
.foregroundStyle(Tj.Palette.text3)
.lineLimit(1)
}
Spacer()
Image(systemName: "chevron.right")
.font(.system(size: 13, weight: .medium))
.font(.tjScaled( 13, weight: .medium))
.foregroundStyle(Tj.Palette.text3)
}
.padding(14)
@@ -166,6 +168,17 @@ struct MeView: View {
.buttonStyle(.plain)
}
private var fontScaleCard: some View {
NavigationLink {
FontSettingsView()
} label: {
settingsCard(title: String(appLoc: "字体大小"),
detail: fontScale.scale.label,
icon: "textformat.size")
}
.buttonStyle(.plain)
}
// MARK: - Face ID ( Toggle )
private var faceIDCard: some View {
@@ -173,17 +186,17 @@ struct MeView: View {
ZStack {
Circle().fill(lockEnabled ? Tj.Palette.amber.opacity(0.25) : Tj.Palette.sand2)
Image(systemName: "faceid")
.font(.system(size: 18))
.font(.tjScaled( 18))
.foregroundStyle(lockEnabled ? Tj.Palette.ink : Tj.Palette.text2)
}
.frame(width: 44, height: 44)
VStack(alignment: .leading, spacing: 2) {
Text("Face ID 启动锁")
.font(.system(size: 15, weight: .medium))
.font(.tjScaled( 15, weight: .medium))
.foregroundStyle(Tj.Palette.text)
Text(faceIDLine)
.font(.system(size: 12))
.font(.tjScaled( 12))
.foregroundStyle(Tj.Palette.text3)
}
Spacer()
@@ -219,20 +232,20 @@ struct MeView: View {
ZStack {
Circle().fill(Tj.Palette.sand2)
Image(systemName: icon)
.font(.system(size: 18))
.font(.tjScaled( 18))
.foregroundStyle(Tj.Palette.text2)
}
.frame(width: 44, height: 44)
Text(title)
.font(.system(size: 15, weight: .medium))
.font(.tjScaled( 15, weight: .medium))
.foregroundStyle(Tj.Palette.text)
Spacer()
Text(detail)
.font(.system(size: 12))
.font(.tjScaled( 12))
.foregroundStyle(Tj.Palette.text3)
Image(systemName: "chevron.right")
.font(.system(size: 13, weight: .medium))
.font(.tjScaled( 13, weight: .medium))
.foregroundStyle(Tj.Palette.text3)
}
.padding(14)

View File

@@ -43,7 +43,7 @@ struct ModelManagementView: View {
if let importError {
Text(importError)
.font(.system(size: 12))
.font(.tjScaled( 12))
.foregroundStyle(Tj.Palette.brick)
.frame(maxWidth: .infinity, alignment: .leading)
}
@@ -86,10 +86,10 @@ struct ModelManagementView: View {
HStack(alignment: .top) {
VStack(alignment: .leading, spacing: 3) {
Text(kind.displayName)
.font(.system(size: 15, weight: .semibold))
.font(.tjScaled( 15, weight: .semibold))
.foregroundStyle(Tj.Palette.text)
Text(subtitle(kind))
.font(.system(size: 12))
.font(.tjScaled( 12))
.foregroundStyle(Tj.Palette.text3)
}
Spacer()
@@ -104,17 +104,17 @@ struct ModelManagementView: View {
Spacer()
Text(speedText(state))
}
.font(.system(size: 11, design: .monospaced))
.font(.tjScaled( 11, design: .monospaced))
.foregroundStyle(Tj.Palette.text3)
} else {
HStack {
Text(formatBytes(ModelManifest.totalBytes(for: kind)))
.font(.system(size: 11, design: .monospaced))
.font(.tjScaled( 11, design: .monospaced))
.foregroundStyle(Tj.Palette.text3)
Spacer()
if case .failed(let message) = state.phase {
Text(message)
.font(.system(size: 11))
.font(.tjScaled( 11))
.foregroundStyle(Tj.Palette.brick)
.lineLimit(1)
}
@@ -156,7 +156,7 @@ struct ModelManagementView: View {
Image(systemName: "checkmark.seal.fill")
Text("两个模型都已就绪")
}
.font(.system(size: 13, weight: .semibold))
.font(.tjScaled( 13, weight: .semibold))
.foregroundStyle(Tj.Palette.leaf)
.frame(maxWidth: .infinity)
.padding(.vertical, 6)
@@ -183,7 +183,7 @@ struct ModelManagementView: View {
VStack(spacing: 8) {
TjLockChip()
Text("100% 本地推理 · 模型仅需下载一次")
.font(.system(size: 11))
.font(.tjScaled( 11))
.foregroundStyle(Tj.Palette.text3)
}
.frame(maxWidth: .infinity)

View File

@@ -37,11 +37,11 @@ struct ModelSelfTestView: View {
VStack(alignment: .leading, spacing: 16) {
VStack(alignment: .leading, spacing: 6) {
Text("测试 PROMPT")
.font(.system(size: 11, weight: .semibold))
.font(.tjScaled( 11, weight: .semibold))
.tracking(0.5)
.foregroundStyle(Tj.Palette.text3)
Text(prompt)
.font(.system(size: 14))
.font(.tjScaled( 14))
.foregroundStyle(Tj.Palette.text)
}
.padding(14)
@@ -50,13 +50,13 @@ struct ModelSelfTestView: View {
HStack {
Text(phase.label)
.font(.system(size: 13, weight: .medium))
.font(.tjScaled( 13, weight: .medium))
.foregroundStyle(statusColor)
.lineLimit(1)
Spacer()
if rate > 0 {
Text(String(format: "%.1f tok/s", rate))
.font(.system(size: 12, design: .monospaced))
.font(.tjScaled( 12, design: .monospaced))
.foregroundStyle(Tj.Palette.text3)
}
}

View File

@@ -74,7 +74,7 @@ struct RemindersListView: View {
private var header: some View {
Text("新建提醒,或在记录指标时开启")
.font(.system(size: 12))
.font(.tjScaled( 12))
.foregroundStyle(Tj.Palette.text3)
.frame(maxWidth: .infinity, alignment: .leading)
}
@@ -89,7 +89,7 @@ struct RemindersListView: View {
private func sectionLabel(_ text: String) -> some View {
Text(text)
.font(.system(size: 12, weight: .semibold))
.font(.tjScaled( 12, weight: .semibold))
.foregroundStyle(Tj.Palette.text3)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.top, 8)
@@ -146,18 +146,18 @@ private struct CustomReminderRow: View {
Circle()
.fill(reminder.enabled ? Tj.Palette.amber.opacity(0.25) : Tj.Palette.sand2)
Image(systemName: "bell.fill")
.font(.system(size: 16))
.font(.tjScaled( 16))
.foregroundStyle(reminder.enabled ? Tj.Palette.ink : Tj.Palette.text3)
}
.frame(width: 36, height: 36)
VStack(alignment: .leading, spacing: 2) {
Text(reminder.title)
.font(.system(size: 15, weight: .semibold))
.font(.tjScaled( 15, weight: .semibold))
.foregroundStyle(Tj.Palette.text)
.lineLimit(1)
Text("\(reminder.timeLabel) · \(reminder.frequencyLabel)")
.font(.system(size: 12))
.font(.tjScaled( 12))
.foregroundStyle(Tj.Palette.text3)
}
Spacer(minLength: 0)
@@ -173,7 +173,7 @@ private struct CustomReminderRow: View {
// 28×28 , Toggle
Image(systemName: "chevron.right")
.font(.system(size: 12, weight: .semibold))
.font(.tjScaled( 12, weight: .semibold))
.foregroundStyle(Tj.Palette.text3)
.frame(width: 28, height: 28)
}
@@ -223,17 +223,17 @@ private struct ReminderRow: View {
Circle()
.fill(reminder.enabled ? Tj.Palette.amber.opacity(0.25) : Tj.Palette.sand2)
Image(systemName: "bell.fill")
.font(.system(size: 16))
.font(.tjScaled( 16))
.foregroundStyle(reminder.enabled ? Tj.Palette.ink : Tj.Palette.text3)
}
.frame(width: 36, height: 36)
VStack(alignment: .leading, spacing: 2) {
Text(reminder.displayName)
.font(.system(size: 15, weight: .semibold))
.font(.tjScaled( 15, weight: .semibold))
.foregroundStyle(Tj.Palette.text)
Text("\(reminder.timeLabel) · \(reminder.frequencyLabel)")
.font(.system(size: 12))
.font(.tjScaled( 12))
.foregroundStyle(Tj.Palette.text3)
}
@@ -248,7 +248,7 @@ private struct ReminderRow: View {
onTapEdit()
} label: {
Image(systemName: isEditing ? "chevron.up" : "chevron.down")
.font(.system(size: 12, weight: .semibold))
.font(.tjScaled( 12, weight: .semibold))
.foregroundStyle(Tj.Palette.text3)
.frame(width: 28, height: 28)
}
@@ -259,7 +259,7 @@ private struct ReminderRow: View {
private var editingPanel: some View {
VStack(alignment: .leading, spacing: 12) {
HStack {
Text("时间").font(.system(size: 13)).foregroundStyle(Tj.Palette.text2)
Text("时间").font(.tjScaled( 13)).foregroundStyle(Tj.Palette.text2)
Spacer()
DatePicker("", selection: $pickedTime, displayedComponents: .hourAndMinute)
.datePickerStyle(.compact)
@@ -278,7 +278,7 @@ private struct ReminderRow: View {
onDelete()
} label: {
Label("删除提醒", systemImage: "trash")
.font(.system(size: 12, weight: .semibold))
.font(.tjScaled( 12, weight: .semibold))
.foregroundStyle(Tj.Palette.brick)
}
.buttonStyle(.plain)
@@ -310,7 +310,7 @@ private struct ReminderRow: View {
onChange()
} label: {
Text(names[idx])
.font(.system(size: 13,
.font(.tjScaled( 13,
weight: reminder.weekdays.contains(w) ? .semibold : .regular))
.foregroundStyle(reminder.weekdays.contains(w) ? Tj.Palette.paper : Tj.Palette.text)
.frame(maxWidth: .infinity, minHeight: 30)

View File

@@ -35,9 +35,40 @@ struct ProfileEditView: View {
private struct ProfileEditForm: View {
@Environment(\.modelContext) private var ctx
@Bindable var profile: UserProfile
@State private var healthImportDraft: HealthProfileImportDraft?
@State private var healthImportError: String?
@State private var isImportingHealthProfile = false
var body: some View {
Form {
Section {
Button {
importHealthProfile()
} label: {
HStack(spacing: 10) {
if isImportingHealthProfile {
ProgressView()
} else {
Image(systemName: "heart.text.square")
.foregroundStyle(Tj.Palette.ink)
}
VStack(alignment: .leading, spacing: 2) {
Text("从 Apple 健康导入")
.foregroundStyle(Tj.Palette.text)
Text("只读取生日、性别、身高、血型")
.font(.tjScaled( 12))
.foregroundStyle(Tj.Palette.text3)
}
}
}
.disabled(isImportingHealthProfile)
.accessibilityElement(children: .combine)
.accessibilityLabel("从 Apple 健康导入")
.accessibilityHint("读取生日、性别、身高和血型,确认后填入个人资料")
} footer: {
Text("导入前会先显示预览,确认后才覆盖个人资料。")
}
Section {
BirthYearRow(profile: profile)
SexRow(profile: profile)
@@ -67,6 +98,90 @@ private struct ProfileEditForm: View {
profile.updatedAt = .now
try? ctx.save()
}
.sheet(item: $healthImportDraft) { draft in
HealthProfileImportPreviewSheet(
draft: draft,
profile: profile
) {
draft.apply(to: profile)
try? ctx.save()
healthImportDraft = nil
}
}
.alert("无法导入 Apple 健康资料", isPresented: Binding(
get: { healthImportError != nil },
set: { if !$0 { healthImportError = nil } }
)) {
Button("", role: .cancel) { healthImportError = nil }
} message: {
Text(healthImportError ?? "")
}
}
private func importHealthProfile() {
guard !isImportingHealthProfile else { return }
isImportingHealthProfile = true
healthImportError = nil
Task {
do {
healthImportDraft = try await HealthProfileImportService.shared.fetchDraft()
} catch {
healthImportError = error.localizedDescription
}
isImportingHealthProfile = false
}
}
}
private struct HealthProfileImportPreviewSheet: View {
@Environment(\.dismiss) private var dismiss
let draft: HealthProfileImportDraft
let profile: UserProfile
let onApply: () -> Void
private var preview: HealthProfileImportPreview {
HealthProfileImportPreview(draft: draft, current: profile)
}
var body: some View {
NavigationStack {
List {
Section {
ForEach(preview.fields, id: \.title) { field in
HStack(alignment: .firstTextBaseline) {
Text(field.title)
.foregroundStyle(Tj.Palette.text)
Spacer(minLength: 12)
VStack(alignment: .trailing, spacing: 4) {
Text(field.imported ?? "未读取到")
.foregroundStyle(field.imported == nil ? Tj.Palette.text3 : Tj.Palette.text)
Text("当前: \(field.current)")
.font(.tjScaled( 11))
.foregroundStyle(Tj.Palette.text3)
}
}
}
} footer: {
Text("未读取到的字段不会修改。")
}
}
.navigationTitle("确认导入")
.navigationBarTitleDisplayMode(.inline)
.scrollContentBackground(.hidden)
.background(Tj.Palette.sand.ignoresSafeArea())
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("取消") { dismiss() }
}
ToolbarItem(placement: .confirmationAction) {
Button("导入") {
onApply()
dismiss()
}
.fontWeight(.semibold)
}
}
}
}
}
@@ -112,7 +227,7 @@ private struct BirthYearRow: View {
Text(selectedLabel)
.foregroundStyle(profile.birthYear == nil ? Tj.Palette.text3 : Tj.Palette.text2)
Image(systemName: "chevron.right")
.font(.system(size: 12, weight: .semibold))
.font(.tjScaled( 12, weight: .semibold))
.foregroundStyle(Tj.Palette.text3)
.rotationEffect(.degrees(expanded ? 90 : 0))
}
@@ -212,7 +327,7 @@ private struct BMIFooter: View {
var body: some View {
if let bmi = profile.bmi {
Text("BMI: \(String(format: "%.1f", bmi)) \(label(bmi))")
.font(.system(size: 11))
.font(.tjScaled( 11))
}
}
@@ -282,7 +397,7 @@ private struct ChronicSection: View {
private func chip(label: String, selected: Bool, action: @escaping () -> Void) -> some View {
Button(action: action) {
Text(label)
.font(.system(size: 13, weight: selected ? .semibold : .regular))
.font(.tjScaled( 13, weight: selected ? .semibold : .regular))
.foregroundStyle(selected ? Tj.Palette.paper : Tj.Palette.text)
.padding(.horizontal, 12)
.padding(.vertical, 6)

View File

@@ -21,7 +21,8 @@ struct QuickRegionCaptureFlow: View {
@State private var analyzeTask: Task<Void, Never>? = nil
/// VL (); cancel ,UI
private let analyzeTimeoutSeconds: Int = 30
/// token ,30s , 60s
private let analyzeTimeoutSeconds: Int = 60
enum Phase {
case idle
@@ -86,23 +87,42 @@ struct QuickRegionCaptureFlow: View {
}
}
// MARK: - :()/ ()
// MARK: - :()/ ()
// RegionCameraView ( 1-2 ); ·
// , ,VL VisionKit :
// + ,VL / 退
@ViewBuilder
private var captureEntry: some View {
#if targetEnvironment(simulator)
PhotoPickerSheet(
onFinish: { imgs in if let first = imgs.first { startAnalyze(image: first) } },
onFinish: { imgs in handleScanned(imgs) },
onCancel: onClose
)
#else
RegionCameraView(
onCapture: { startAnalyze(image: $0) },
onCancel: onClose
)
if DocumentScannerView.isSupported {
DocumentScannerView(
onFinish: { imgs in handleScanned(imgs) },
onCancel: onClose
)
} else {
PhotoPickerSheet(
onFinish: { imgs in handleScanned(imgs) },
onCancel: onClose
)
}
#endif
}
/// /:();
private func handleScanned(_ images: [UIImage]) {
if let first = images.first {
startAnalyze(image: first)
} else {
onClose()
}
}
// MARK: -
private func startAnalyze(image: UIImage) {
@@ -110,12 +130,9 @@ struct QuickRegionCaptureFlow: View {
phase = .analyzing(image: image)
let timeout = analyzeTimeoutSeconds
// MainActor ,Task{} , phase 线,
// :Vision OCR Qwen3-1.7B LLM ( 3B VL )
analyzeTask = Task {
guard let data = image.jpegData(compressionQuality: 0.9) else {
phase = .confirm(image: image, items: [],
warning: String(appLoc: "图片编码失败,手动补充或重拍"))
return
}
let timeoutWarn = String(appLoc: "识别超时(>\(timeout)s),手动补充或重拍")
let watchdog = Task {
try? await Task.sleep(for: .seconds(timeout))
@@ -124,12 +141,25 @@ struct QuickRegionCaptureFlow: View {
defer { watchdog.cancel() }
do {
let parsed = try await CaptureService.shared.recognizeRegion(imageData: data)
// 1. OCR
let text = try await OCRService.recognizeText(in: image)
if Task.isCancelled {
phase = .confirm(image: image, items: [], warning: timeoutWarn); return
}
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
#if DEBUG
print("🔤 [OCR] recognized text:\n\(trimmed)\n--- end OCR ---")
#endif
if trimmed.isEmpty {
phase = .confirm(image: image, items: [],
warning: String(appLoc: "识别超时(>\(timeout)s),手动补充或重拍"))
warning: String(appLoc: "识别到文字,手动补充或重拍"))
return
}
// 2. LLM
let parsed = try await CaptureService.shared.recognizeIndicators(fromOCRText: trimmed)
if Task.isCancelled {
phase = .confirm(image: image, items: [], warning: timeoutWarn); return
}
let items = Self.buildItems(from: parsed)
phase = .confirm(
image: image,
@@ -138,23 +168,23 @@ struct QuickRegionCaptureFlow: View {
)
} catch CaptureError.modelNotReady {
phase = .confirm(image: image, items: [],
warning: String(appLoc: "VL 模型未就绪,手动补充"))
warning: String(appLoc: "AI 模型未就绪,手动补充"))
} catch let CaptureError.parseFailed(msg) {
phase = .confirm(image: image, items: [],
warning: String(appLoc: "VL 输出无法解析:\(msg)"))
warning: String(appLoc: "解析失败:\(msg)"))
} catch let CaptureError.inferenceFailed(msg) {
phase = .confirm(image: image, items: [],
warning: Task.isCancelled
? String(appLoc: "识别超时(>\(timeout)s),手动补充或重拍")
: String(appLoc: "推理失败:\(msg)"))
warning: Task.isCancelled ? timeoutWarn
: String(appLoc: "识别失败:\(msg)"))
} catch {
phase = .confirm(image: image, items: [],
warning: String(appLoc: "未知错误:\(error.localizedDescription)"))
warning: Task.isCancelled ? timeoutWarn
: String(appLoc: "未知错误:\(error.localizedDescription)"))
}
}
}
/// VL ,(high/low)
/// LLM ,(high/low)
private static func buildItems(from parsed: [ParsedReport.ParsedIndicator]) -> [QuickRegionItem] {
let mapped = parsed.map {
QuickRegionItem(name: $0.name, value: $0.value, unit: $0.unit,
@@ -233,16 +263,16 @@ private struct AnalyzingRegionView: View {
.font(.tjH2())
.foregroundStyle(Tj.Palette.text)
Text("100% 本地推理 · 已用 \(elapsed)s")
.font(.system(size: 12))
.font(.tjScaled( 12))
.foregroundStyle(Tj.Palette.text3)
if elapsed >= timeoutSeconds - 5 {
Text("快超时了,>\(timeoutSeconds)s 会自动转手动录入")
.font(.system(size: 11))
.font(.tjScaled( 11))
.foregroundStyle(Tj.Palette.amber)
}
}
Button("取消识别 · 改为手动录入", action: onCancel)
.font(.system(size: 13, weight: .medium))
.font(.tjScaled( 13, weight: .medium))
.foregroundStyle(Tj.Palette.text3)
.padding(.top, 4)
Spacer()

View File

@@ -55,7 +55,7 @@ struct QuickRegionConfirmView: View {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundStyle(Tj.Palette.amber)
Text(text)
.font(.system(size: 13))
.font(.tjScaled( 13))
.foregroundStyle(Tj.Palette.text2)
Spacer()
}
@@ -70,11 +70,11 @@ struct QuickRegionConfirmView: View {
VStack(alignment: .leading, spacing: 10) {
HStack {
Text("拍到的局部")
.font(.system(size: 13, weight: .semibold))
.font(.tjScaled( 13, weight: .semibold))
.foregroundStyle(Tj.Palette.text2)
Spacer()
Text("仅核对用 · 不保存照片")
.font(.system(size: 11))
.font(.tjScaled( 11))
.foregroundStyle(Tj.Palette.text3)
}
Image(uiImage: image)
@@ -91,7 +91,7 @@ struct QuickRegionConfirmView: View {
onRetake()
} label: {
Label("重拍", systemImage: "camera.rotate")
.font(.system(size: 13, weight: .medium))
.font(.tjScaled( 13, weight: .medium))
.foregroundStyle(Tj.Palette.ink)
}
}
@@ -102,7 +102,7 @@ struct QuickRegionConfirmView: View {
private var timeCard: some View {
VStack(alignment: .leading, spacing: 10) {
Text("测量时间")
.font(.system(size: 13, weight: .semibold))
.font(.tjScaled( 13, weight: .semibold))
.foregroundStyle(Tj.Palette.text2)
DatePicker("", selection: $capturedAt, in: ...Date.now)
.datePickerStyle(.compact)
@@ -116,7 +116,7 @@ struct QuickRegionConfirmView: View {
VStack(alignment: .leading, spacing: 14) {
HStack {
Text("识别到的指标 (\(items.count))")
.font(.system(size: 13, weight: .semibold))
.font(.tjScaled( 13, weight: .semibold))
.foregroundStyle(Tj.Palette.text2)
Spacer()
Button {
@@ -124,14 +124,14 @@ struct QuickRegionConfirmView: View {
status: .high, include: true))
} label: {
Label("加一项", systemImage: "plus.circle.fill")
.font(.system(size: 13, weight: .medium))
.font(.tjScaled( 13, weight: .medium))
.foregroundStyle(Tj.Palette.ink)
}
}
if items.isEmpty {
Text("没有识别到指标,点「加一项」手动补充,或返回重拍")
.font(.system(size: 13))
.font(.tjScaled( 13))
.foregroundStyle(Tj.Palette.text3)
.frame(maxWidth: .infinity, alignment: .center)
.padding(.vertical, 20)
@@ -153,17 +153,17 @@ struct QuickRegionConfirmView: View {
item.wrappedValue.include.toggle()
} label: {
Image(systemName: item.wrappedValue.include ? "checkmark.circle.fill" : "circle")
.font(.system(size: 20))
.font(.tjScaled( 20))
.foregroundStyle(item.wrappedValue.include ? Tj.Palette.ink : Tj.Palette.text3)
}
.buttonStyle(.plain)
TextField(String(appLoc: "指标名"), text: item.name)
.font(.system(size: 15, weight: .medium))
.font(.tjScaled( 15, weight: .medium))
if abnormal {
Text(statusLabel(item.wrappedValue.status))
.font(.system(size: 10, weight: .semibold))
.font(.tjScaled( 10, weight: .semibold))
.foregroundStyle(statusColor(item.wrappedValue.status))
.padding(.horizontal, 7).padding(.vertical, 3)
.background(Capsule().fill(statusColor(item.wrappedValue.status).opacity(0.16)))
@@ -175,7 +175,7 @@ struct QuickRegionConfirmView: View {
}
} label: {
Image(systemName: "trash")
.font(.system(size: 14))
.font(.tjScaled( 14))
.foregroundStyle(Tj.Palette.brick)
}
}
@@ -203,10 +203,10 @@ struct QuickRegionConfirmView: View {
mono: Bool = false) -> some View {
VStack(alignment: .leading, spacing: 4) {
Text(label)
.font(.system(size: 11))
.font(.tjScaled( 11))
.foregroundStyle(Tj.Palette.text3)
TextField("", text: text)
.font(.system(size: 14, weight: mono ? .semibold : .regular,
.font(.tjScaled( 14, weight: mono ? .semibold : .regular,
design: mono ? .monospaced : .default))
.keyboardType(mono ? .decimalPad : .default)
.textInputAutocapitalization(.never)
@@ -234,7 +234,7 @@ struct QuickRegionConfirmView: View {
item.wrappedValue.status = st
} label: {
Text(statusLabel(st))
.font(.system(size: 12, weight: selected ? .semibold : .regular))
.font(.tjScaled( 12, weight: selected ? .semibold : .regular))
.foregroundStyle(selected ? Tj.Palette.paper : Tj.Palette.text2)
.padding(.horizontal, 12)
.padding(.vertical, 6)

View File

@@ -69,7 +69,7 @@ struct RegionCameraView: View {
//
Text("把异常项放进框里 · 对准一两行")
.font(.system(size: 13, weight: .medium))
.font(.tjScaled( 13, weight: .medium))
.foregroundStyle(.white)
.padding(.horizontal, 12)
.padding(.vertical, 6)
@@ -89,7 +89,7 @@ struct RegionCameraView: View {
onCancel()
} label: {
Text("取消")
.font(.system(size: 16, weight: .medium))
.font(.tjScaled( 16, weight: .medium))
.foregroundStyle(.white)
.padding(.horizontal, 14)
.padding(.vertical, 8)
@@ -126,19 +126,19 @@ struct RegionCameraView: View {
private var deniedView: some View {
VStack(spacing: 16) {
Image(systemName: "camera.fill")
.font(.system(size: 40))
.font(.tjScaled( 40))
.foregroundStyle(.white.opacity(0.8))
Text("相机权限未开启")
.font(.tjH2())
.foregroundStyle(.white)
Text("异常项快拍需要相机。去「设置 → 康康 → 相机」打开后再回来。")
.font(.system(size: 13))
.font(.tjScaled( 13))
.foregroundStyle(.white.opacity(0.7))
.multilineTextAlignment(.center)
.padding(.horizontal, 36)
HStack(spacing: 12) {
Button("取消") { onCancel() }
.font(.system(size: 15))
.font(.tjScaled( 15))
.foregroundStyle(.white)
.padding(.horizontal, 18).padding(.vertical, 10)
.background(Capsule().strokeBorder(.white.opacity(0.5), lineWidth: 1))
@@ -147,7 +147,7 @@ struct RegionCameraView: View {
UIApplication.shared.open(url)
}
}
.font(.system(size: 15, weight: .semibold))
.font(.tjScaled( 15, weight: .semibold))
.foregroundStyle(.black)
.padding(.horizontal, 18).padding(.vertical, 10)
.background(Capsule().fill(.white))

View File

@@ -1,16 +1,18 @@
import SwiftUI
enum RecordKind: String, Identifiable, CaseIterable {
case quick, indicator, archive, diary, symptom, reminder
case quick, indicator, healthExport, archive, diary, symptom, reminder
var id: String { rawValue }
/// RecordSheet () enum ,
static let displayOrder: [RecordKind] = [.diary, .reminder, .symptom, .indicator, .quick, .archive]
/// :`.quick`() `.indicator`(),
static let displayOrder: [RecordKind] = [.diary, .reminder, .symptom, .indicator, .healthExport, .archive]
var title: String {
switch self {
case .quick: return String(appLoc: "异常项快拍")
case .indicator: return String(appLoc: "记录指标")
case .healthExport: return String(appLoc: "身体档案")
case .archive: return String(appLoc: "体检报告归档")
case .diary: return String(appLoc: "健康日记")
case .symptom: return String(appLoc: "记录症状")
@@ -20,7 +22,8 @@ enum RecordKind: String, Identifiable, CaseIterable {
var subtitle: String {
switch self {
case .quick: return String(appLoc: "拍一张化验单,VL 自动识别")
case .indicator: return String(appLoc: "手动填一项指标(免拍照)")
case .indicator: return String(appLoc: "手动填写,或拍照自动识别")
case .healthExport: return String(appLoc: "多轮问答后生成给医生看的整理报告")
case .archive: return String(appLoc: "完整保存整份报告(可多页)")
case .diary: return String(appLoc: "记录身体状态、用药、感受 · 可让 AI 辅助")
case .symptom: return String(appLoc: "开始一个持续症状,结束时再点结束")
@@ -31,6 +34,7 @@ enum RecordKind: String, Identifiable, CaseIterable {
switch self {
case .quick: return "camera.fill"
case .indicator: return "number.square.fill"
case .healthExport: return "doc.text.below.ecg"
case .archive: return "doc.fill"
case .diary: return "heart.text.square"
case .symptom: return "waveform.path.ecg"
@@ -41,6 +45,7 @@ enum RecordKind: String, Identifiable, CaseIterable {
switch self {
case .quick: return Tj.Palette.brick
case .indicator: return Tj.Palette.brick
case .healthExport: return Tj.Palette.ink
case .archive: return Tj.Palette.ink
case .diary: return Tj.Palette.leaf
case .symptom: return Tj.Palette.amber
@@ -66,7 +71,7 @@ struct RecordSheet: View {
.foregroundStyle(Tj.Palette.text)
Spacer()
Text("本地处理 · 永不上传")
.font(.system(size: 12))
.font(.tjScaled( 12))
.foregroundStyle(Tj.Palette.text3)
}
.padding(.bottom, 14)
@@ -83,22 +88,22 @@ struct RecordSheet: View {
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
.fill(kind.accent)
Image(systemName: kind.icon)
.font(.system(size: 18, weight: .medium))
.font(.tjScaled( 18, weight: .medium))
.foregroundStyle(Tj.Palette.paper)
}
.frame(width: 44, height: 44)
VStack(alignment: .leading, spacing: 2) {
Text(kind.title)
.font(.system(size: 15, weight: .semibold))
.font(.tjScaled( 15, weight: .semibold))
.foregroundStyle(Tj.Palette.text)
Text(kind.subtitle)
.font(.system(size: 12))
.font(.tjScaled( 12))
.foregroundStyle(Tj.Palette.text3)
}
Spacer()
Image(systemName: "chevron.right")
.font(.system(size: 14, weight: .medium))
.font(.tjScaled( 14, weight: .medium))
.foregroundStyle(Tj.Palette.text3)
}
.padding(16)

View File

@@ -25,7 +25,7 @@ struct OngoingSymptomsCard: View {
.font(.tjH2())
.foregroundStyle(Tj.Palette.text)
Text("\(ongoing.count)")
.font(.system(size: 12))
.font(.tjScaled( 12))
.foregroundStyle(Tj.Palette.text3)
Spacer()
}
@@ -51,12 +51,12 @@ struct OngoingSymptomsCard: View {
VStack(alignment: .leading, spacing: 4) {
HStack(spacing: 8) {
Text(sym.name)
.font(.system(size: 15, weight: .semibold))
.font(.tjScaled( 15, weight: .semibold))
.foregroundStyle(Tj.Palette.text)
severityDot(sym.severity)
}
Text("已持续 \(formatDuration(interval))")
.font(.system(size: 12))
.font(.tjScaled( 12))
.foregroundStyle(isLong ? Tj.Palette.brick : Tj.Palette.text3)
}
Spacer(minLength: 8)
@@ -64,7 +64,7 @@ struct OngoingSymptomsCard: View {
ending = sym
} label: {
Text("结束")
.font(.system(size: 12, weight: .semibold))
.font(.tjScaled( 12, weight: .semibold))
.foregroundStyle(Tj.Palette.text)
.padding(.horizontal, 12)
.padding(.vertical, 6)

View File

@@ -28,7 +28,7 @@ struct SymptomEndSheet: View {
HStack {
VStack(alignment: .leading, spacing: 4) {
Text("结束症状")
.font(.system(size: 12, weight: .semibold))
.font(.tjScaled( 12, weight: .semibold))
.tracking(0.3)
.foregroundStyle(Tj.Palette.text3)
Text(symptom.name)
@@ -40,16 +40,16 @@ struct SymptomEndSheet: View {
VStack(alignment: .leading, spacing: 6) {
Text("开始于")
.font(.system(size: 12))
.font(.tjScaled( 12))
.foregroundStyle(Tj.Palette.text3)
Text(symptom.startedAt.formatted(date: .abbreviated, time: .shortened))
.font(.system(size: 14, weight: .medium))
.font(.tjScaled( 14, weight: .medium))
.foregroundStyle(Tj.Palette.text)
}
VStack(alignment: .leading, spacing: 8) {
Text("结束时间")
.font(.system(size: 12, weight: .semibold))
.font(.tjScaled( 12, weight: .semibold))
.tracking(0.3)
.foregroundStyle(Tj.Palette.text2)
DatePicker("", selection: $endedAt, in: lowerBound...Date.now)
@@ -59,11 +59,11 @@ struct SymptomEndSheet: View {
HStack {
Text("本次持续")
.font(.system(size: 13))
.font(.tjScaled( 13))
.foregroundStyle(Tj.Palette.text3)
Spacer()
Text(durationLabel)
.font(.system(size: 15, weight: .semibold, design: .monospaced))
.font(.tjScaled( 15, weight: .semibold, design: .monospaced))
.foregroundStyle(Tj.Palette.brick)
}
.padding(.horizontal, 14)

View File

@@ -69,7 +69,7 @@ struct SymptomStartSheet: View {
.foregroundStyle(Tj.Palette.text)
Spacer()
Text("结束时再来点结束")
.font(.system(size: 12))
.font(.tjScaled( 12))
.foregroundStyle(Tj.Palette.text3)
}
.padding(.horizontal, 20)
@@ -130,15 +130,15 @@ struct SymptomStartSheet: View {
sectionLabel(String(appLoc: "强度"))
Spacer()
Text("\(Int(severity)) / 5")
.font(.system(size: 13, weight: .semibold, design: .monospaced))
.font(.tjScaled( 13, weight: .semibold, design: .monospaced))
.foregroundStyle(severityColor)
}
Slider(value: $severity, in: 1...5, step: 1)
.tint(severityColor)
HStack {
Text("轻微").font(.system(size: 11)).foregroundStyle(Tj.Palette.text3)
Text("轻微").font(.tjScaled( 11)).foregroundStyle(Tj.Palette.text3)
Spacer()
Text("剧烈").font(.system(size: 11)).foregroundStyle(Tj.Palette.text3)
Text("剧烈").font(.tjScaled( 11)).foregroundStyle(Tj.Palette.text3)
}
}
}
@@ -190,7 +190,7 @@ struct SymptomStartSheet: View {
private func sectionLabel(_ text: String) -> some View {
Text(text)
.font(.system(size: 12, weight: .semibold))
.font(.tjScaled( 12, weight: .semibold))
.tracking(0.3)
.foregroundStyle(Tj.Palette.text2)
}
@@ -198,7 +198,7 @@ struct SymptomStartSheet: View {
private func chip(_ label: String, selected: Bool, action: @escaping () -> Void) -> some View {
Button(action: action) {
Text(label)
.font(.system(size: 13, weight: selected ? .semibold : .regular))
.font(.tjScaled( 13, weight: selected ? .semibold : .regular))
.foregroundStyle(selected ? Tj.Palette.paper : Tj.Palette.text)
.padding(.horizontal, 14)
.padding(.vertical, 8)

View File

@@ -52,6 +52,7 @@ struct TimelineEntryDetailView: View {
let detail: TimelineDetail
@State private var showDeleteConfirm = false
@State private var evidenceTarget: Indicator?
var body: some View {
VStack(spacing: 0) {
@@ -77,6 +78,11 @@ struct TimelineEntryDetailView: View {
} message: {
Text("删除后无法恢复。")
}
.sheet(item: $evidenceTarget) { indicator in
if let report = indicator.report {
EvidenceImagePreview(report: report, indicator: indicator)
}
}
}
// MARK: - (:SwiftData + Vault unlink, CLAUDE.md §6)
@@ -84,7 +90,7 @@ struct TimelineEntryDetailView: View {
private var deleteButton: some View {
Button(role: .destructive) { showDeleteConfirm = true } label: {
Label(String(appLoc: "永久删除"), systemImage: "trash")
.font(.system(size: 12, weight: .medium))
.font(.tjScaled( 12, weight: .medium))
.foregroundStyle(Tj.Palette.brick.opacity(0.8))
.padding(.horizontal, 14)
.padding(.vertical, 8)
@@ -136,7 +142,7 @@ struct TimelineEntryDetailView: View {
HStack(spacing: 12) {
Button { dismiss() } label: {
Image(systemName: "xmark")
.font(.system(size: 16, weight: .semibold))
.font(.tjScaled( 16, weight: .semibold))
.foregroundStyle(Tj.Palette.text)
.frame(width: 32, height: 32)
.background(Circle().fill(Tj.Palette.sand2))
@@ -187,16 +193,19 @@ struct TimelineEntryDetailView: View {
}
HStack(alignment: .firstTextBaseline, spacing: 4) {
Text(i.value)
.font(.system(size: 30, weight: .bold, design: .rounded))
.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(.system(size: 14)).foregroundStyle(Tj.Palette.text3)
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) }
}
}
@@ -215,9 +224,9 @@ struct TimelineEntryDetailView: View {
}
HStack(alignment: .firstTextBaseline, spacing: 4) {
Text("\(sys.value)/\(dia?.value ?? "")")
.font(.system(size: 30, weight: .bold, design: .rounded))
.font(.tjScaled( 30, weight: .bold, design: .rounded))
.foregroundStyle(combined == .normal ? Tj.Palette.text : Tj.Palette.brick)
Text("mmHg").font(.system(size: 14)).foregroundStyle(Tj.Palette.text3)
Text("mmHg").font(.tjScaled( 14)).foregroundStyle(Tj.Palette.text3)
}
divider
if !sys.range.isEmpty { field(String(appLoc: "参考范围"), sys.range) }
@@ -237,10 +246,10 @@ struct TimelineEntryDetailView: View {
HStack(spacing: 8) {
TjBadge(text: r.type.label, style: .neutral)
Text(Self.dateText(r.reportDate))
.font(.system(size: 12)).foregroundStyle(Tj.Palette.text3)
.font(.tjScaled( 12)).foregroundStyle(Tj.Palette.text3)
if !r.assets.isEmpty {
Text(String(appLoc: "原图\(r.assets.count)"))
.font(.system(size: 12)).foregroundStyle(Tj.Palette.text3)
.font(.tjScaled( 12)).foregroundStyle(Tj.Palette.text3)
}
}
if let inst = r.institution, !inst.isEmpty {
@@ -251,8 +260,8 @@ struct TimelineEntryDetailView: View {
if let sum = r.summary, !sum.isEmpty {
card {
Text(String(appLoc: "摘要"))
.font(.system(size: 12, weight: .semibold)).foregroundStyle(Tj.Palette.text2)
Text(sum).font(.system(size: 14)).foregroundStyle(Tj.Palette.text)
.font(.tjScaled( 12, weight: .semibold)).foregroundStyle(Tj.Palette.text2)
Text(sum).font(.tjScaled( 14)).foregroundStyle(Tj.Palette.text)
.fixedSize(horizontal: false, vertical: true)
}
}
@@ -260,15 +269,18 @@ struct TimelineEntryDetailView: View {
if !r.indicators.isEmpty {
card {
Text(String(appLoc: "指标"))
.font(.system(size: 12, weight: .semibold)).foregroundStyle(Tj.Palette.text2)
.font(.tjScaled( 12, weight: .semibold)).foregroundStyle(Tj.Palette.text2)
ForEach(sorted) { ind in
HStack {
Text(ind.name).font(.system(size: 14)).foregroundStyle(Tj.Palette.text)
Spacer(minLength: 8)
Text(ind.unit.isEmpty ? ind.value : "\(ind.value) \(ind.unit)")
.font(.system(size: 13, design: .monospaced))
.foregroundStyle(ind.status == .normal ? Tj.Palette.text2 : Tj.Palette.brick)
statusChip(ind.status)
VStack(alignment: .leading, spacing: 6) {
HStack {
Text(ind.name).font(.tjScaled( 14)).foregroundStyle(Tj.Palette.text)
Spacer(minLength: 8)
Text(ind.unit.isEmpty ? ind.value : "\(ind.value) \(ind.unit)")
.font(.tjScaled( 13, design: .monospaced))
.foregroundStyle(ind.status == .normal ? Tj.Palette.text2 : Tj.Palette.brick)
statusChip(ind.status)
}
evidenceButton(for: ind, assets: r.assets)
}
}
}
@@ -286,9 +298,9 @@ struct TimelineEntryDetailView: View {
VStack(alignment: .leading, spacing: 16) {
card {
Text(Self.dateTimeText(d.createdAt))
.font(.system(size: 12)).foregroundStyle(Tj.Palette.text3)
.font(.tjScaled( 12)).foregroundStyle(Tj.Palette.text3)
Text(d.content)
.font(.system(size: 15))
.font(.tjScaled( 15))
.foregroundStyle(Tj.Palette.text)
.textSelection(.enabled)
.frame(maxWidth: .infinity, alignment: .leading)
@@ -309,7 +321,7 @@ struct TimelineEntryDetailView: View {
Spacer()
if s.isOngoing {
Text(String(appLoc: "进行中"))
.font(.system(size: 12, weight: .semibold))
.font(.tjScaled( 12, weight: .semibold))
.foregroundStyle(Tj.Palette.brick)
.padding(.horizontal, 8).padding(.vertical, 4)
.background(Capsule().fill(Tj.Palette.brick.opacity(0.14)))
@@ -346,16 +358,36 @@ struct TimelineEntryDetailView: View {
private func field(_ label: String, _ value: String) -> some View {
HStack(alignment: .top, spacing: 12) {
Text(label).font(.system(size: 13)).foregroundStyle(Tj.Palette.text3)
Text(label).font(.tjScaled( 13)).foregroundStyle(Tj.Palette.text3)
Spacer(minLength: 12)
Text(value)
.font(.system(size: 14, weight: .medium))
.font(.tjScaled( 14, weight: .medium))
.foregroundStyle(Tj.Palette.text)
.multilineTextAlignment(.trailing)
.fixedSize(horizontal: false, vertical: true)
}
}
@ViewBuilder
private func evidenceButton(for indicator: Indicator, assets: [Asset]) -> some View {
if indicator.hasEvidenceBox,
let page = indicator.sourcePageIndex,
assets.indices.contains(page) {
Button {
evidenceTarget = indicator
} label: {
Label(String(appLoc: "查看原图位置"), systemImage: "viewfinder")
.font(.tjScaled( 12, weight: .semibold))
.foregroundStyle(Tj.Palette.ink)
.padding(.horizontal, 10)
.padding(.vertical, 6)
.background(Capsule().fill(Tj.Palette.leaf.opacity(0.14)))
.contentShape(Rectangle())
}
.buttonStyle(.plain)
}
}
private var divider: some View {
Rectangle().fill(Tj.Palette.lineSoft).frame(height: 1)
}
@@ -370,8 +402,8 @@ struct TimelineEntryDetailView: View {
case .normal: text = String(appLoc: "正常"); color = Tj.Palette.leaf; arrow = ""
}
return HStack(spacing: 3) {
if !arrow.isEmpty { Text(arrow).font(.system(size: 11, weight: .bold)) }
Text(text).font(.system(size: 12, weight: .semibold))
if !arrow.isEmpty { Text(arrow).font(.tjScaled( 11, weight: .bold)) }
Text(text).font(.tjScaled( 12, weight: .semibold))
}
.foregroundStyle(color)
.padding(.horizontal, 8)
@@ -387,3 +419,142 @@ struct TimelineEntryDetailView: View {
d.formatted(.dateTime.year().month().day())
}
}
private struct EvidenceImagePreview: View {
@Environment(\.dismiss) private var dismiss
let report: Report
let indicator: Indicator
@State private var selection: Int
init(report: Report, indicator: Indicator) {
self.report = report
self.indicator = indicator
let page = indicator.sourcePageIndex ?? 0
_selection = State(initialValue: min(max(page, 0), max(report.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))
}
VStack(alignment: .leading, spacing: 2) {
Text(indicator.name)
.font(.tjScaled( 16, weight: .semibold))
.foregroundStyle(Tj.Palette.text)
Text("\(selection + 1) 页 · 原图证据")
.font(.tjScaled( 12))
.foregroundStyle(Tj.Palette.text3)
}
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(report.assets.enumerated()), id: \.offset) { index, asset in
EvidenceImagePage(
asset: asset,
highlight: index == indicator.sourcePageIndex ? indicator.evidenceRect : nil
)
.tag(index)
.padding(16)
}
}
.tabViewStyle(.page(indexDisplayMode: report.assets.count > 1 ? .automatic : .never))
}
.background(Tj.Palette.sand.ignoresSafeArea())
.presentationDetents([.large])
.presentationDragIndicator(.visible)
.presentationBackground(Tj.Palette.sand)
}
}
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 {
ZStack {
Image(uiImage: image)
.resizable()
.scaledToFit()
.frame(width: geo.size.width, height: geo.size.height)
if let highlight {
EvidenceHighlightOverlay(imageSize: image.size, normalizedRect: highlight)
}
}
.frame(width: geo.size.width, height: geo.size.height)
.background(Tj.Palette.paper)
.clipShape(RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous))
.overlay(
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)
}
}
}
}
private struct EvidenceHighlightOverlay: View {
let imageSize: CGSize
let normalizedRect: CGRect
var body: some View {
GeometryReader { geo in
let fitted = fittedRect(imageSize: imageSize, containerSize: geo.size)
let rect = CGRect(
x: fitted.minX + normalizedRect.minX * fitted.width,
y: fitted.minY + normalizedRect.minY * fitted.height,
width: normalizedRect.width * fitted.width,
height: normalizedRect.height * fitted.height
)
RoundedRectangle(cornerRadius: 4, style: .continuous)
.fill(Tj.Palette.brick.opacity(0.16))
.overlay(
RoundedRectangle(cornerRadius: 4, style: .continuous)
.stroke(Tj.Palette.brick, lineWidth: 2)
)
.frame(width: rect.width, height: rect.height)
.position(x: rect.midX, y: rect.midY)
.shadow(color: Tj.Palette.brick.opacity(0.24), radius: 8, y: 2)
}
.allowsHitTesting(false)
}
private func fittedRect(imageSize: CGSize, containerSize: CGSize) -> CGRect {
guard imageSize.width > 0,
imageSize.height > 0,
containerSize.width > 0,
containerSize.height > 0 else {
return .zero
}
let scale = min(containerSize.width / imageSize.width, containerSize.height / imageSize.height)
let size = CGSize(width: imageSize.width * scale, height: imageSize.height * scale)
return CGRect(
x: (containerSize.width - size.width) / 2,
y: (containerSize.height - size.height) / 2,
width: size.width,
height: size.height
)
}
}

View File

@@ -9,7 +9,7 @@ struct TimelineRow: View {
RoundedRectangle(cornerRadius: 8, style: .continuous)
.fill(entry.kind.accent.opacity(0.12))
Image(systemName: entry.kind.icon)
.font(.system(size: 14, weight: .semibold))
.font(.tjScaled( 14, weight: .semibold))
.foregroundStyle(entry.kind.accent)
}
.frame(width: 36, height: 36)
@@ -25,12 +25,12 @@ struct TimelineRow: View {
VStack(alignment: .leading, spacing: 2) {
Text("\(entry.date.timelineLabel) · \(entry.subtitle)")
.font(.system(size: 11))
.font(.tjScaled( 11))
.tracking(0.3)
.foregroundStyle(Tj.Palette.text3)
.lineLimit(1)
Text(entry.title)
.font(.system(size: 14, weight: .medium))
.font(.tjScaled( 14, weight: .medium))
.foregroundStyle(Tj.Palette.text)
.lineLimit(1)
.truncationMode(.tail)
@@ -38,7 +38,7 @@ struct TimelineRow: View {
Spacer(minLength: 8)
if let trailing = entry.trailing {
Text(trailing)
.font(.system(size: 12, weight: .semibold, design: .monospaced))
.font(.tjScaled( 12, weight: .semibold, design: .monospaced))
.foregroundStyle(entry.trailingIsAlert ? Tj.Palette.brick : Tj.Palette.text2)
.lineLimit(1)
.fixedSize()

View File

@@ -66,7 +66,7 @@ struct CalendarMonthGrid: View {
HStack(spacing: 4) {
ForEach(weekdayLabels, id: \.self) { w in
Text(w)
.font(.system(size: 11, weight: .medium))
.font(.tjScaled( 11, weight: .medium))
.foregroundStyle(Tj.Palette.text3)
.frame(maxWidth: .infinity)
}
@@ -123,7 +123,7 @@ private struct DayCellView: View {
VStack(spacing: 2) {
Text("\(dayNumber)")
.font(.system(size: 13,
.font(.tjScaled( 13,
weight: (isToday || isSelected) ? .bold : .regular,
design: .default))
.foregroundStyle(textColor)
@@ -137,7 +137,7 @@ private struct DayCellView: View {
}
if ranges.count > 2 {
Text("+\(ranges.count - 2)")
.font(.system(size: 7, design: .monospaced))
.font(.tjScaled( 7, design: .monospaced))
.foregroundStyle(Tj.Palette.text3)
}
}

View File

@@ -62,7 +62,7 @@ private struct MiniMonth: View {
var body: some View {
VStack(alignment: .leading, spacing: 6) {
Text(monthLabel)
.font(.system(size: 12, weight: .semibold))
.font(.tjScaled( 12, weight: .semibold))
.foregroundStyle(Tj.Palette.text)
LazyVGrid(columns: microColumns, spacing: 2) {

View File

@@ -104,7 +104,7 @@ struct DayDetailContent: View {
HStack(alignment: .firstTextBaseline) {
VStack(alignment: .leading, spacing: 4) {
Text(dateLine)
.font(.system(size: 12, weight: .semibold))
.font(.tjScaled( 12, weight: .semibold))
.tracking(0.5)
.foregroundStyle(Tj.Palette.text3)
Text(dayLabel)
@@ -114,7 +114,7 @@ struct DayDetailContent: View {
Spacer()
if totalCount > 0 {
Text("\(totalCount)")
.font(.system(size: 12, design: .monospaced))
.font(.tjScaled( 12, design: .monospaced))
.foregroundStyle(Tj.Palette.text3)
}
}
@@ -140,11 +140,11 @@ struct DayDetailContent: View {
VStack(alignment: .leading, spacing: 10) {
HStack {
Text(title)
.font(.system(size: 13, weight: .semibold))
.font(.tjScaled( 13, weight: .semibold))
.tracking(0.3)
.foregroundStyle(Tj.Palette.text2)
Text("\(count)")
.font(.system(size: 11, design: .monospaced))
.font(.tjScaled( 11, design: .monospaced))
.foregroundStyle(Tj.Palette.text3)
Spacer()
}
@@ -162,17 +162,17 @@ struct DayDetailContent: View {
VStack(alignment: .leading, spacing: 3) {
HStack(spacing: 6) {
Text(s.name)
.font(.system(size: 15, weight: .semibold))
.font(.tjScaled( 15, weight: .semibold))
.foregroundStyle(Tj.Palette.text)
Text(state.badge)
.font(.system(size: 10, weight: .semibold))
.font(.tjScaled( 10, weight: .semibold))
.foregroundStyle(state.badgeFg)
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(Capsule().fill(state.badgeBg))
}
Text("\(state.subtitle) · 持续 \(formatDuration(s.duration))")
.font(.system(size: 11))
.font(.tjScaled( 11))
.foregroundStyle(Tj.Palette.text3)
}
Spacer(minLength: 6)
@@ -181,7 +181,7 @@ struct DayDetailContent: View {
endingSymptom = s
} label: {
Text("结束")
.font(.system(size: 12, weight: .semibold))
.font(.tjScaled( 12, weight: .semibold))
.foregroundStyle(Tj.Palette.text)
.padding(.horizontal, 12)
.padding(.vertical, 6)
@@ -200,24 +200,24 @@ struct DayDetailContent: View {
RoundedRectangle(cornerRadius: 8, style: .continuous)
.fill(indicatorAccent(i).opacity(0.12))
Image(systemName: "drop.fill")
.font(.system(size: 13, weight: .semibold))
.font(.tjScaled( 13, weight: .semibold))
.foregroundStyle(indicatorAccent(i))
}
.frame(width: 32, height: 32)
VStack(alignment: .leading, spacing: 2) {
Text(i.name)
.font(.system(size: 14, weight: .medium))
.font(.tjScaled( 14, weight: .medium))
.foregroundStyle(Tj.Palette.text)
.lineLimit(1)
if !i.range.isEmpty {
Text("参考 \(i.range)")
.font(.system(size: 11))
.font(.tjScaled( 11))
.foregroundStyle(Tj.Palette.text3)
}
}
Spacer(minLength: 6)
Text("\(i.value) \(i.unit)\(arrow(i))")
.font(.system(size: 13, weight: .semibold, design: .monospaced))
.font(.tjScaled( 13, weight: .semibold, design: .monospaced))
.foregroundStyle(i.status == .normal ? Tj.Palette.text2 : Tj.Palette.brick)
.lineLimit(1)
.fixedSize()
@@ -235,23 +235,23 @@ struct DayDetailContent: View {
RoundedRectangle(cornerRadius: 8, style: .continuous)
.fill(Tj.Palette.ink2.opacity(0.12))
Image(systemName: "doc.fill")
.font(.system(size: 13, weight: .semibold))
.font(.tjScaled( 13, weight: .semibold))
.foregroundStyle(Tj.Palette.ink2)
}
.frame(width: 32, height: 32)
VStack(alignment: .leading, spacing: 2) {
Text(r.title)
.font(.system(size: 14, weight: .medium))
.font(.tjScaled( 14, weight: .medium))
.foregroundStyle(Tj.Palette.text)
.lineLimit(1)
Text("\(r.type.label) · 共 \(r.pageCount)")
.font(.system(size: 11))
.font(.tjScaled( 11))
.foregroundStyle(Tj.Palette.text3)
}
Spacer(minLength: 6)
if let summary {
Text(summary)
.font(.system(size: 11, weight: .semibold, design: .monospaced))
.font(.tjScaled( 11, weight: .semibold, design: .monospaced))
.foregroundStyle(Tj.Palette.brick)
}
}
@@ -263,7 +263,7 @@ struct DayDetailContent: View {
VStack(alignment: .leading, spacing: 6) {
HStack {
Text(d.createdAt.formatted(date: .omitted, time: .shortened))
.font(.system(size: 11, design: .monospaced))
.font(.tjScaled( 11, design: .monospaced))
.foregroundStyle(Tj.Palette.text3)
Spacer()
}
@@ -284,7 +284,7 @@ struct DayDetailContent: View {
.frame(height: 90)
.frame(maxWidth: 240)
Text("点底部 + 号可以补一条")
.font(.system(size: 11))
.font(.tjScaled( 11))
.foregroundStyle(Tj.Palette.text3)
}
.padding(.vertical, 12)

View File

@@ -66,10 +66,10 @@ struct SeriesChartCard: View {
private var header: some View {
HStack(alignment: .lastTextBaseline, spacing: 10) {
Text(bucket.title)
.font(.system(size: 15, weight: .semibold))
.font(.tjScaled( 15, weight: .semibold))
.foregroundStyle(Tj.Palette.text)
Text("\(allPoints.count) 条 · 近 \(daysSpanLabel)")
.font(.system(size: 11))
.font(.tjScaled( 11))
.foregroundStyle(Tj.Palette.text3)
Spacer()
latestValueBadge
@@ -87,10 +87,10 @@ struct SeriesChartCard: View {
}
return HStack(spacing: 4) {
Text(joined)
.font(.system(size: 14, weight: .semibold, design: .monospaced))
.font(.tjScaled( 14, weight: .semibold, design: .monospaced))
.foregroundStyle(anyAbnormal ? Tj.Palette.brick : Tj.Palette.text)
Text(bucket.unit)
.font(.system(size: 10, design: .monospaced))
.font(.tjScaled( 10, design: .monospaced))
.foregroundStyle(Tj.Palette.text3)
}
}
@@ -142,7 +142,7 @@ struct SeriesChartCard: View {
AxisGridLine().foregroundStyle(Tj.Palette.lineSoft)
AxisValueLabel()
.foregroundStyle(Tj.Palette.text3)
.font(.system(size: 10, design: .monospaced))
.font(.tjScaled( 10, design: .monospaced))
}
}
.chartYScale(domain: valueDomain ?? 0...1)
@@ -156,7 +156,7 @@ struct SeriesChartCard: View {
.fill(line.color)
.frame(width: 8, height: 8)
Text(line.label ?? line.seriesKey)
.font(.system(size: 11))
.font(.tjScaled( 11))
.foregroundStyle(Tj.Palette.text2)
}
}

View File

@@ -99,7 +99,7 @@ struct TrendDetailView: View {
withAnimation(.snappy(duration: 0.2)) { range = r }
} label: {
Text(r.label)
.font(.system(size: 12, weight: range == r ? .semibold : .regular))
.font(.tjScaled( 12, weight: range == r ? .semibold : .regular))
.foregroundStyle(range == r ? Tj.Palette.paper : Tj.Palette.text)
.frame(maxWidth: .infinity)
.padding(.vertical, 7)
@@ -210,7 +210,7 @@ struct TrendDetailView: View {
AxisGridLine().foregroundStyle(Tj.Palette.lineSoft)
AxisValueLabel()
.foregroundStyle(Tj.Palette.text3)
.font(.system(size: 10, design: .monospaced))
.font(.tjScaled( 10, design: .monospaced))
}
}
.chartYScale(domain: valueDomain ?? 0...1)
@@ -222,7 +222,7 @@ struct TrendDetailView: View {
HStack(spacing: 5) {
Circle().fill(line.color).frame(width: 8, height: 8)
Text(line.label ?? line.seriesKey)
.font(.system(size: 11))
.font(.tjScaled( 11))
.foregroundStyle(Tj.Palette.text2)
}
}
@@ -265,20 +265,20 @@ struct TrendDetailView: View {
VStack(alignment: .leading, spacing: 10) {
if filteredLines.count > 1, let label = line.label {
Text(label)
.font(.system(size: 12, weight: .semibold))
.font(.tjScaled( 12, weight: .semibold))
.foregroundStyle(Tj.Palette.text2)
}
HStack(alignment: .firstTextBaseline, spacing: 6) {
Text(latest.map { fmt($0.value) } ?? "")
.font(.system(size: 28, weight: .bold, design: .monospaced))
.font(.tjScaled( 28, weight: .bold, design: .monospaced))
.foregroundStyle((latest?.status ?? .normal) == .normal ? Tj.Palette.text : Tj.Palette.brick)
Text(bucket.unit)
.font(.system(size: 12))
.font(.tjScaled( 12))
.foregroundStyle(Tj.Palette.text3)
Spacer()
if let delta = deltaText(latest: latest, prev: prev) {
Text(delta.text)
.font(.system(size: 13, weight: .semibold, design: .monospaced))
.font(.tjScaled( 13, weight: .semibold, design: .monospaced))
.foregroundStyle(delta.color)
}
}
@@ -294,10 +294,10 @@ struct TrendDetailView: View {
private func statCell(_ label: String, _ value: String) -> some View {
VStack(spacing: 3) {
Text(value)
.font(.system(size: 14, weight: .semibold, design: .monospaced))
.font(.tjScaled( 14, weight: .semibold, design: .monospaced))
.foregroundStyle(Tj.Palette.text)
Text(label)
.font(.system(size: 10))
.font(.tjScaled( 10))
.foregroundStyle(Tj.Palette.text3)
}
.frame(maxWidth: .infinity)
@@ -323,10 +323,10 @@ struct TrendDetailView: View {
private var aiPlaceholder: some View {
HStack(spacing: 8) {
Image(systemName: "sparkles")
.font(.system(size: 12))
.font(.tjScaled( 12))
.foregroundStyle(Tj.Palette.text3)
Text("AI 趋势解读即将上线")
.font(.system(size: 12))
.font(.tjScaled( 12))
.foregroundStyle(Tj.Palette.text3)
Spacer()
}
@@ -364,7 +364,7 @@ struct TrendDetailView: View {
private var pointsList: some View {
VStack(alignment: .leading, spacing: 10) {
Text("全部记录")
.font(.system(size: 13, weight: .semibold))
.font(.tjScaled( 13, weight: .semibold))
.foregroundStyle(Tj.Palette.text2)
VStack(spacing: 8) {
ForEach(pointRows) { row in
@@ -382,7 +382,7 @@ struct TrendDetailView: View {
private func pointRowView(_ row: PointRow) -> some View {
HStack(spacing: 12) {
Text(row.day.formatted(.dateTime.year().month(.abbreviated).day()))
.font(.system(size: 13))
.font(.tjScaled( 13))
.foregroundStyle(Tj.Palette.text2)
Spacer(minLength: 8)
HStack(spacing: 10) {
@@ -393,14 +393,14 @@ struct TrendDetailView: View {
Circle().fill(line.color).frame(width: 6, height: 6)
}
Text(fmt(p.value) + arrow(p.status))
.font(.system(size: 13, weight: .semibold, design: .monospaced))
.font(.tjScaled( 13, weight: .semibold, design: .monospaced))
.foregroundStyle(p.status == .normal ? Tj.Palette.text : Tj.Palette.brick)
}
}
}
}
Image(systemName: "chevron.right")
.font(.system(size: 11, weight: .medium))
.font(.tjScaled( 11, weight: .medium))
.foregroundStyle(Tj.Palette.text3)
}
.padding(12)

View File

@@ -19,11 +19,11 @@ struct TrendRow: View {
HStack(spacing: 12) {
VStack(alignment: .leading, spacing: 3) {
Text(bucket.title)
.font(.system(size: 15, weight: .semibold))
.font(.tjScaled( 15, weight: .semibold))
.foregroundStyle(Tj.Palette.text)
.lineLimit(1)
Text(subtitle)
.font(.system(size: 11))
.font(.tjScaled( 11))
.foregroundStyle(Tj.Palette.text3)
}
@@ -34,17 +34,17 @@ struct TrendRow: View {
VStack(alignment: .trailing, spacing: 2) {
Text(latestValue)
.font(.system(size: 14, weight: .semibold, design: .monospaced))
.font(.tjScaled( 14, weight: .semibold, design: .monospaced))
.foregroundStyle(anyLatestAbnormal ? Tj.Palette.brick : Tj.Palette.text)
.lineLimit(1)
Text(bucket.unit)
.font(.system(size: 9, design: .monospaced))
.font(.tjScaled( 9, design: .monospaced))
.foregroundStyle(Tj.Palette.text3)
}
.fixedSize()
Image(systemName: "chevron.right")
.font(.system(size: 12, weight: .medium))
.font(.tjScaled( 12, weight: .medium))
.foregroundStyle(Tj.Palette.text3)
}
.padding(14)

View File

@@ -63,7 +63,7 @@ struct TrendsView: View {
.font(.tjH2())
.foregroundStyle(Tj.Palette.text)
Text("\(buckets.count)")
.font(.system(size: 12))
.font(.tjScaled( 12))
.foregroundStyle(Tj.Palette.text3)
Spacer()
}
@@ -87,7 +87,7 @@ struct TrendsView: View {
.frame(height: 120)
.frame(maxWidth: 260)
Text("同一指标记录满 2 次后,会在这里出现时间序列")
.font(.system(size: 12))
.font(.tjScaled( 12))
.foregroundStyle(Tj.Palette.text3)
.multilineTextAlignment(.center)
}

View File

@@ -1201,6 +1201,9 @@
}
}
}
},
"AI 模型未就绪,手动补充" : {
},
"AI 没有给出建议,请稍后重试" : {
"localizations" : {
@@ -1267,6 +1270,9 @@
}
}
}
},
"AI 趋势解读即将上线" : {
},
"AI 辅助 · 医生角度查漏补缺" : {
"localizations" : {
@@ -1311,6 +1317,9 @@
}
}
}
},
"Apple 健康里没有可导入的生日、性别、身高或血型。" : {
},
"B 型" : {
"localizations" : {
@@ -1408,9 +1417,21 @@
}
}
}
},
"s" : {
},
"series" : {
},
"start" : {
},
"t" : {
},
"v" : {
},
"VL 模型尚未就绪" : {
"localizations" : {
@@ -1477,9 +1498,6 @@
}
}
}
},
"VL 模型未就绪,手动补充" : {
},
"VL 输出无法解析:%@" : {
"localizations" : {
@@ -1591,6 +1609,9 @@
}
}
}
},
"上下文:全部记录指标 + 健康日记 · 本地 RAG · 不上传任何数据" : {
},
"上限" : {
"localizations" : {
@@ -2101,6 +2122,9 @@
}
}
}
},
"从 Apple 健康导入" : {
},
"从文件导入(离线)" : {
"localizations" : {
@@ -2502,8 +2526,12 @@
}
}
}
},
"例:最近血压波动大吗?" : {
},
"例:最近血糖好像不稳,把过去三个月的化验单整理一下" : {
"extractionState" : "stale",
"localizations" : {
"en" : {
"stringUnit" : {
@@ -2526,6 +2554,7 @@
}
},
"例:我感冒3天了,把最近一个月的健康情况给医生看" : {
"extractionState" : "stale",
"localizations" : {
"en" : {
"stringUnit" : {
@@ -2546,6 +2575,9 @@
}
}
}
},
"例:把我最近头晕、睡眠和指标变化整理给医生" : {
},
"例如:< 3.40 或 3.9 - 6.1" : {
"localizations" : {
@@ -2877,6 +2909,9 @@
}
}
}
},
"健康日历" : {
},
"健康日记" : {
"localizations" : {
@@ -2899,6 +2934,9 @@
}
}
}
},
"健康档案 Aa 123" : {
},
"健康记录" : {
"localizations" : {
@@ -2966,6 +3004,9 @@
}
}
}
},
"先问清楚,再整理给医生" : {
},
"免责声明" : {
"localizations" : {
@@ -3078,6 +3119,9 @@
}
}
}
},
"全部记录" : {
},
"六" : {
"localizations" : {
@@ -3300,6 +3344,9 @@
}
}
}
},
"最低" : {
},
"最近记录" : {
"localizations" : {
@@ -3322,6 +3369,9 @@
}
}
}
},
"最高" : {
},
"冠心病" : {
"localizations" : {
@@ -3700,6 +3750,9 @@
}
}
}
},
"化验指标趋势" : {
},
"化验项快捷(不进趋势)" : {
"localizations" : {
@@ -3814,6 +3867,9 @@
}
}
}
},
"原图无法读取" : {
},
"去设置" : {
@@ -3927,6 +3983,9 @@
}
}
}
},
"发送问题" : {
},
"取消" : {
"localizations" : {
@@ -3993,6 +4052,9 @@
}
}
}
},
"只读取生日、性别、身高、血型" : {
},
"可选开启 Face ID 启动锁,进一步保护隐私。" : {
"localizations" : {
@@ -4037,6 +4099,9 @@
}
}
}
},
"同一指标记录满 2 次后,会在这里出现时间序列" : {
},
"名称" : {
"localizations" : {
@@ -4191,6 +4256,9 @@
}
}
}
},
"围绕你的指标和健康日记提问" : {
},
"图例" : {
"localizations" : {
@@ -4257,9 +4325,6 @@
}
}
}
},
"图片编码失败,手动补充或重拍" : {
},
"在「+ 新建 → 指标记录 → %@」记录一次" : {
"localizations" : {
@@ -4306,6 +4371,7 @@
}
},
"在这里输入主诉……" : {
"extractionState" : "stale",
"localizations" : {
"en" : {
"stringUnit" : {
@@ -4486,6 +4552,9 @@
}
}
}
},
"多轮问答后生成给医生看的整理报告" : {
},
"多页报告" : {
"extractionState" : "stale",
@@ -4531,6 +4600,9 @@
}
}
}
},
"大" : {
},
"失眠" : {
"localizations" : {
@@ -4795,6 +4867,18 @@
}
}
}
},
"字体大小" : {
},
"字号放大 20%" : {
},
"字号放大 40%" : {
},
"字号放大 60%" : {
},
"完成" : {
"localizations" : {
@@ -4928,6 +5012,12 @@
}
}
}
},
"导入" : {
},
"导入前会先显示预览,确认后才覆盖个人资料。" : {
},
"导入失败:%@" : {
"localizations" : {
@@ -4974,27 +5064,8 @@
}
}
},
"导出身体档案" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Export health profile"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "身体プロファイルをエクスポート"
}
},
"ko" : {
"stringUnit" : {
"state" : "translated",
"value" : "건강 프로필 내보내기"
}
}
}
"导出历史" : {
},
"将追加:" : {
"localizations" : {
@@ -5514,6 +5585,9 @@
}
}
}
},
"平均" : {
},
"年" : {
"localizations" : {
@@ -5939,6 +6013,9 @@
}
}
}
},
"当前: %@" : {
},
"当前用药" : {
"localizations" : {
@@ -6209,6 +6286,9 @@
}
}
}
},
"患者" : {
},
"慢性肾病" : {
"localizations" : {
@@ -6299,6 +6379,7 @@
}
},
"我的导出 · %lld 份" : {
"extractionState" : "stale",
"localizations" : {
"en" : {
"stringUnit" : {
@@ -6363,6 +6444,9 @@
}
}
}
},
"或手动填写" : {
},
"或者自己写" : {
"localizations" : {
@@ -6432,6 +6516,7 @@
}
},
"手动填一项指标(免拍照)" : {
"extractionState" : "stale",
"localizations" : {
"en" : {
"stringUnit" : {
@@ -6452,6 +6537,12 @@
}
}
}
},
"手动填写,或拍照自动识别" : {
},
"手动记录" : {
},
"把异常项放进框里 · 对准一两行" : {
@@ -6594,6 +6685,9 @@
},
"拍到的局部" : {
},
"拍化验单,VL 自动读出数值" : {
},
"拍报告的小贴士" : {
"localizations" : {
@@ -6685,6 +6779,9 @@
}
}
}
},
"拍照识别" : {
},
"拍照识别报告 → 结构化指标" : {
"localizations" : {
@@ -7357,6 +7454,9 @@
}
}
}
},
"放大后整个 App 的文字立即变大,无需重启。设置会被记住。" : {
},
"数值" : {
"localizations" : {
@@ -7447,6 +7547,9 @@
}
}
}
},
"整理好的报告" : {
},
"整页入框,避免裁切到指标" : {
"localizations" : {
@@ -7651,6 +7754,9 @@
}
}
}
},
"无法导入 Apple 健康资料" : {
},
"日" : {
"localizations" : {
@@ -7990,6 +8096,9 @@
}
}
}
},
"未读取到的字段不会修改。" : {
},
"未选" : {
"localizations" : {
@@ -8147,6 +8256,7 @@
}
},
"本地 RAG · Qwen3 1.7B · 不上传任何数据" : {
"extractionState" : "stale",
"localizations" : {
"en" : {
"stringUnit" : {
@@ -8366,6 +8476,12 @@
}
}
}
},
"本月 %lld 天有记录" : {
},
"本月暂无记录" : {
},
"本机保存" : {
"localizations" : {
@@ -8580,6 +8696,12 @@
}
}
}
},
"查看原图位置" : {
},
"标准" : {
},
"标签" : {
@@ -8898,6 +9020,9 @@
}
}
}
},
"正在查看本地记录…" : {
},
"正常" : {
"localizations" : {
@@ -9103,6 +9228,9 @@
},
"没有识别到指标,点「加一项」手动补充,或返回重拍" : {
},
"没识别到文字,手动补充或重拍" : {
},
"没读出指标,手动补充或重拍" : {
@@ -9238,6 +9366,9 @@
}
}
}
},
"特大" : {
},
"状态" : {
"localizations" : {
@@ -9397,6 +9528,7 @@
}
},
"生成报告" : {
"extractionState" : "stale",
"localizations" : {
"en" : {
"stringUnit" : {
@@ -9440,8 +9572,12 @@
}
}
}
},
"生成整理报告" : {
},
"生成新导出" : {
"extractionState" : "stale",
"localizations" : {
"en" : {
"stringUnit" : {
@@ -9688,6 +9824,9 @@
},
"相机权限未开启" : {
},
"确认导入" : {
},
"程度" : {
@@ -9764,6 +9903,9 @@
}
}
}
},
"第 %lld 页 · 原图证据" : {
},
"第 1 轮 · %lld 条" : {
"localizations" : {
@@ -10075,6 +10217,7 @@
}
},
"给医生看的就诊摘要" : {
"extractionState" : "stale",
"localizations" : {
"en" : {
"stringUnit" : {
@@ -10140,6 +10283,9 @@
}
}
}
},
"继续提问或补充情况…" : {
},
"维生素 D" : {
"extractionState" : "stale",
@@ -10533,6 +10679,9 @@
}
}
}
},
"解析失败:%@" : {
},
"解锁康康,查看你的健康档案" : {
"localizations" : {
@@ -11011,6 +11160,7 @@
}
},
"说说你想给医生看什么" : {
"extractionState" : "stale",
"localizations" : {
"en" : {
"stringUnit" : {
@@ -11053,6 +11203,9 @@
}
}
}
},
"读取生日、性别、身高和血型,确认后填入个人资料" : {
},
"谷丙转氨酶" : {
"extractionState" : "stale",
@@ -11122,6 +11275,9 @@
}
}
}
},
"超大" : {
},
"超过参考上限 0.44属轻度偏高。建议关注饮食结构减少动物脂肪摄入3 个月内复查。若家族有心血管病史,可与医生沟通是否需要药物干预。" : {
"extractionState" : "stale",
@@ -11259,6 +11415,28 @@
}
}
},
"身体档案" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Health profile"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "身体プロファイル"
}
},
"ko" : {
"stringUnit" : {
"state" : "translated",
"value" : "건강 프로필"
}
}
}
},
"身体档案 · 历史导出" : {
"localizations" : {
"en" : {
@@ -11504,6 +11682,15 @@
}
}
}
},
"近1年" : {
},
"近3月" : {
},
"近6月" : {
},
"返回修改" : {
"localizations" : {
@@ -11570,6 +11757,9 @@
}
}
}
},
"还没有可成趋势的指标" : {
},
"还没有导出过\n回到记录页右上角生成一份" : {
"localizations" : {
@@ -11703,6 +11893,9 @@
}
}
}
},
"这台设备暂不支持读取 Apple 健康资料。" : {
},
"这是什么" : {
"localizations" : {
@@ -11841,8 +12034,12 @@
},
"重拍" : {
},
"重新整理" : {
},
"重新生成" : {
"extractionState" : "stale",
"localizations" : {
"en" : {
"stringUnit" : {
@@ -12262,6 +12459,9 @@
}
}
}
},
"默认字号" : {
}
},
"version" : "1.0"

View File

@@ -1,4 +1,5 @@
import Foundation
import CoreGraphics
import SwiftData
enum IndicatorStatus: String, Codable, CaseIterable {
@@ -55,6 +56,14 @@ final class Indicator {
/// (IndicatorSource.rawValue) SwiftData ,
var sourceRaw: String = IndicatorSource.manual.rawValue
/// VL 0-based;box (0...1)
///
var sourcePageIndex: Int?
var sourceBoxX: Double?
var sourceBoxY: Double?
var sourceBoxWidth: Double?
var sourceBoxHeight: Double?
init(name: String,
value: String,
unit: String,
@@ -66,7 +75,12 @@ final class Indicator {
asset: Asset? = nil,
pinned: Bool = false,
seriesKey: String? = nil,
source: IndicatorSource = .manual) {
source: IndicatorSource = .manual,
sourcePageIndex: Int? = nil,
sourceBoxX: Double? = nil,
sourceBoxY: Double? = nil,
sourceBoxWidth: Double? = nil,
sourceBoxHeight: Double? = nil) {
self.name = name
self.value = value
self.unit = unit
@@ -79,6 +93,11 @@ final class Indicator {
self.pinned = pinned
self.seriesKey = seriesKey
self.sourceRaw = source.rawValue
self.sourcePageIndex = sourcePageIndex
self.sourceBoxX = sourceBoxX
self.sourceBoxY = sourceBoxY
self.sourceBoxWidth = sourceBoxWidth
self.sourceBoxHeight = sourceBoxHeight
}
var status: IndicatorStatus {
@@ -88,6 +107,22 @@ final class Indicator {
var source: IndicatorSource {
IndicatorSource(rawValue: sourceRaw) ?? .manual
}
var hasEvidenceBox: Bool {
evidenceRect != nil && sourcePageIndex != nil
}
var evidenceRect: CGRect? {
guard let x = sourceBoxX,
let y = sourceBoxY,
let width = sourceBoxWidth,
let height = sourceBoxHeight,
x >= 0, y >= 0, width > 0, height > 0,
x + width <= 1, y + height <= 1 else {
return nil
}
return CGRect(x: x, y: y, width: width, height: height)
}
}
@Model

View File

@@ -44,6 +44,7 @@ struct RootView: View {
@State private var showDiary = false
@State private var showIndicator = false
@State private var showReminders = false
@State private var showHealthExport = false
/// tab : pushEdge, tab
/// tab ,
@@ -83,6 +84,7 @@ struct RootView: View {
case .diary: showDiary = true
case .indicator: showIndicator = true
case .reminder: showReminders = true
case .healthExport: showHealthExport = true
}
}
}
@@ -94,12 +96,21 @@ struct RootView: View {
DiaryQuickSheet()
}
.sheet(isPresented: $showIndicator) {
IndicatorQuickSheet()
// : VL ()
IndicatorQuickSheet(onRequestCamera: {
showIndicator = false
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
activeFlow = .quick
}
})
}
.sheet(isPresented: $showReminders) {
// NavigationStack ;sheet
NavigationStack { RemindersListView(presentedAsSheet: true) }
}
.fullScreenCover(isPresented: $showHealthExport) {
HealthExportSheet()
}
#if os(iOS)
.fullScreenCover(item: $activeFlow) { flow in
switch flow {
@@ -176,12 +187,12 @@ private struct TabBar: View {
.matchedGeometryEffect(id: "tabIndicator", in: indicatorNS)
}
Image(systemName: t.icon)
.font(.system(size: 18, weight: isActive ? .semibold : .regular))
.font(.tjScaled( 18, weight: isActive ? .semibold : .regular))
}
.frame(width: 50, height: slotHeight)
Text(t.label)
.font(.system(size: 11, weight: isActive ? .semibold : .regular))
.font(.tjScaled( 11, weight: isActive ? .semibold : .regular))
}
.foregroundStyle(isActive ? Tj.Palette.ink : Tj.Palette.text3)
.frame(maxWidth: .infinity)
@@ -204,13 +215,13 @@ private struct TabBar: View {
radius: 4, x: 0, y: 2)
Image(systemName: "plus")
.font(.system(size: 16, weight: .semibold))
.font(.tjScaled( 16, weight: .semibold))
.foregroundStyle(Tj.Palette.paper)
}
.frame(width: slotHeight, height: slotHeight)
Text("新建")
.font(.system(size: 11, weight: .semibold))
.font(.tjScaled( 11, weight: .semibold))
.foregroundStyle(Tj.Palette.ink)
}
.frame(maxWidth: .infinity)

View File

@@ -25,7 +25,7 @@ struct LockScreenView: View {
.fill(Tj.Palette.paper)
.overlay(Circle().strokeBorder(Tj.Palette.line, lineWidth: 1))
Image(systemName: "lock.fill")
.font(.system(size: 34))
.font(.tjScaled( 34))
.foregroundStyle(Tj.Palette.ink)
}
.frame(width: 92, height: 92)
@@ -36,7 +36,7 @@ struct LockScreenView: View {
.font(.tjH2())
.foregroundStyle(Tj.Palette.text)
Text("你的健康档案已加密保护")
.font(.system(size: 13))
.font(.tjScaled( 13))
.foregroundStyle(Tj.Palette.text3)
}
@@ -72,7 +72,7 @@ struct PrivacyCoverView: View {
.fill(Tj.Palette.paper)
.overlay(Circle().strokeBorder(Tj.Palette.line, lineWidth: 1))
Image(systemName: "heart.text.square.fill")
.font(.system(size: 30))
.font(.tjScaled( 30))
.foregroundStyle(Tj.Palette.ink)
}
.frame(width: 80, height: 80)

View File

@@ -21,6 +21,11 @@ struct ParsedReport: Sendable {
var unit: String
var range: String
var status: IndicatorStatus
var sourcePageIndex: Int?
var sourceBoxX: Double?
var sourceBoxY: Double?
var sourceBoxWidth: Double?
var sourceBoxHeight: Double?
}
/// = ,UI 退
@@ -100,11 +105,16 @@ actor CaptureService {
do {
raw = try await AIRuntime.shared.analyzeReport(
imageURLs: [tmpURL],
prompt: VLPrompts.regionExtraction()
prompt: VLPrompts.regionExtraction(),
// ,512 token
maxTokens: 2048
)
} catch {
throw CaptureError.inferenceFailed("\(error)")
}
#if DEBUG
print("🔎 [recognizeRegion] image bytes=\(imageData.count), VL raw output:\n\(raw)\n--- end VL raw ---")
#endif
do {
return try CaptureService.parseIndicatorsJSON(raw)
} catch let CaptureError.parseFailed(msg) {
@@ -114,6 +124,56 @@ actor CaptureService {
}
}
/// OCR : Vision OCR LLM(Qwen3-1.7B)
/// Report; `CaptureError`,UI 退(§3.2)
/// (MainActor) OCR,OCR actor, UIImage actor
func recognizeIndicators(fromOCRText text: String) async throws -> [ParsedReport.ParsedIndicator] {
do {
try await AIRuntime.shared.prepare() // LLM( VL,OOM )
} catch {
throw CaptureError.modelNotReady
}
let prompt = VLPrompts.indicatorsFromText(text)
var collected = ""
do {
// , token;LLM VL AIRuntime
let stream = await AIRuntime.shared.generate(prompt: prompt, maxTokens: 2048)
for try await chunk in stream {
collected += chunk.text
}
} catch {
throw CaptureError.inferenceFailed("\(error)")
}
// Qwen3 <think></think>, JSON
let cleaned = CaptureService.stripThink(collected)
#if DEBUG
print("🧠 [recognizeIndicators] LLM cleaned output:\n\(cleaned)\n--- end LLM ---")
#endif
do {
return try CaptureService.parseIndicatorsJSON(cleaned)
} catch let CaptureError.parseFailed(msg) {
throw CaptureError.parseFailed(msg)
} catch {
throw CaptureError.parseFailed("\(error)")
}
}
/// Qwen3 <think></think>( / / ), trim
/// HealthExportService.stripThinkBlocks , MainActor actor, nonisolated
nonisolated static func stripThink(_ raw: String) -> String {
var s = raw
while let openR = s.range(of: "<think>"),
let closeR = s.range(of: "</think>", range: openR.upperBound..<s.endIndex) {
s.removeSubrange(openR.lowerBound..<closeR.upperBound)
}
if let openR = s.range(of: "<think>") { s = String(s[..<openR.lowerBound]) }
if let closeR = s.range(of: "</think>") { s = String(s[closeR.upperBound...]) }
while let first = s.first, first.isWhitespace { s.removeFirst() }
return s
}
/// VL + JSON assets Vault
private func runVL(on assets: [FileVault.SavedAsset]) async throws -> ParsedReport {
do {
@@ -344,7 +404,36 @@ actor CaptureService {
let range = stringValue(d, keys: ["range", "reference", "reference_range", "ref", "参考", "参考值", "参考范围", "正常范围"]) ?? ""
let statusRaw = stringValue(d, keys: ["status", "flag", "abnormal", "异常", "提示", "标记"])
let status = parseIndicatorStatus(raw: statusRaw, value: value, range: range)
return .init(name: name, value: value, unit: unit, range: range, status: status)
let evidence = parseEvidenceLocation(d)
return .init(
name: name,
value: value,
unit: unit,
range: range,
status: status,
sourcePageIndex: evidence?.pageIndex,
sourceBoxX: evidence?.box.x,
sourceBoxY: evidence?.box.y,
sourceBoxWidth: evidence?.box.width,
sourceBoxHeight: evidence?.box.height
)
}
private static func parseEvidenceLocation(_ d: [String: Any]) -> (pageIndex: Int, box: (x: Double, y: Double, width: Double, height: Double))? {
guard let page = intValue(d, keys: ["source_page", "sourcePage", "page", "页码", "来源页码"]),
page >= 1,
let box = numberArrayValue(d, keys: ["source_box", "sourceBox", "box", "bbox", "位置", "来源位置"]),
box.count == 4 else {
return nil
}
let x = box[0]
let y = box[1]
let width = box[2]
let height = box[3]
guard x >= 0, y >= 0, width > 0, height > 0, x + width <= 1, y + height <= 1 else {
return nil
}
return (page - 1, (x, y, width, height))
}
private static func stringValue(_ d: [String: Any], keys: [String]) -> String? {
@@ -359,6 +448,44 @@ actor CaptureService {
return nil
}
private static func intValue(_ d: [String: Any], keys: [String]) -> Int? {
for key in keys {
if let i = d[key] as? Int {
return i
}
if let n = d[key] as? NSNumber {
return n.intValue
}
if let s = d[key] as? String, let i = Int(s.trimmingCharacters(in: .whitespacesAndNewlines)) {
return i
}
}
return nil
}
private static func numberArrayValue(_ d: [String: Any], keys: [String]) -> [Double]? {
for key in keys {
if let arr = d[key] as? [Double] {
return arr
}
if let arr = d[key] as? [NSNumber] {
return arr.map(\.doubleValue)
}
if let arr = d[key] as? [Any] {
let values = arr.compactMap { item -> Double? in
if let d = item as? Double { return d }
if let n = item as? NSNumber { return n.doubleValue }
if let s = item as? String { return Double(s.trimmingCharacters(in: .whitespacesAndNewlines)) }
return nil
}
if values.count == arr.count {
return values
}
}
}
return nil
}
private static func arrayValue(_ d: [String: Any], keys: [String]) -> [[String: Any]] {
for key in keys {
if let arr = d[key] as? [[String: Any]] {
@@ -480,7 +607,12 @@ extension Report {
status: p.status,
capturedAt: reportDate,
report: self,
source: .report
source: .report,
sourcePageIndex: p.sourcePageIndex,
sourceBoxX: p.sourceBoxX,
sourceBoxY: p.sourceBoxY,
sourceBoxWidth: p.sourceBoxWidth,
sourceBoxHeight: p.sourceBoxHeight
)
ctx.insert(i)
}

View File

@@ -0,0 +1,181 @@
import Foundation
/// ##
///
/// `docs/superpowers/specs/2026-06-07-export-indicator-trends-design.md`:
/// 2 ,
/// ( + + + ),** LLM**, `ReportCompareService` ,
/// (§10#5 退 / §12#6 )
struct ExportTrend: Sendable {
enum Direction: Sendable {
case up, down, flat
var arrow: String {
switch self {
case .up: return ""
case .down: return ""
case .flat: return ""
}
}
}
let title: String
let unit: String
/// "152138" "152/96138/88"
let valueText: String
let direction: Direction
/// , "90-140";( / ) nil
let rangeText: String?
///
let spanDays: Int
///
let count: Int
/// ,
let flagged: Bool
/// :` 152138 mmHg 90-140 21 4 `
func line() -> String {
var s = flagged ? "⚠️ " : ""
s += title
s += " \(valueText)"
if !unit.isEmpty { s += " \(unit)" }
s += " \(direction.arrow)"
if let r = rangeText, !r.isEmpty { s += "(参考 \(r)" }
s += ",近 \(spanDays)\(count)"
return s
}
}
enum ExportTrendBuilder {
/// : < 5% ()
static let flatThreshold = 0.05
///
/// - Parameters:
/// - allInWindow: ****()
/// - relevant: ****() series
/// - profile: ( SeriesBucket)
/// - customMetrics: , series /
/// - Returns: ,
static func build(allInWindow: [Indicator],
relevant: [Indicator],
profile: UserProfile? = nil,
customMetrics: [CustomMonitorMetric] = []) -> [ExportTrend] {
let relevantIDs = Set(relevant.compactMap { bucketID(for: $0) })
guard !relevantIDs.isEmpty else { return [] }
// Trends : seriesKey name+unit 退minPoints2
let buckets = SeriesBucket.build(from: allInWindow,
profile: profile,
customMetrics: customMetrics,
minPoints: 2)
let trends = buckets
.filter { relevantIDs.contains($0.id) }
.compactMap { trend(from: $0) }
// ,
return trends.sorted { lhs, rhs in
if lhs.flagged != rhs.flagged { return lhs.flagged }
return lhs.spanDays >= rhs.spanDays // , buckets
}
}
/// SeriesBucket id( `SeriesBucket.build` id )
/// nil series()
static func bucketID(for i: Indicator) -> String? {
if let k = i.seriesKey, !k.isEmpty {
if k == "bp.systolic" || k == "bp.diastolic" { return "bp" }
return k
}
let nk = SeriesBucket.normalizedKey(name: i.name, unit: i.unit)
return nk.isEmpty ? nil : "lab:\(nk)"
}
// MARK: - Private
private static func trend(from bucket: SeriesBucket) -> ExportTrend? {
if bucket.id == "bp" { return bpTrend(from: bucket) }
guard let line = bucket.lines.first,
line.points.count >= 2,
let first = line.points.first,
let last = line.points.last else { return nil }
return ExportTrend(
title: bucket.title,
unit: bucket.unit,
valueText: "\(num(first.value))\(num(last.value))",
direction: direction(first: first.value, last: last.value),
rangeText: rangeText(line.referenceRange),
spanDays: spanDays(first.date, last.date),
count: line.points.count,
flagged: last.status != .normal
|| crossedBoundary(first: first.status, last: last.status)
)
}
/// : + ,;(/,)
private static func bpTrend(from bucket: SeriesBucket) -> ExportTrend? {
guard let sys = bucket.lines.first(where: { $0.seriesKey == "bp.systolic" }),
sys.points.count >= 2,
let sFirst = sys.points.first,
let sLast = sys.points.last else { return nil }
let dia = bucket.lines.first { $0.seriesKey == "bp.diastolic" }
let dFirst = dia?.points.first
let dLast = dia?.points.last
let valueText: String
if let dFirst, let dLast {
valueText = "\(num(sFirst.value))/\(num(dFirst.value))\(num(sLast.value))/\(num(dLast.value))"
} else {
valueText = "\(num(sFirst.value))\(num(sLast.value))"
}
let sysFlag = sLast.status != .normal
|| crossedBoundary(first: sFirst.status, last: sLast.status)
let diaFlag = dLast.map { $0.status != .normal } ?? false
return ExportTrend(
title: bucket.title,
unit: bucket.unit,
valueText: valueText,
direction: direction(first: sFirst.value, last: sLast.value),
rangeText: nil,
spanDays: spanDays(sFirst.date, sLast.date),
count: sys.points.count,
flagged: sysFlag || diaFlag
)
}
static func direction(first: Double, last: Double) -> ExportTrend.Direction {
let delta = last - first
let base = abs(first)
let rel = base > 0 ? abs(delta) / base : abs(delta)
if rel < flatThreshold { return .flat }
return delta > 0 ? .up : .down
}
/// ()
static func crossedBoundary(first: IndicatorStatus, last: IndicatorStatus) -> Bool {
(first == .normal) != (last == .normal)
}
static func spanDays(_ from: Date, _ to: Date) -> Int {
let days = to.timeIntervalSince(from) / 86400
return max(1, Int(days.rounded()))
}
static func rangeText(_ r: ClosedRange<Double>?) -> String? {
guard let r else { return nil }
return "\(num(r.lowerBound))-\(num(r.upperBound))"
}
/// :, 0(138.0"138",6.10"6.1")
static func num(_ v: Double) -> String {
if v.truncatingRemainder(dividingBy: 1) == 0 { return String(Int(v)) }
return String(format: "%g", v)
}
}

View File

@@ -0,0 +1,47 @@
import Foundation
struct HealthExportDialogueTurn: Identifiable, Hashable, Sendable {
enum Role: String, Sendable {
case user
case assistant
var transcriptLabel: String {
switch self {
case .user: return String(appLoc: "患者")
case .assistant: return String(appLoc: "康康")
}
}
}
let id: UUID
var role: Role
var text: String
var createdAt: Date
init(role: Role, text: String, createdAt: Date = .now, id: UUID = UUID()) {
self.id = id
self.role = role
self.text = text
self.createdAt = createdAt
}
static func user(_ text: String) -> HealthExportDialogueTurn {
HealthExportDialogueTurn(role: .user, text: text)
}
static func assistant(_ text: String) -> HealthExportDialogueTurn {
HealthExportDialogueTurn(role: .assistant, text: text)
}
static func transcript(from turns: [HealthExportDialogueTurn]) -> String {
turns
.compactMap { turn -> String? in
let cleaned = turn.text
.trimmingCharacters(in: .whitespacesAndNewlines)
.replacingOccurrences(of: "\n", with: " ")
guard !cleaned.isEmpty else { return nil }
return "\(turn.role.transcriptLabel): \(cleaned)"
}
.joined(separator: "\n")
}
}

View File

@@ -135,6 +135,13 @@ struct HealthExportService {
throw ServiceError.generationFailed("模型未输出任何内容")
}
// ( LLM,)
let trendBlock = Self.trendSection(snapshot.trends)
if !trendBlock.isEmpty {
generated += trendBlock
continuation.yield(.token(TokenChunk(text: trendBlock, decodeRate: 0)))
}
// Phase 4:
let export = HealthExport(
prompt: prompt,
@@ -170,6 +177,146 @@ struct HealthExportService {
}
}
/// ,
func answer(question: String,
conversation: [HealthExportDialogueTurn],
in modelContext: ModelContext) -> AsyncThrowingStream<TokenChunk, Error> {
AsyncThrowingStream { continuation in
let task = Task { @MainActor in
do {
do {
try await AIRuntime.shared.prepare()
} catch {
throw ServiceError.modelNotReady
}
let snapshot = Self.retrieveDialogueSnapshot(ctx: modelContext)
let dataJSON = Self.serializeData(snapshot: snapshot)
let transcript = HealthExportDialogueTurn.transcript(from: conversation)
let prompt = HealthExportPrompts.dialogueAnswer(
latestQuestion: question,
transcript: transcript,
dataJSON: dataJSON
)
var displayed = ""
var rawAccum = ""
let stream = await AIRuntime.shared.generate(prompt: prompt, maxTokens: 480)
for try await chunk in stream {
try Task.checkCancellation()
rawAccum += chunk.text
let clean = Self.stripThinkBlocks(rawAccum)
if clean.count > displayed.count, clean.hasPrefix(displayed) {
let delta = String(clean.dropFirst(displayed.count))
displayed = clean
continuation.yield(TokenChunk(text: delta, decodeRate: chunk.decodeRate))
} else if clean != displayed {
displayed = clean
}
}
guard !displayed.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
throw ServiceError.generationFailed("模型未输出任何内容")
}
continuation.finish()
} catch is CancellationError {
continuation.finish(throwing: ServiceError.cancelled)
} catch let e as ServiceError {
continuation.finish(throwing: e)
} catch {
continuation.finish(throwing: ServiceError.generationFailed("\(error)"))
}
}
continuation.onTermination = { _ in task.cancel() }
}
}
/// HealthExport
func export(conversation: [HealthExportDialogueTurn],
in modelContext: ModelContext) -> AsyncThrowingStream<Event, Error> {
AsyncThrowingStream { continuation in
let task = Task { @MainActor in
do {
do {
try await AIRuntime.shared.prepare()
} catch {
throw ServiceError.modelNotReady
}
continuation.yield(.phaseChanged(.retrieving))
let snapshot = Self.retrieveDialogueSnapshot(ctx: modelContext)
let dataJSON = Self.serializeData(snapshot: snapshot)
let transcript = HealthExportDialogueTurn.transcript(from: conversation)
try Task.checkCancellation()
continuation.yield(.phaseChanged(.generating))
let genPrompt = HealthExportPrompts.dialogueReportGeneration(
transcript: transcript,
dataJSON: dataJSON
)
var generated = ""
var rawAccum = ""
var lastRate: Double = 0
let stream = await AIRuntime.shared.generate(prompt: genPrompt, maxTokens: 1200)
for try await chunk in stream {
try Task.checkCancellation()
if chunk.decodeRate > 0 { lastRate = chunk.decodeRate }
rawAccum += chunk.text
let clean = Self.stripThinkBlocks(rawAccum)
if clean.count > generated.count, clean.hasPrefix(generated) {
let delta = String(clean.dropFirst(generated.count))
generated = clean
continuation.yield(.token(TokenChunk(text: delta, decodeRate: chunk.decodeRate)))
} else if clean != generated {
generated = clean
}
}
guard !generated.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
throw ServiceError.generationFailed("模型未输出任何内容")
}
// ( LLM,)
let trendBlock = Self.trendSection(snapshot.trends)
if !trendBlock.isEmpty {
generated += trendBlock
continuation.yield(.token(TokenChunk(text: trendBlock, decodeRate: 0)))
}
let export = HealthExport(
prompt: transcript,
content: generated,
referencedIndicatorIDs: snapshot.indicators.map { Self.idString($0.persistentModelID) },
referencedReportIDs: [],
referencedSymptomIDs: [],
referencedDiaryIDs: snapshot.diaries.map { Self.idString($0.persistentModelID) },
inferredTimeFromDate: snapshot.fromDate,
inferredTimeToDate: snapshot.toDate,
inferredIntent: "dialogue_export",
inferredLabelCN: "对话整理",
modelTag: ModelKind.llm.rawValue,
decodeRate: lastRate
)
modelContext.insert(export)
do { try modelContext.save() } catch {
print("[HealthExportService] save failed: \(error)")
}
continuation.yield(.phaseChanged(.completed))
continuation.yield(.completed(persistentID: export.persistentModelID))
continuation.finish()
} catch is CancellationError {
continuation.finish(throwing: ServiceError.cancelled)
} catch let e as ServiceError {
continuation.finish(throwing: e)
} catch {
continuation.finish(throwing: ServiceError.generationFailed("\(error)"))
}
}
continuation.onTermination = { _ in task.cancel() }
}
}
// MARK: - Phase 1: intent extraction
struct Intent: Sendable {
@@ -251,6 +398,8 @@ struct HealthExportService {
var reports: [Report]
var diaries: [DiaryEntry]
var profile: UserProfile
/// (, LLM) ##
var trends: [ExportTrend] = []
}
/// SwiftData @MainActor
@@ -265,7 +414,8 @@ struct HealthExportService {
predicate: #Predicate { $0.capturedAt >= fromDate && $0.capturedAt <= toDate },
sortBy: [SortDescriptor(\.capturedAt, order: .reverse)]
)
var indicators = (try? ctx.fetch(indDesc)) ?? []
let allInWindow = (try? ctx.fetch(indDesc)) ?? []
var indicators = allInWindow
if !intent.keywords.isEmpty {
let filtered = indicators.filter { ind in
intent.keywords.contains { kw in
@@ -328,6 +478,14 @@ struct HealthExportService {
// Profile()
let profile = UserProfileStore.loadOrCreate(in: ctx)
// (, LLM)
// in-window ; indicators series
let trends = ExportTrendBuilder.build(
allInWindow: allInWindow,
relevant: indicators,
profile: profile
)
return Snapshot(
fromDate: fromDate,
toDate: toDate,
@@ -335,8 +493,44 @@ struct HealthExportService {
symptoms: symptoms,
reports: reports,
diaries: diaries,
profile: profile,
trends: trends
)
}
/// 使 + prompt ,
static func retrieveDialogueSnapshot(ctx: ModelContext) -> Snapshot {
let indicatorDesc = FetchDescriptor<Indicator>(
sortBy: [SortDescriptor(\.capturedAt, order: .reverse)]
)
let diaryDesc = FetchDescriptor<DiaryEntry>(
sortBy: [SortDescriptor(\.createdAt, order: .reverse)]
)
let indicators = (try? ctx.fetch(indicatorDesc)) ?? []
let diaries = (try? ctx.fetch(diaryDesc)) ?? []
let profile = UserProfileStore.loadOrCreate(in: ctx)
let dates = indicators.map(\.capturedAt) + diaries.map(\.createdAt)
let fromDate = dates.min() ?? Date()
let toDate = dates.max() ?? Date()
// ,
let trends = ExportTrendBuilder.build(
allInWindow: indicators,
relevant: indicators,
profile: profile
)
return Snapshot(
fromDate: fromDate,
toDate: toDate,
indicators: indicators,
symptoms: [],
reports: [],
diaries: diaries,
profile: profile,
trends: trends
)
}
// MARK: - Phase 3: serialize data for prompt
@@ -480,6 +674,12 @@ struct HealthExportService {
"""
}
/// LLM ## ()
static func trendSection(_ trends: [ExportTrend]) -> String {
guard !trends.isEmpty else { return "" }
return "\n\n## 指标趋势\n" + trends.map { $0.line() }.joined(separator: "\n")
}
// MARK: - Helpers
/// SwiftData persistentModelID

View File

@@ -0,0 +1,188 @@
import Foundation
import HealthKit
struct HealthProfileImportDraft: Identifiable, Equatable {
let id = UUID()
var birthYear: Int?
var biologicalSexRaw: String?
var heightCM: Int?
var bloodTypeRaw: String?
var hasAnyImportableField: Bool {
birthYear != nil ||
biologicalSexRaw != nil ||
heightCM != nil ||
bloodTypeRaw != nil
}
func apply(to profile: UserProfile, now: Date = .now) {
if let birthYear { profile.birthYear = birthYear }
if let biologicalSexRaw { profile.biologicalSexRaw = biologicalSexRaw }
if let heightCM { profile.heightCM = heightCM }
if let bloodTypeRaw { profile.bloodTypeRaw = bloodTypeRaw }
profile.updatedAt = now
}
}
struct HealthProfileImportPreview {
struct Field: Equatable {
let title: String
let current: String
let imported: String?
var willUpdate: Bool {
guard let imported else { return false }
return imported != current
}
}
let birthYear: Field
let sex: Field
let height: Field
let bloodType: Field
var fields: [Field] { [birthYear, sex, height, bloodType] }
init(draft: HealthProfileImportDraft, current profile: UserProfile) {
birthYear = Field(
title: String(appLoc: "出生年份"),
current: profile.birthYear.map(String.init) ?? String(appLoc: "未设置"),
imported: draft.birthYear.map(String.init)
)
sex = Field(
title: String(appLoc: "性别"),
current: profile.sex.label,
imported: draft.biologicalSexRaw.map { Self.sexLabel(raw: $0) }
)
height = Field(
title: String(appLoc: "身高"),
current: profile.heightCM.map { "\($0)cm" } ?? String(appLoc: "未设置"),
imported: draft.heightCM.map { "\($0)cm" }
)
bloodType = Field(
title: String(appLoc: "血型"),
current: profile.bloodTypeRaw.isEmpty ? String(appLoc: "未设置") : "\(profile.bloodTypeRaw)",
imported: draft.bloodTypeRaw.map { "\($0)" }
)
}
private static func sexLabel(raw: String) -> String {
(UserProfile.Sex(rawValue: raw) ?? .undisclosed).label
}
}
enum HealthProfileImportError: LocalizedError {
case unavailable
case noReadableFields
var errorDescription: String? {
switch self {
case .unavailable:
return String(appLoc: "这台设备暂不支持读取 Apple 健康资料。")
case .noReadableFields:
return String(appLoc: "Apple 健康里没有可导入的生日、性别、身高或血型。")
}
}
}
struct HealthProfileImportService {
static let shared = HealthProfileImportService()
private let store = HKHealthStore()
func fetchDraft() async throws -> HealthProfileImportDraft {
guard HKHealthStore.isHealthDataAvailable() else {
throw HealthProfileImportError.unavailable
}
let readTypes = readObjectTypes()
try await requestReadAuthorization(for: readTypes)
async let birthYear = readBirthYear()
async let sex = readBiologicalSexRaw()
async let height = readLatestHeightCM()
async let bloodType = readBloodTypeRaw()
let draft = HealthProfileImportDraft(
birthYear: try await birthYear,
biologicalSexRaw: try await sex,
heightCM: try await height,
bloodTypeRaw: try await bloodType
)
guard draft.hasAnyImportableField else {
throw HealthProfileImportError.noReadableFields
}
return draft
}
private func readObjectTypes() -> Set<HKObjectType> {
var types: Set<HKObjectType> = [
HKObjectType.characteristicType(forIdentifier: .dateOfBirth)!,
HKObjectType.characteristicType(forIdentifier: .biologicalSex)!,
HKObjectType.characteristicType(forIdentifier: .bloodType)!,
]
if let height = HKObjectType.quantityType(forIdentifier: .height) {
types.insert(height)
}
return types
}
private func requestReadAuthorization(for readTypes: Set<HKObjectType>) async throws {
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
store.requestAuthorization(toShare: [], read: readTypes) { _, error in
if let error {
continuation.resume(throwing: error)
} else {
continuation.resume()
}
}
}
}
private func readBirthYear() throws -> Int? {
try store.dateOfBirthComponents().year
}
private func readBiologicalSexRaw() throws -> String? {
switch try store.biologicalSex().biologicalSex {
case .female: return "female"
case .male: return "male"
default: return nil
}
}
private func readBloodTypeRaw() throws -> String? {
switch try store.bloodType().bloodType {
case .aPositive, .aNegative: return "A"
case .bPositive, .bNegative: return "B"
case .abPositive, .abNegative: return "AB"
case .oPositive, .oNegative: return "O"
default: return nil
}
}
private func readLatestHeightCM() async throws -> Int? {
guard let heightType = HKObjectType.quantityType(forIdentifier: .height) else {
return nil
}
let sort = NSSortDescriptor(key: HKSampleSortIdentifierEndDate, ascending: false)
return try await withCheckedThrowingContinuation { continuation in
let query = HKSampleQuery(
sampleType: heightType,
predicate: nil,
limit: 1,
sortDescriptors: [sort]
) { _, samples, error in
if let error {
continuation.resume(throwing: error)
return
}
let sample = samples?.first as? HKQuantitySample
let cm = sample?.quantity.doubleValue(for: .meterUnit(with: .centi))
continuation.resume(returning: cm.map { Int($0.rounded()) })
}
store.execute(query)
}
}
}

View File

@@ -0,0 +1,69 @@
import Foundation
import Vision
import UIKit
enum OCRError: Error {
case noImage
}
/// (Apple Vision,100% ,)
/// · :VL , OCR , LLM
enum OCRService {
/// ,()
/// ;,便 LLM
static func recognizeText(in cgImage: CGImage) async throws -> String {
try await withCheckedThrowingContinuation { (cont: CheckedContinuation<String, Error>) in
DispatchQueue.global(qos: .userInitiated).async {
let request = VNRecognizeTextRequest()
request.recognitionLevel = .accurate
request.usesLanguageCorrection = true
// (/)+ ;
request.recognitionLanguages = ["zh-Hans", "zh-Hant", "en-US"]
let handler = VNImageRequestHandler(cgImage: cgImage, orientation: .up, options: [:])
do {
try handler.perform([request])
let obs = (request.results as? [VNRecognizedTextObservation]) ?? []
cont.resume(returning: assemble(obs))
} catch {
cont.resume(throwing: error)
}
}
}
}
/// UIImage 便
static func recognizeText(in image: UIImage) async throws -> String {
guard let cg = image.cgImage else { throw OCRError.noImage }
return try await recognizeText(in: cg)
}
/// observation
/// Vision y ; midY ( y ), minX
private static func assemble(_ obs: [VNRecognizedTextObservation]) -> String {
let items: [(rect: CGRect, text: String)] = obs.compactMap { o in
guard let t = o.topCandidates(1).first?.string, !t.isEmpty else { return nil }
return (o.boundingBox, t)
}
guard !items.isEmpty else { return "" }
let sorted = items.sorted { $0.rect.midY > $1.rect.midY }
let yTol: CGFloat = 0.012 // (); cell midY <
var rows: [[(rect: CGRect, text: String)]] = []
var rowY: [CGFloat] = [] // midY ,()
for item in sorted {
if let i = rows.indices.last, abs(rowY[i] - item.rect.midY) < yTol {
rows[i].append(item)
rowY[i] = (rowY[i] * CGFloat(rows[i].count - 1) + item.rect.midY) / CGFloat(rows[i].count)
} else {
rows.append([item])
rowY.append(item.rect.midY)
}
}
return rows.map { row in
row.sorted { $0.rect.minX < $1.rect.minX }
.map(\.text)
.joined(separator: " ")
}.joined(separator: "\n")
}
}

View File

@@ -10,5 +10,7 @@
-->
<key>com.apple.developer.kernel.increased-memory-limit</key>
<true/>
<key>com.apple.developer.healthkit</key>
<true/>
</dict>
</plist>