```
feat: 添加拍药盒功能和语音直达入口 - 实现拍药盒扫描流程,支持本地OCR识别药品信息 - 在日记页面添加拍药盒和记症状的三选一入口 - 优化按钮点击区域,确保符合苹果HIG最小命中区标准 - 添加用药记录到时间线的独立分类显示 - 实现长按+号语音直达功能,支持语音意图分类跳转 - 更新项目配置文件,启用代码分析和死代码剥离选项 - 增加多项本地化字符串支持新功能 ```
This commit is contained in:
11
KangkangWidget-src/KangkangWidgetBundle.swift
Normal file
11
KangkangWidget-src/KangkangWidgetBundle.swift
Normal file
@@ -0,0 +1,11 @@
|
||||
import WidgetKit
|
||||
import SwiftUI
|
||||
|
||||
/// KangkangWidget extension 入口。
|
||||
/// W5 做 Live Activity 时,把 ActivityConfiguration 也注册进这个 Bundle。
|
||||
@main
|
||||
struct KangkangWidgetBundle: WidgetBundle {
|
||||
var body: some Widget {
|
||||
PinnedIndicatorsWidget()
|
||||
}
|
||||
}
|
||||
249
KangkangWidget-src/PinnedIndicatorsWidget.swift
Normal file
249
KangkangWidget-src/PinnedIndicatorsWidget.swift
Normal file
@@ -0,0 +1,249 @@
|
||||
import WidgetKit
|
||||
import SwiftUI
|
||||
|
||||
// MARK: - 快照模型(主 App 的独立拷贝)
|
||||
//
|
||||
// ⚠️ 同步契约:与主 App `康康/Persistence/WidgetSnapshot.swift` 字段必须一致。
|
||||
// extension 不引主 App 代码(免去 target membership 配置),改字段时两边一起改。
|
||||
|
||||
private struct WidgetSnapshot: Codable, Equatable {
|
||||
struct Item: Codable, Equatable {
|
||||
var name: String
|
||||
var value: String
|
||||
var unit: String
|
||||
var statusRaw: String // high|low|normal
|
||||
var capturedAt: Date
|
||||
}
|
||||
|
||||
var updatedAt: Date
|
||||
var items: [Item]
|
||||
|
||||
static let appGroupID = "group.com.xuhuayong.kangkang"
|
||||
static let storeKey = "kk.widget.snapshot.v1"
|
||||
|
||||
static func load() -> WidgetSnapshot? {
|
||||
guard let defaults = UserDefaults(suiteName: appGroupID),
|
||||
let data = defaults.data(forKey: storeKey) else { return nil }
|
||||
return try? JSONDecoder().decode(WidgetSnapshot.self, from: data)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 调色(镜像主 App Tj.Palette,extension 不引 DesignSystem)
|
||||
|
||||
private enum KkColor {
|
||||
static let sand = Color(red: 0.976, green: 0.969, blue: 0.949)
|
||||
static let ink = Color(red: 0.165, green: 0.153, blue: 0.137)
|
||||
static let text = Color(red: 0.149, green: 0.137, blue: 0.118)
|
||||
static let text2 = Color(red: 0.420, green: 0.408, blue: 0.384)
|
||||
static let text3 = Color(red: 0.616, green: 0.604, blue: 0.580)
|
||||
static let brick = Color(red: 0.886, green: 0.388, blue: 0.314) // high
|
||||
static let amber = Color(red: 0.871, green: 0.627, blue: 0.314) // low
|
||||
static let leaf = Color(red: 0.180, green: 0.357, blue: 0.518) // normal
|
||||
}
|
||||
|
||||
private func statusColor(_ raw: String) -> Color {
|
||||
switch raw {
|
||||
case "high": return KkColor.brick
|
||||
case "low": return KkColor.amber
|
||||
default: return KkColor.leaf
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Timeline
|
||||
|
||||
private struct PinnedEntry: TimelineEntry {
|
||||
let date: Date
|
||||
let items: [WidgetSnapshot.Item]
|
||||
let updatedAt: Date?
|
||||
}
|
||||
|
||||
private struct PinnedProvider: TimelineProvider {
|
||||
func placeholder(in context: Context) -> PinnedEntry {
|
||||
PinnedEntry(date: .now, items: Self.sampleItems, updatedAt: .now)
|
||||
}
|
||||
|
||||
func getSnapshot(in context: Context, completion: @escaping (PinnedEntry) -> Void) {
|
||||
if context.isPreview {
|
||||
completion(placeholder(in: context))
|
||||
} else {
|
||||
completion(currentEntry())
|
||||
}
|
||||
}
|
||||
|
||||
func getTimeline(in context: Context, completion: @escaping (Timeline<PinnedEntry>) -> Void) {
|
||||
// 数据由主 App 写快照后 reloadAllTimelines 主动推;这里 30 分钟兜底刷一次
|
||||
// (只为让"x 天前"的相对时间不至于太陈旧)。
|
||||
let entry = currentEntry()
|
||||
let next = Calendar.current.date(byAdding: .minute, value: 30, to: .now) ?? .now
|
||||
completion(Timeline(entries: [entry], policy: .after(next)))
|
||||
}
|
||||
|
||||
private func currentEntry() -> PinnedEntry {
|
||||
let snap = WidgetSnapshot.load()
|
||||
return PinnedEntry(date: .now, items: snap?.items ?? [], updatedAt: snap?.updatedAt)
|
||||
}
|
||||
|
||||
static let sampleItems: [WidgetSnapshot.Item] = [
|
||||
.init(name: "收缩压", value: "128", unit: "mmHg", statusRaw: "normal",
|
||||
capturedAt: .now.addingTimeInterval(-3600 * 5)),
|
||||
.init(name: "空腹血糖", value: "6.4", unit: "mmol/L", statusRaw: "high",
|
||||
capturedAt: .now.addingTimeInterval(-3600 * 30)),
|
||||
.init(name: "体重", value: "68.5", unit: "kg", statusRaw: "normal",
|
||||
capturedAt: .now.addingTimeInterval(-3600 * 50)),
|
||||
.init(name: "尿酸", value: "486", unit: "μmol/L", statusRaw: "high",
|
||||
capturedAt: .now.addingTimeInterval(-3600 * 80)),
|
||||
]
|
||||
}
|
||||
|
||||
// MARK: - Views
|
||||
|
||||
private struct PinnedIndicatorsView: View {
|
||||
@Environment(\.widgetFamily) private var family
|
||||
let entry: PinnedEntry
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if entry.items.isEmpty {
|
||||
emptyView
|
||||
} else {
|
||||
switch family {
|
||||
case .systemMedium: mediumView
|
||||
default: smallView
|
||||
}
|
||||
}
|
||||
}
|
||||
.containerBackground(for: .widget) { KkColor.sand }
|
||||
}
|
||||
|
||||
private var emptyView: some View {
|
||||
VStack(spacing: 6) {
|
||||
Image(systemName: "chart.line.uptrend.xyaxis")
|
||||
.font(.system(size: 22))
|
||||
.foregroundStyle(KkColor.text3)
|
||||
Text("在康康里关注指标后\n这里会显示最新值")
|
||||
.font(.system(size: 11))
|
||||
.multilineTextAlignment(.center)
|
||||
.foregroundStyle(KkColor.text3)
|
||||
}
|
||||
}
|
||||
|
||||
/// 小尺寸:首条放大 + 其余最多 2 条小行。
|
||||
private var smallView: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
header
|
||||
if let first = entry.items.first {
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
Text(first.name)
|
||||
.font(.system(size: 11))
|
||||
.foregroundStyle(KkColor.text2)
|
||||
HStack(alignment: .firstTextBaseline, spacing: 3) {
|
||||
Text(first.value)
|
||||
.font(.system(size: 24, weight: .semibold, design: .rounded))
|
||||
.foregroundStyle(statusColor(first.statusRaw))
|
||||
Text(first.unit)
|
||||
.font(.system(size: 10))
|
||||
.foregroundStyle(KkColor.text3)
|
||||
}
|
||||
}
|
||||
}
|
||||
ForEach(entry.items.dropFirst().prefix(2), id: \.name) { item in
|
||||
compactRow(item)
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||
}
|
||||
|
||||
/// 中尺寸:两列网格,最多 6 条。
|
||||
private var mediumView: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
header
|
||||
LazyVGrid(columns: [GridItem(.flexible(), spacing: 12), GridItem(.flexible())],
|
||||
alignment: .leading, spacing: 8) {
|
||||
ForEach(entry.items.prefix(6), id: \.name) { item in
|
||||
gridCell(item)
|
||||
}
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||
}
|
||||
|
||||
private var header: some View {
|
||||
HStack(spacing: 4) {
|
||||
Text("康康 · 长期监测")
|
||||
.font(.system(size: 10, weight: .semibold))
|
||||
.foregroundStyle(KkColor.text3)
|
||||
Spacer()
|
||||
if let updatedAt = entry.updatedAt {
|
||||
Text(updatedAt, style: .relative)
|
||||
.font(.system(size: 9))
|
||||
.foregroundStyle(KkColor.text3)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func compactRow(_ item: WidgetSnapshot.Item) -> some View {
|
||||
HStack(spacing: 4) {
|
||||
Circle()
|
||||
.fill(statusColor(item.statusRaw))
|
||||
.frame(width: 5, height: 5)
|
||||
Text(item.name)
|
||||
.font(.system(size: 10))
|
||||
.foregroundStyle(KkColor.text2)
|
||||
.lineLimit(1)
|
||||
Spacer(minLength: 2)
|
||||
Text(item.value)
|
||||
.font(.system(size: 11, weight: .semibold, design: .rounded))
|
||||
.foregroundStyle(KkColor.text)
|
||||
}
|
||||
}
|
||||
|
||||
private func gridCell(_ item: WidgetSnapshot.Item) -> some View {
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
HStack(spacing: 4) {
|
||||
Circle()
|
||||
.fill(statusColor(item.statusRaw))
|
||||
.frame(width: 5, height: 5)
|
||||
Text(item.name)
|
||||
.font(.system(size: 10))
|
||||
.foregroundStyle(KkColor.text2)
|
||||
.lineLimit(1)
|
||||
}
|
||||
HStack(alignment: .firstTextBaseline, spacing: 2) {
|
||||
Text(item.value)
|
||||
.font(.system(size: 15, weight: .semibold, design: .rounded))
|
||||
.foregroundStyle(KkColor.text)
|
||||
Text(item.unit)
|
||||
.font(.system(size: 8))
|
||||
.foregroundStyle(KkColor.text3)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Widget
|
||||
|
||||
struct PinnedIndicatorsWidget: Widget {
|
||||
var body: some WidgetConfiguration {
|
||||
StaticConfiguration(kind: "PinnedIndicatorsWidget", provider: PinnedProvider()) { entry in
|
||||
PinnedIndicatorsView(entry: entry)
|
||||
}
|
||||
.configurationDisplayName("长期监测")
|
||||
.description("展示你关注的健康指标最新值。数据 100% 在本机。")
|
||||
.supportedFamilies([.systemSmall, .systemMedium])
|
||||
}
|
||||
}
|
||||
|
||||
#Preview("small", as: .systemSmall) {
|
||||
PinnedIndicatorsWidget()
|
||||
} timeline: {
|
||||
PinnedEntry(date: .now, items: PinnedProvider.sampleItems, updatedAt: .now)
|
||||
}
|
||||
|
||||
#Preview("medium", as: .systemMedium) {
|
||||
PinnedIndicatorsWidget()
|
||||
} timeline: {
|
||||
PinnedEntry(date: .now, items: PinnedProvider.sampleItems, updatedAt: .now)
|
||||
}
|
||||
52
docs/Widget接入步骤.md
Normal file
52
docs/Widget接入步骤.md
Normal file
@@ -0,0 +1,52 @@
|
||||
# 桌面 Widget 接入步骤(约 3 分钟,Xcode 操作)
|
||||
|
||||
代码已全部写好。主 App 侧(快照写入 + RootView hook)已自动编译生效;
|
||||
Widget extension 需要你在 Xcode 里建一次 target,再放入两个源文件。
|
||||
|
||||
## 1. 创建 Widget Extension target
|
||||
|
||||
1. Xcode 打开 `康康.xcodeproj` → 菜单 **File → New → Target…**
|
||||
2. 选 **iOS → Widget Extension**,点 Next
|
||||
3. Product Name 填 **`KangkangWidget`**
|
||||
- ❌ 不勾 "Include Live Activity"(W5 做 Live Activity 时再往这个 target 里加,Bundle 入口已留好注释)
|
||||
- ❌ 不勾 "Include Configuration App Intent"(我们用 StaticConfiguration)
|
||||
4. 点 Finish;弹出 "Activate scheme?" 选 **Activate**
|
||||
|
||||
## 2. 替换模板代码
|
||||
|
||||
Xcode 会在工程根目录生成 `KangkangWidget/` 文件夹(含模板 swift 文件)。
|
||||
|
||||
1. 删除模板生成的所有 `.swift` 文件(`KangkangWidget.swift`、`KangkangWidgetBundle.swift`、`AppIntent.swift` 等,**保留 `Info.plist` 和 Assets**),选 "Move to Trash"
|
||||
2. 把 `KangkangWidget-src/` 里的两个文件拖进 Xcode 的 `KangkangWidget` 文件夹(勾选 target:KangkangWidget):
|
||||
- `KangkangWidgetBundle.swift`
|
||||
- `PinnedIndicatorsWidget.swift`
|
||||
3. 拖完后可删掉暂存目录 `KangkangWidget-src/`
|
||||
|
||||
## 3. 配置 App Group(两个 target 都要)
|
||||
|
||||
数据通过 App Group UserDefaults 传递,ID 固定为 **`group.com.xuhuayong.kangkang`**。
|
||||
|
||||
1. 选中工程 → target **康康** → Signing & Capabilities → **+ Capability → App Groups** → + 添加 `group.com.xuhuayong.kangkang`
|
||||
2. target **KangkangWidget** → 同样添加 App Groups → 勾选同一个 `group.com.xuhuayong.kangkang`
|
||||
3. KangkangWidget 的 **iOS Deployment Target 改成 17.0**(模板默认可能更高)
|
||||
|
||||
> 个人开发者账号下 App Group 会自动注册;如签名报错,在两个 target 的 Signing 里确认 Team 一致。
|
||||
|
||||
## 4. 验证
|
||||
|
||||
1. scheme 切回 **康康**,跑真机/模拟器
|
||||
2. 进 App(首页出现即写入快照),回到桌面 → 长按 → 添加小组件 → 找 **康康 · 长期监测**
|
||||
3. 小/中两个尺寸都支持。没有任何 pinned 指标时显示引导文案;
|
||||
在趋势页关注指标(或 C2「关联到趋势」)后,回桌面即可看到最新值
|
||||
|
||||
## 故障排查
|
||||
|
||||
- **小组件空白/不出现**:先确认两个 target 的 App Group 勾的是同一个 ID;再确认主 App 至少前台打开过一次(快照由主 App 写)
|
||||
- **数据不更新**:快照在 App 进后台时刷新;强杀 App 不触发 `scenePhase == .background`,正常 Home 手势退出即可
|
||||
- **编译报 `containerBackground` 不存在**:KangkangWidget 的 Deployment Target 没改成 17.0
|
||||
|
||||
## 架构备忘(给后续会话)
|
||||
|
||||
- 主 App 写快照:`康康/Persistence/WidgetSnapshot.swift`(数据契约)+ `WidgetSnapshotRefresher.swift`(pinned 指标 → App Group,RootView 在启动和进后台时调用)
|
||||
- Widget 读快照:`KangkangWidget/PinnedIndicatorsWidget.swift` 内有 `WidgetSnapshot` 的**独立拷贝**(extension 不引主 App 代码)。⚠️ 改字段两边同步
|
||||
- Widget 不读 SwiftData:store 有文件保护且在主 App 沙盒,extension 锁屏时读不到;快照 = 最后一次看到的值,锁屏也能显示
|
||||
@@ -187,7 +187,7 @@
|
||||
attributes = {
|
||||
BuildIndependentTargetsInParallel = 1;
|
||||
LastSwiftUpdateCheck = 2600;
|
||||
LastUpgradeCheck = 2600;
|
||||
LastUpgradeCheck = 2650;
|
||||
TargetAttributes = {
|
||||
5E463CF82FC403BB0089145B = {
|
||||
CreatedOnToolsVersion = 26.0.1;
|
||||
@@ -296,6 +296,7 @@
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||
CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES;
|
||||
CLANG_ANALYZER_NONNULL = YES;
|
||||
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
|
||||
@@ -325,6 +326,7 @@
|
||||
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||
COPY_PHASE_STRIP = NO;
|
||||
DEAD_CODE_STRIPPING = YES;
|
||||
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||
DEVELOPMENT_TEAM = F2C8C774FG;
|
||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||
@@ -348,6 +350,7 @@
|
||||
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
||||
MTL_FAST_MATH = YES;
|
||||
ONLY_ACTIVE_ARCH = YES;
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||
};
|
||||
@@ -358,6 +361,7 @@
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||
CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES;
|
||||
CLANG_ANALYZER_NONNULL = YES;
|
||||
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
|
||||
@@ -387,6 +391,7 @@
|
||||
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||
COPY_PHASE_STRIP = NO;
|
||||
DEAD_CODE_STRIPPING = YES;
|
||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||
DEVELOPMENT_TEAM = F2C8C774FG;
|
||||
ENABLE_NS_ASSERTIONS = NO;
|
||||
@@ -403,6 +408,7 @@
|
||||
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||
MTL_ENABLE_DEBUG_INFO = NO;
|
||||
MTL_FAST_MATH = YES;
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||
SWIFT_COMPILATION_MODE = wholemodule;
|
||||
};
|
||||
name = Release;
|
||||
@@ -415,6 +421,7 @@
|
||||
CODE_SIGN_ENTITLEMENTS = "康康/康康.entitlements";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 5;
|
||||
DEAD_CODE_STRIPPING = YES;
|
||||
DEVELOPMENT_TEAM = F2C8C774FG;
|
||||
ENABLE_APP_SANDBOX = YES;
|
||||
ENABLE_HARDENED_RUNTIME = YES;
|
||||
@@ -473,6 +480,7 @@
|
||||
CODE_SIGN_ENTITLEMENTS = "康康/康康.entitlements";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 5;
|
||||
DEAD_CODE_STRIPPING = YES;
|
||||
DEVELOPMENT_TEAM = F2C8C774FG;
|
||||
ENABLE_APP_SANDBOX = YES;
|
||||
ENABLE_HARDENED_RUNTIME = YES;
|
||||
@@ -529,6 +537,7 @@
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 5;
|
||||
DEAD_CODE_STRIPPING = YES;
|
||||
DEVELOPMENT_TEAM = F2C8C774FG;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||
@@ -556,6 +565,7 @@
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 5;
|
||||
DEAD_CODE_STRIPPING = YES;
|
||||
DEVELOPMENT_TEAM = F2C8C774FG;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||
@@ -582,6 +592,7 @@
|
||||
buildSettings = {
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 5;
|
||||
DEAD_CODE_STRIPPING = YES;
|
||||
DEVELOPMENT_TEAM = F2C8C774FG;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||
@@ -608,6 +619,7 @@
|
||||
buildSettings = {
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 5;
|
||||
DEAD_CODE_STRIPPING = YES;
|
||||
DEVELOPMENT_TEAM = F2C8C774FG;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme
|
||||
LastUpgradeVersion = "2600"
|
||||
LastUpgradeVersion = "2650"
|
||||
version = "1.7">
|
||||
<BuildAction
|
||||
parallelizeBuildables = "YES"
|
||||
|
||||
43
康康/AI/Prompts/IntentPrompts.swift
Normal file
43
康康/AI/Prompts/IntentPrompts.swift
Normal file
@@ -0,0 +1,43 @@
|
||||
import Foundation
|
||||
|
||||
/// 「长按 + 语音直达」prompt:端侧语音转写文本 → LLM(MNN/SME2 主链路)分类到新建入口。
|
||||
/// 输出契约:严格 JSON `{"intent":"…"}`;解析失败/超时 → VoiceIntentService 回退关键词匹配(§3.2)。
|
||||
nonisolated enum IntentPrompts {
|
||||
|
||||
static func classify(_ utterance: String) -> String {
|
||||
classifyTemplate.replacingOccurrences(of: "{{TEXT}}", with: String(utterance.prefix(120)))
|
||||
}
|
||||
|
||||
private static let classifyTemplate: String = #"""
|
||||
你是健康 App 的语音意图分类器。用户长按「新建」按钮说了一句话,判断 ta 想打开哪个功能。
|
||||
请只输出一段合法 JSON,格式 {"intent":"<分类>"},不要解释、不要 markdown 围栏、不要任何前后缀文字。
|
||||
|
||||
分类(只能选下面其中一个):
|
||||
- "diary" 写日记,记录今天的感受、饮食、睡眠、身体状态
|
||||
- "medication" 记录用药、拍药盒、吃了什么药
|
||||
- "symptom" 记录症状,哪里不舒服(头疼、咳嗽、发烧、头晕…)
|
||||
- "indicator" 记录指标数值(血压、血糖、体重、心率、体温…)
|
||||
- "archive" 归档整份体检报告/化验单(拍报告存档)
|
||||
- "export" 生成给医生看的身体档案/健康总结
|
||||
- "reminder" 设置周期提醒
|
||||
- "unknown" 无法判断
|
||||
|
||||
规则:
|
||||
- 说到「提醒我…」一律 "reminder",即使内容涉及吃药或量血压。
|
||||
- 只是陈述吃了什么药 → "medication";只是陈述哪里不舒服 → "symptom"。
|
||||
- 既像日记又提到具体数值时,以数值为准 → "indicator"。
|
||||
|
||||
示例:
|
||||
"帮我记一下今天的血压,高压128低压85" → {"intent":"indicator"}
|
||||
"我今天有点头疼,想记录一下" → {"intent":"symptom"}
|
||||
"刚买了一盒降压药,拍一下存进去" → {"intent":"medication"}
|
||||
"今天睡得不错,写个日记" → {"intent":"diary"}
|
||||
"把这份体检报告存档" → {"intent":"archive"}
|
||||
"每天早上八点提醒我量血压" → {"intent":"reminder"}
|
||||
"整理一份给医生看的健康总结" → {"intent":"export"}
|
||||
|
||||
现在判断下面这句话,只输出 JSON。/no_think
|
||||
|
||||
用户的话:{{TEXT}}
|
||||
"""#
|
||||
}
|
||||
51
康康/AI/Prompts/MedicationPrompts.swift
Normal file
51
康康/AI/Prompts/MedicationPrompts.swift
Normal file
@@ -0,0 +1,51 @@
|
||||
import Foundation
|
||||
|
||||
/// 「拍药盒入档」prompt:Vision OCR 出药盒/说明书/处方文字后,
|
||||
/// 交 LLM(Qwen,MNN/SME2 主链路)结构化抽药品名 + 规格 + 用法。
|
||||
/// 输出契约:严格 JSON;解析失败 → UI 回退手动录入(§3.2 失败回退红线)。
|
||||
/// 注意:只做"识别入档",不做剂量推荐/用药提醒(§1 明确不做)。
|
||||
nonisolated enum MedicationPrompts {
|
||||
|
||||
static func medicationsFromText(_ ocrText: String) -> String {
|
||||
medicationsFromTextTemplate
|
||||
.replacingOccurrences(of: "{{OCR_TEXT}}", with: VLPrompts.clipOCR(ocrText, limit: 1200))
|
||||
}
|
||||
|
||||
private static let medicationsFromTextTemplate: String = #"""
|
||||
你是药品包装识别助手。下面是对一张药盒、药品说明书或处方单做 OCR 得到的纯文本,可能有错字、换行混乱或无关噪声。
|
||||
请从中提取药品信息,只输出一段合法 JSON,不要解释、不要 markdown 围栏、不要任何前后缀文字。
|
||||
|
||||
JSON schema(严格):
|
||||
{
|
||||
"medications": [
|
||||
{
|
||||
"name": string, // 药品通用名或商品名,如 "缬沙坦胶囊"
|
||||
"strength": string, // 规格,如 "80mg"、"0.5g×24片";识别不出填 ""
|
||||
"usage": string // 用法用量,如 "每日一次,一次一粒";包装上没有就填 ""
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
规则:
|
||||
- 只提取药品本身;"国药准字"批准文号、生产厂家、批号、有效期、条形码一律忽略。
|
||||
- 一张药盒通常只有 1 种药;处方单可能有多种,都要提取。
|
||||
- 不要发明药品。名称读不清的整条跳过;strength / usage 读不清就填 "",不要编造。
|
||||
- 不要输出任何服药建议或剂量调整建议,只抄录包装上已有的文字。
|
||||
- 同一药品只输出一次。
|
||||
|
||||
示例 1(药盒):
|
||||
输入 OCR 文本: 缬沙坦胶囊 80mg×7粒 国药准字H20103521 XX药业有限公司
|
||||
输出:
|
||||
{"medications":[{"name":"缬沙坦胶囊","strength":"80mg×7粒","usage":""}]}
|
||||
|
||||
示例 2(说明书含用法):
|
||||
输入 OCR 文本: 二甲双胍缓释片 0.5g×30片 用法用量:口服,一次1片,一日2次,随餐服用
|
||||
输出:
|
||||
{"medications":[{"name":"二甲双胍缓释片","strength":"0.5g×30片","usage":"口服,一次1片,一日2次,随餐服用"}]}
|
||||
|
||||
现在请解析下面这段 OCR 文本,只输出 JSON。/no_think
|
||||
|
||||
OCR 文本:
|
||||
{{OCR_TEXT}}
|
||||
"""#
|
||||
}
|
||||
@@ -32,8 +32,14 @@ struct PhotoPickerSheet: View {
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
|
||||
Button("取消", action: onCancel)
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
Button(action: onCancel) {
|
||||
Text("取消")
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
.padding(.horizontal, 24)
|
||||
.frame(minHeight: 44) // HIG 最小命中区
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
if loading {
|
||||
ProgressView().tint(Tj.Palette.ink)
|
||||
|
||||
@@ -362,10 +362,16 @@ private struct AnalyzingView: View {
|
||||
.foregroundStyle(Tj.Palette.amber)
|
||||
}
|
||||
}
|
||||
Button("取消识别 · 改为手动录入", action: onCancel)
|
||||
.font(.tjScaled( 13, weight: .medium))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
.padding(.top, 4)
|
||||
Button(action: onCancel) {
|
||||
Text("取消识别 · 改为手动录入")
|
||||
.font(.tjScaled( 13, weight: .medium))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
.padding(.horizontal, 20)
|
||||
.frame(minHeight: 44) // HIG 最小命中区
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.padding(.top, 4)
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
|
||||
@@ -11,6 +11,10 @@ struct DiaryQuickSheet: View {
|
||||
|
||||
@State private var content: String = ""
|
||||
@State private var createdAt: Date = .now
|
||||
/// 「拍药盒」分支:全屏扫描流程,确认后存为带「用药」tag 的日记。
|
||||
@State private var showMedicationScan = false
|
||||
/// 「记症状」分支:嵌套弹出 SymptomStartSheet(自带保存/取消,关闭后回到本页)。
|
||||
@State private var showSymptomStart = false
|
||||
|
||||
/// AI 辅助状态
|
||||
enum AssistPhase {
|
||||
@@ -92,6 +96,24 @@ struct DiaryQuickSheet: View {
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.bottom, 10)
|
||||
|
||||
// 入口三选一:写日记(本页)/ 拍药盒(存「用药」日记)/ 记症状(SymptomStartSheet)
|
||||
HStack(spacing: 10) {
|
||||
modeCard(icon: "pencil", title: String(appLoc: "写日记"),
|
||||
subtitle: String(appLoc: "文字或语音"), active: true) {
|
||||
contentFocused = true
|
||||
}
|
||||
modeCard(icon: "pills.fill", title: String(appLoc: "拍药盒"),
|
||||
subtitle: String(appLoc: "识别用药"), active: false) {
|
||||
showMedicationScan = true
|
||||
}
|
||||
modeCard(icon: "waveform.path.ecg", title: String(appLoc: "记症状"),
|
||||
subtitle: String(appLoc: "持续追踪"), active: false) {
|
||||
showSymptomStart = true
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.bottom, 14)
|
||||
|
||||
ScrollViewReader { proxy in
|
||||
@@ -228,6 +250,20 @@ struct DiaryQuickSheet: View {
|
||||
.presentationDragIndicator(.hidden)
|
||||
.presentationBackground(Tj.Palette.sand)
|
||||
.presentationCornerRadius(Tj.Radius.xl)
|
||||
.fullScreenCover(isPresented: $showMedicationScan) {
|
||||
MedicationScanFlow(
|
||||
onSave: { entries in
|
||||
// 落库:「用药」日记(进记录时间线)+ 同步个人资料·当前用药。
|
||||
MedicationArchiver.archive(entries: entries, in: ctx)
|
||||
dismiss()
|
||||
},
|
||||
onClose: { showMedicationScan = false }
|
||||
)
|
||||
}
|
||||
.sheet(isPresented: $showSymptomStart) {
|
||||
// 嵌套 sheet:症状表单自带保存/取消;取消回到日记,不强行关闭。
|
||||
SymptomStartSheet()
|
||||
}
|
||||
.onDisappear {
|
||||
suggestTask?.cancel()
|
||||
voiceFlowTask?.cancel()
|
||||
@@ -555,6 +591,41 @@ struct DiaryQuickSheet: View {
|
||||
.foregroundStyle(Tj.Palette.text2)
|
||||
}
|
||||
|
||||
/// 顶部入口三选一卡片(写日记 / 拍药盒 / 记症状)。active 表示当前所在模式。
|
||||
/// 竖排紧凑布局:三卡并排在 iPhone 宽度下横排放不下完整文案。
|
||||
private func modeCard(icon: String, title: String, subtitle: String,
|
||||
active: Bool, action: @escaping () -> Void) -> some View {
|
||||
Button(action: action) {
|
||||
VStack(spacing: 5) {
|
||||
Image(systemName: icon)
|
||||
.font(.tjScaled( 15, weight: .medium))
|
||||
.foregroundStyle(active ? Tj.Palette.paper : Tj.Palette.ink)
|
||||
.frame(width: 28, height: 28)
|
||||
.background(Circle().fill(active ? Tj.Palette.ink : Tj.Palette.sand2))
|
||||
Text(title)
|
||||
.font(.tjScaled( 13, weight: .semibold))
|
||||
.foregroundStyle(Tj.Palette.text)
|
||||
Text(subtitle)
|
||||
.font(.tjScaled( 10))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
.lineLimit(1)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 10)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
|
||||
.fill(Tj.Palette.paper)
|
||||
)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
|
||||
.strokeBorder(active ? Tj.Palette.ink : Tj.Palette.line,
|
||||
lineWidth: active ? 1.5 : 1)
|
||||
)
|
||||
.contentShape(RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
// MARK: 语音输入流程
|
||||
|
||||
private func startVoice() {
|
||||
|
||||
285
康康/Features/Profile/MedicationScanFlow.swift
Normal file
285
康康/Features/Profile/MedicationScanFlow.swift
Normal file
@@ -0,0 +1,285 @@
|
||||
import SwiftUI
|
||||
import SwiftData
|
||||
import UIKit
|
||||
|
||||
/// 「拍药盒入档」流程:拍药盒/说明书 → Vision OCR → LLM 结构化 → 核对 → 落库。
|
||||
/// 入口:「+ 新建 · 健康日记 · 拍药盒」与「我的 · 个人资料 · 当前用药」。
|
||||
/// 两个入口确认后都走 `MedicationArchiver`:记一条「用药」日记(进记录时间线)+ 同步当前用药。
|
||||
/// 只识别入档,不做用药提醒/剂量建议(§1)。
|
||||
///
|
||||
/// 状态机(与 QuickRegionCaptureFlow 同构):
|
||||
/// ```
|
||||
/// idle(相机/相册) → recognizing(OCR + LLM) → confirm(核对可编辑) → onSave → 关闭
|
||||
/// │ 失败/没读出 ──────► confirm(空行 + 警示文案,手动补)
|
||||
/// ```
|
||||
struct MedicationScanFlow: View {
|
||||
/// 用户确认后回传条目文本(非空,如 "缬沙坦胶囊 80mg · 一日一次")。落库由调用方做。
|
||||
let onSave: ([String]) -> Void
|
||||
let onClose: () -> Void
|
||||
|
||||
@State private var phase: Phase = .idle
|
||||
/// 识别任务句柄:识别中点「取消」要能立刻中断,不留后台推理。
|
||||
@State private var recognitionTask: Task<Void, Never>?
|
||||
|
||||
enum Phase {
|
||||
case idle
|
||||
case recognizing(image: UIImage)
|
||||
case confirm(items: [EditableMedication], warning: String?)
|
||||
}
|
||||
|
||||
struct EditableMedication: Identifiable {
|
||||
let id = UUID()
|
||||
var name: String
|
||||
var strength: String
|
||||
var usage: String
|
||||
var include: Bool = true
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
content
|
||||
.background(Tj.Palette.sand.ignoresSafeArea())
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var content: some View {
|
||||
switch phase {
|
||||
case .idle:
|
||||
// 不整体 ignoresSafeArea:相机内部已全屏黑底,忽略安全区会让「取消」顶进灵动岛。
|
||||
captureEntry
|
||||
|
||||
case .recognizing(let image):
|
||||
recognizingView(image: image)
|
||||
|
||||
case .confirm(let items, let warning):
|
||||
NavigationStack {
|
||||
MedicationConfirmView(
|
||||
items: items,
|
||||
warning: warning,
|
||||
onSave: { saveItems($0) },
|
||||
onRetake: { phase = .idle }
|
||||
)
|
||||
.navigationTitle("核对药品")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarLeading) {
|
||||
Button("取消") { onClose() }
|
||||
.foregroundStyle(Tj.Palette.text)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 入口:拍照(真机)/ 相册(模拟器)
|
||||
|
||||
@ViewBuilder
|
||||
private var captureEntry: some View {
|
||||
#if targetEnvironment(simulator)
|
||||
PhotoPickerSheet(
|
||||
onFinish: { images in
|
||||
if let first = images.first { startRecognition(first) } else { onClose() }
|
||||
},
|
||||
onCancel: onClose
|
||||
)
|
||||
#else
|
||||
SingleShotCameraView(
|
||||
onCapture: { startRecognition($0) },
|
||||
onCancel: onClose
|
||||
)
|
||||
#endif
|
||||
}
|
||||
|
||||
private func recognizingView(image: UIImage) -> some View {
|
||||
VStack(spacing: 18) {
|
||||
Image(uiImage: image)
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(maxHeight: 320)
|
||||
.clipShape(RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous))
|
||||
.padding(.horizontal, 24)
|
||||
ProgressView().tint(Tj.Palette.ink)
|
||||
Text("正在本地识别药品…")
|
||||
.font(.tjScaled(14))
|
||||
.foregroundStyle(Tj.Palette.text2)
|
||||
Text("照片与文字均不离开设备")
|
||||
.font(.tjScaled(12))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
// 识别中也要能退出,不能让用户干等(§3.2 不卡死)
|
||||
.overlay(alignment: .topLeading) {
|
||||
Button {
|
||||
recognitionTask?.cancel()
|
||||
onClose()
|
||||
} label: {
|
||||
Text("取消")
|
||||
.font(.tjScaled( 16, weight: .medium))
|
||||
.foregroundStyle(Tj.Palette.text)
|
||||
.padding(.horizontal, 18)
|
||||
.frame(minHeight: 44)
|
||||
.background(Capsule().fill(Tj.Palette.paper))
|
||||
.overlay(Capsule().strokeBorder(Tj.Palette.line, lineWidth: 1))
|
||||
.contentShape(Capsule())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.padding(.leading, 16)
|
||||
.padding(.top, 8)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 识别(整图 OCR → LLM 结构化)
|
||||
|
||||
private func startRecognition(_ image: UIImage) {
|
||||
phase = .recognizing(image: image)
|
||||
recognitionTask = Task {
|
||||
let (items, warning) = await recognize(image)
|
||||
guard !Task.isCancelled else { return } // 识别中点了取消:不再回写 phase
|
||||
await MainActor.run {
|
||||
// 全失败也不卡死:给一条空行让用户手填(§3.2 失败回退红线)。
|
||||
if items.isEmpty {
|
||||
phase = .confirm(items: [EditableMedication(name: "", strength: "", usage: "")],
|
||||
warning: warning ?? String(appLoc: "没读出药品,可以手动填写"))
|
||||
} else {
|
||||
phase = .confirm(items: items, warning: warning)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func recognize(_ image: UIImage) async -> (items: [EditableMedication], warning: String?) {
|
||||
do {
|
||||
let text = try await OCRService.recognizeText(in: image)
|
||||
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if trimmed.isEmpty {
|
||||
return ([], String(appLoc: "没识别到文字,拍清楚一点再试"))
|
||||
}
|
||||
let parsed = try await MedicationScanService.shared.recognizeMedications(fromOCRText: trimmed)
|
||||
let items = parsed.map {
|
||||
EditableMedication(name: $0.name, strength: $0.strength, usage: $0.usage)
|
||||
}
|
||||
return (items, items.isEmpty ? String(appLoc: "没读出药品,可以手动填写") : nil)
|
||||
} catch CaptureError.modelNotReady {
|
||||
return ([], String(appLoc: "AI 模型未就绪,可以手动填写"))
|
||||
} catch let CaptureError.parseFailed(msg) {
|
||||
return ([], String(appLoc: "解析失败:\(msg)"))
|
||||
} catch let CaptureError.inferenceFailed(msg) {
|
||||
return ([], String(appLoc: "识别失败:\(msg)"))
|
||||
} catch {
|
||||
return ([], String(appLoc: "未知错误:\(error.localizedDescription)"))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 保存
|
||||
|
||||
private func saveItems(_ items: [EditableMedication]) {
|
||||
let entries = items
|
||||
.filter { $0.include && !$0.name.trimmingCharacters(in: .whitespaces).isEmpty }
|
||||
.map {
|
||||
ParsedMedication(name: $0.name, strength: $0.strength, usage: $0.usage).entryText
|
||||
}
|
||||
onSave(entries)
|
||||
onClose()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 统一落库(MainActor,SwiftData 写主上下文必须由 View 侧持有的 ctx 来做,§3.1)
|
||||
|
||||
/// 拍药盒确认后的统一落库,两个入口共用:
|
||||
/// 1. 记一条带「用药」tag 的 DiaryEntry → 出现在「记录」时间线的「用药」分类
|
||||
/// 2. 同步到 UserProfile.currentMedications(去重)→ AI 解读 / 身体档案 prompt 背景
|
||||
@MainActor
|
||||
enum MedicationArchiver {
|
||||
static func archive(entries: [String], in ctx: ModelContext) {
|
||||
guard !entries.isEmpty else { return }
|
||||
let diary = DiaryEntry(content: entries.joined(separator: "\n"),
|
||||
tags: [DiaryEntry.medicationTag])
|
||||
ctx.insert(diary)
|
||||
|
||||
let profile = UserProfileStore.loadOrCreate(in: ctx)
|
||||
for entry in entries where !profile.currentMedications.contains(entry) {
|
||||
profile.currentMedications.append(entry)
|
||||
}
|
||||
profile.updatedAt = .now
|
||||
try? ctx.save()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 核对页
|
||||
|
||||
private struct MedicationConfirmView: View {
|
||||
@State var items: [MedicationScanFlow.EditableMedication]
|
||||
let warning: String?
|
||||
let onSave: ([MedicationScanFlow.EditableMedication]) -> Void
|
||||
let onRetake: () -> Void
|
||||
|
||||
private var canSave: Bool {
|
||||
items.contains {
|
||||
$0.include && !$0.name.trimmingCharacters(in: .whitespaces).isEmpty
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
Form {
|
||||
if let warning {
|
||||
Section {
|
||||
Label(warning, systemImage: "exclamationmark.triangle")
|
||||
.font(.tjScaled(13))
|
||||
.foregroundStyle(Tj.Palette.amber)
|
||||
}
|
||||
}
|
||||
|
||||
ForEach($items) { $item in
|
||||
Section {
|
||||
HStack {
|
||||
TextField(String(appLoc: "药品名,如:缬沙坦胶囊"), text: $item.name)
|
||||
.foregroundStyle(Tj.Palette.text)
|
||||
Toggle("", isOn: $item.include)
|
||||
.labelsHidden()
|
||||
.tint(Tj.Palette.ink)
|
||||
}
|
||||
TextField(String(appLoc: "规格,如:80mg×7粒"), text: $item.strength)
|
||||
.foregroundStyle(Tj.Palette.text2)
|
||||
TextField(String(appLoc: "用法,如:一日一次,一次一粒"), text: $item.usage)
|
||||
.foregroundStyle(Tj.Palette.text2)
|
||||
}
|
||||
}
|
||||
|
||||
Section {
|
||||
Button {
|
||||
items.append(.init(name: "", strength: "", usage: ""))
|
||||
} label: {
|
||||
Label("再加一种", systemImage: "plus.circle")
|
||||
.foregroundStyle(Tj.Palette.ink)
|
||||
}
|
||||
Button {
|
||||
onRetake()
|
||||
} label: {
|
||||
Label("重拍", systemImage: "camera")
|
||||
.foregroundStyle(Tj.Palette.ink)
|
||||
}
|
||||
} footer: {
|
||||
Text("将记入健康日记(记录页可查),并同步到「当前用药」供 AI 解读参考。不提供任何用药建议。")
|
||||
}
|
||||
}
|
||||
.scrollContentBackground(.hidden)
|
||||
|
||||
Button {
|
||||
onSave(items)
|
||||
} label: {
|
||||
Text("保存用药记录")
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(TjPrimaryButton())
|
||||
.disabled(!canSave)
|
||||
.opacity(canSave ? 1 : 0.4)
|
||||
.padding(.horizontal, 18)
|
||||
.padding(.bottom, 12)
|
||||
}
|
||||
.background(Tj.Palette.sand.ignoresSafeArea())
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
MedicationScanFlow(onSave: { print($0) }, onClose: {})
|
||||
}
|
||||
@@ -38,6 +38,7 @@ private struct ProfileEditForm: View {
|
||||
@State private var healthImportDraft: HealthProfileImportDraft?
|
||||
@State private var healthImportError: String?
|
||||
@State private var isImportingHealthProfile = false
|
||||
@State private var showMedicationScan = false
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
@@ -88,7 +89,8 @@ private struct ProfileEditForm: View {
|
||||
StringListSection(title: String(appLoc: "家族史"), placeholder: String(appLoc: "如:母亲 高血压"),
|
||||
items: $profile.familyHistory)
|
||||
StringListSection(title: String(appLoc: "当前用药"), placeholder: String(appLoc: "如:缬沙坦 80mg qd"),
|
||||
items: $profile.currentMedications)
|
||||
items: $profile.currentMedications,
|
||||
onScan: { showMedicationScan = true })
|
||||
}
|
||||
.navigationTitle("个人资料")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
@@ -98,6 +100,16 @@ private struct ProfileEditForm: View {
|
||||
profile.updatedAt = .now
|
||||
try? ctx.save()
|
||||
}
|
||||
.fullScreenCover(isPresented: $showMedicationScan) {
|
||||
// 拍药盒 → 本地 OCR + LLM 识别 → 核对 → 统一落库:
|
||||
// 记一条「用药」日记(进记录时间线)+ 同步当前用药(去重)。
|
||||
MedicationScanFlow(
|
||||
onSave: { entries in
|
||||
MedicationArchiver.archive(entries: entries, in: ctx)
|
||||
},
|
||||
onClose: { showMedicationScan = false }
|
||||
)
|
||||
}
|
||||
.sheet(item: $healthImportDraft) { draft in
|
||||
HealthProfileImportPreviewSheet(
|
||||
draft: draft,
|
||||
@@ -456,10 +468,27 @@ private struct StringListSection: View {
|
||||
let title: String
|
||||
let placeholder: String
|
||||
@Binding var items: [String]
|
||||
/// 非 nil 时在节内显示「拍药盒自动识别」入口(目前仅「当前用药」用)。
|
||||
var onScan: (() -> Void)? = nil
|
||||
@State private var newInput = ""
|
||||
|
||||
var body: some View {
|
||||
Section(title) {
|
||||
if let onScan {
|
||||
Button(action: onScan) {
|
||||
HStack(spacing: 10) {
|
||||
Image(systemName: "camera.viewfinder")
|
||||
.foregroundStyle(Tj.Palette.ink)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("拍药盒自动识别")
|
||||
.foregroundStyle(Tj.Palette.text)
|
||||
Text("拍药盒或说明书,本地识别药名与规格")
|
||||
.font(.tjScaled( 12))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
ForEach(items, id: \.self) { item in
|
||||
HStack {
|
||||
Text(item)
|
||||
|
||||
@@ -32,8 +32,9 @@ struct QuickRegionCaptureFlow: View {
|
||||
private var content: some View {
|
||||
switch phase {
|
||||
case .idle:
|
||||
// 不再整体 ignoresSafeArea:相机/框选内部已各自做全屏黑底,
|
||||
// 这里再忽略安全区会把「取消」顶进灵动岛,几乎点不到。
|
||||
captureEntry
|
||||
.ignoresSafeArea()
|
||||
|
||||
case .adjust(let image):
|
||||
RegionAdjustView(
|
||||
@@ -45,7 +46,6 @@ struct QuickRegionCaptureFlow: View {
|
||||
onRetake: { phase = .idle },
|
||||
onCancel: { onClose() }
|
||||
)
|
||||
.ignoresSafeArea()
|
||||
|
||||
case .confirm(let image, let items, let warning):
|
||||
NavigationStack {
|
||||
|
||||
@@ -50,7 +50,11 @@ struct RegionAdjustView: View {
|
||||
Text("取消")
|
||||
.font(.tjScaled( 16, weight: .medium))
|
||||
.foregroundStyle(.white)
|
||||
.padding(.horizontal, 12)
|
||||
.frame(minWidth: 60, minHeight: 44) // HIG 最小命中区,命中整块而非文字
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
Spacer()
|
||||
Text("框住异常指标")
|
||||
.font(.tjScaled( 16, weight: .semibold))
|
||||
@@ -63,10 +67,14 @@ struct RegionAdjustView: View {
|
||||
Text("重拍")
|
||||
.font(.tjScaled( 16, weight: .medium))
|
||||
.foregroundStyle(.white)
|
||||
.padding(.horizontal, 12)
|
||||
.frame(minWidth: 60, minHeight: 44)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
.padding(.horizontal, 18)
|
||||
.padding(.vertical, 12)
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 4)
|
||||
.background(Color.black)
|
||||
}
|
||||
|
||||
|
||||
@@ -49,13 +49,15 @@ struct SingleShotCameraView: View {
|
||||
Text("取消")
|
||||
.font(.tjScaled( 16, weight: .medium))
|
||||
.foregroundStyle(.white)
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 8)
|
||||
.padding(.horizontal, 18)
|
||||
.frame(minHeight: 44) // 苹果 HIG 最小命中区
|
||||
.background(Capsule().fill(.black.opacity(0.35)))
|
||||
.contentShape(Capsule())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal, 18)
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.top, 8)
|
||||
|
||||
Spacer()
|
||||
|
||||
@@ -5,8 +5,15 @@ enum RecordKind: String, Identifiable, CaseIterable {
|
||||
var id: String { rawValue }
|
||||
|
||||
/// RecordSheet 列表的展示顺序(从上到下)。与 enum 声明序解耦,改顺序只动这里。
|
||||
/// 注:`.quick`(指标速记)已并入 `.indicator`(记录指标)内的「拍照识别」,不再单列。
|
||||
static let displayOrder: [RecordKind] = [.diary, .reminder, .symptom, .indicator, .healthExport, .archive]
|
||||
/// 注:`.quick`(指标速记)已并入 `.indicator`(记录指标)内的「拍照识别」;
|
||||
/// `.symptom`(记录症状)与拍药盒一起并入 `.diary`(健康日记)顶部三选一,不再单列。
|
||||
static let displayOrder: [RecordKind] = [.diary, .reminder, .indicator, .healthExport, .archive]
|
||||
|
||||
/// 健康日记行的功能提示 pill(代替 subtitle,让"症状/药盒在日记里"一眼可见)。
|
||||
/// 计算属性:每次按当前语言解析,语言切换即时更新(同 ProfileEditView 的 presets 约定)。
|
||||
static var diaryFeaturePills: [String] {
|
||||
[String(appLoc: "写日记"), String(appLoc: "拍药盒"), String(appLoc: "记症状")]
|
||||
}
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
@@ -25,7 +32,7 @@ enum RecordKind: String, Identifiable, CaseIterable {
|
||||
case .indicator: return String(appLoc: "手动填写,或拍照自动识别")
|
||||
case .healthExport: return String(appLoc: "多轮问答后生成给医生看的整理报告")
|
||||
case .archive: return String(appLoc: "完整保存整份报告(可多页)")
|
||||
case .diary: return String(appLoc: "记录身体状态、用药、感受 · 可让 AI 辅助")
|
||||
case .diary: return String(appLoc: "写日记或拍药盒记录用药 · 可让 AI 辅助")
|
||||
case .symptom: return String(appLoc: "开始一个持续症状,结束时再点结束")
|
||||
case .reminder: return String(appLoc: "管理用药、复查、监测的周期提醒")
|
||||
}
|
||||
@@ -93,13 +100,27 @@ struct RecordSheet: View {
|
||||
}
|
||||
.frame(width: 44, height: 44)
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(kind.title)
|
||||
.font(.tjScaled( 15, weight: .semibold))
|
||||
.foregroundStyle(Tj.Palette.text)
|
||||
Text(kind.subtitle)
|
||||
.font(.tjScaled( 12))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
if kind == .diary {
|
||||
// 醒目提示:症状/药盒已并入日记,用 pill 直接点名
|
||||
HStack(spacing: 5) {
|
||||
ForEach(RecordKind.diaryFeaturePills, id: \.self) { pill in
|
||||
Text(pill)
|
||||
.font(.tjScaled( 10, weight: .medium))
|
||||
.foregroundStyle(Tj.Palette.ink)
|
||||
.padding(.horizontal, 7)
|
||||
.padding(.vertical, 2)
|
||||
.background(Capsule().fill(Tj.Palette.sand2))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Text(kind.subtitle)
|
||||
.font(.tjScaled( 12))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
Image(systemName: "chevron.right")
|
||||
@@ -111,6 +132,17 @@ struct RecordSheet: View {
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
// 语音直达提示:长按 + 即可说话,不用翻菜单
|
||||
HStack(spacing: 5) {
|
||||
Image(systemName: "mic.fill")
|
||||
.font(.tjScaled( 10))
|
||||
Text("下次试试长按 + ,直接说出想记的内容")
|
||||
.font(.tjScaled( 11))
|
||||
}
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.top, 6)
|
||||
}
|
||||
.padding(.bottom, 22)
|
||||
}
|
||||
|
||||
276
康康/Features/Record/VoiceCommandSheet.swift
Normal file
276
康康/Features/Record/VoiceCommandSheet.swift
Normal file
@@ -0,0 +1,276 @@
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
/// 「长按 + 语音直达」面板:开口说想记什么 → 端侧转写(SpeechDictationService)
|
||||
/// → LLM 意图分类(VoiceIntentService)→ 回调 RootView 打开对应新建入口。
|
||||
///
|
||||
/// 状态机:
|
||||
/// ```
|
||||
/// requesting(权限) → recording(实时字幕) → classifying → onResolve(intent) 关闭
|
||||
/// │ 拒绝 → denied │ 没听到/没听懂 → failed(再说一次 / 打开菜单)
|
||||
/// ```
|
||||
/// 全程本机:转写 requiresOnDeviceRecognition,分类走端侧 LLM。
|
||||
struct VoiceCommandSheet: View {
|
||||
/// 识别成功:RootView 负责关闭本 sheet 并路由。
|
||||
let onResolve: (VoiceIntent) -> Void
|
||||
/// 兜底:打开普通新建菜单(RecordSheet)。
|
||||
let onOpenMenu: () -> Void
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
enum Phase: Equatable {
|
||||
case requesting
|
||||
case denied
|
||||
case recording
|
||||
case classifying
|
||||
case failed(message: String)
|
||||
}
|
||||
|
||||
@State private var phase: Phase = .requesting
|
||||
@State private var transcript = ""
|
||||
@State private var seconds = 0
|
||||
/// @State 保证视图身份期内实例唯一(同 DiaryQuickSheet 的注释,防止重建后麦克风悬挂)。
|
||||
@State private var dictation = SpeechDictationService()
|
||||
@State private var ticker: Task<Void, Never>?
|
||||
|
||||
/// 录音超过 20s 自动结束:语音直达说的都是短句,长录是忘了点完成。
|
||||
private let maxSeconds = 20
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
Capsule()
|
||||
.fill(Tj.Palette.line)
|
||||
.frame(width: 40, height: 4)
|
||||
.padding(.top, 10)
|
||||
.padding(.bottom, 16)
|
||||
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("说出想记的内容")
|
||||
.font(.tjH2())
|
||||
.foregroundStyle(Tj.Palette.text)
|
||||
Text("比如:记一下血压 / 我头疼 / 拍个药盒")
|
||||
.font(.tjScaled( 11))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
}
|
||||
Spacer()
|
||||
Text("全程本机")
|
||||
.font(.tjScaled( 12))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.bottom, 16)
|
||||
|
||||
content
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
|
||||
.padding(.horizontal, 20)
|
||||
|
||||
buttons
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.vertical, 14)
|
||||
}
|
||||
.background(
|
||||
Tj.Palette.sand
|
||||
.clipShape(RoundedRectangle(cornerRadius: Tj.Radius.xl, style: .continuous))
|
||||
.ignoresSafeArea(edges: .bottom)
|
||||
)
|
||||
.presentationDetents([.fraction(0.5)])
|
||||
.presentationDragIndicator(.hidden)
|
||||
.presentationBackground(Tj.Palette.sand)
|
||||
.presentationCornerRadius(Tj.Radius.xl)
|
||||
.task { await begin() }
|
||||
.onDisappear {
|
||||
ticker?.cancel()
|
||||
dictation.abort()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 分阶段内容
|
||||
|
||||
@ViewBuilder
|
||||
private var content: some View {
|
||||
switch phase {
|
||||
case .requesting:
|
||||
ProgressView().tint(Tj.Palette.ink)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.top, 30)
|
||||
|
||||
case .denied:
|
||||
VStack(spacing: 10) {
|
||||
Image(systemName: "mic.slash")
|
||||
.font(.tjScaled( 30))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
Text("需要麦克风与语音识别权限")
|
||||
.font(.tjScaled( 14, weight: .semibold))
|
||||
.foregroundStyle(Tj.Palette.text)
|
||||
Text("语音和文字都只在本机处理,不会上传。")
|
||||
.font(.tjScaled( 12))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
Button("前往设置") {
|
||||
if let url = URL(string: UIApplication.openSettingsURLString) {
|
||||
UIApplication.shared.open(url)
|
||||
}
|
||||
}
|
||||
.font(.tjScaled( 13, weight: .semibold))
|
||||
.foregroundStyle(Tj.Palette.ink)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.top, 16)
|
||||
|
||||
case .recording:
|
||||
VStack(spacing: 14) {
|
||||
HStack(spacing: 8) {
|
||||
Circle()
|
||||
.fill(Tj.Palette.brick)
|
||||
.frame(width: 8, height: 8)
|
||||
Text("正在听 · \(seconds)s")
|
||||
.font(.tjScaled( 12, weight: .semibold))
|
||||
.foregroundStyle(Tj.Palette.brick)
|
||||
}
|
||||
transcriptBox(placeholder: String(appLoc: "请开口说话…"))
|
||||
}
|
||||
|
||||
case .classifying:
|
||||
VStack(spacing: 14) {
|
||||
HStack(spacing: 8) {
|
||||
ProgressView().tint(Tj.Palette.ink)
|
||||
Text("正在理解…")
|
||||
.font(.tjScaled( 12, weight: .semibold))
|
||||
.foregroundStyle(Tj.Palette.text2)
|
||||
}
|
||||
transcriptBox(placeholder: "")
|
||||
}
|
||||
|
||||
case .failed(let message):
|
||||
VStack(spacing: 10) {
|
||||
Image(systemName: "questionmark.bubble")
|
||||
.font(.tjScaled( 28))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
Text(message)
|
||||
.font(.tjScaled( 13))
|
||||
.foregroundStyle(Tj.Palette.text2)
|
||||
.multilineTextAlignment(.center)
|
||||
if !transcript.isEmpty {
|
||||
Text("“\(transcript)”")
|
||||
.font(.tjScaled( 12))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
.lineLimit(2)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.top, 12)
|
||||
}
|
||||
}
|
||||
|
||||
private func transcriptBox(placeholder: String) -> some View {
|
||||
ScrollView(showsIndicators: false) {
|
||||
Text(transcript.isEmpty ? placeholder : transcript)
|
||||
.font(.tjScaled( 15))
|
||||
.foregroundStyle(transcript.isEmpty ? Tj.Palette.text3 : Tj.Palette.text)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
.frame(minHeight: 64, maxHeight: 110)
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 12)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
|
||||
.fill(Tj.Palette.paper)
|
||||
)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
|
||||
.strokeBorder(Tj.Palette.line, lineWidth: 1)
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - 底部按钮
|
||||
|
||||
@ViewBuilder
|
||||
private var buttons: some View {
|
||||
switch phase {
|
||||
case .recording:
|
||||
HStack(spacing: 12) {
|
||||
Button("取消") { dismiss() }
|
||||
.buttonStyle(TjGhostButton(height: 44, fontSize: 15, horizontalPadding: 18))
|
||||
Button("说完了") { finishRecording() }
|
||||
.buttonStyle(TjPrimaryButton(height: 44, fontSize: 15, horizontalPadding: 18))
|
||||
}
|
||||
case .failed:
|
||||
HStack(spacing: 12) {
|
||||
Button("打开新建菜单") { onOpenMenu() }
|
||||
.buttonStyle(TjGhostButton(height: 44, fontSize: 14, horizontalPadding: 14))
|
||||
Button("再说一次") { Task { await begin() } }
|
||||
.buttonStyle(TjPrimaryButton(height: 44, fontSize: 14, horizontalPadding: 18))
|
||||
}
|
||||
case .denied:
|
||||
Button("取消") { dismiss() }
|
||||
.buttonStyle(TjGhostButton(height: 44, fontSize: 15, horizontalPadding: 18))
|
||||
case .requesting, .classifying:
|
||||
Button("取消") { dismiss() }
|
||||
.buttonStyle(TjGhostButton(height: 44, fontSize: 15, horizontalPadding: 18))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 流程
|
||||
|
||||
private func begin() async {
|
||||
ticker?.cancel()
|
||||
transcript = ""
|
||||
seconds = 0
|
||||
guard SpeechDictationService.isAvailable else {
|
||||
phase = .failed(message: String(appLoc: "本机不支持端侧语音识别,试试下面的新建菜单"))
|
||||
return
|
||||
}
|
||||
phase = .requesting
|
||||
guard await dictation.requestAuthorization() else {
|
||||
phase = .denied
|
||||
return
|
||||
}
|
||||
do {
|
||||
try dictation.start { transcript = $0 }
|
||||
phase = .recording
|
||||
startTicker()
|
||||
} catch {
|
||||
phase = .failed(message: error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
private func startTicker() {
|
||||
ticker = Task { @MainActor in
|
||||
while !Task.isCancelled {
|
||||
try? await Task.sleep(nanoseconds: 1_000_000_000)
|
||||
guard phase == .recording else { return }
|
||||
seconds += 1
|
||||
if seconds >= maxSeconds {
|
||||
finishRecording()
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func finishRecording() {
|
||||
guard phase == .recording else { return }
|
||||
ticker?.cancel()
|
||||
phase = .classifying
|
||||
Task {
|
||||
let text = await dictation.stop()
|
||||
transcript = text
|
||||
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else {
|
||||
phase = .failed(message: String(appLoc: "没听到内容,再试一次?"))
|
||||
return
|
||||
}
|
||||
if let intent = await VoiceIntentService.classify(trimmed) {
|
||||
onResolve(intent)
|
||||
} else {
|
||||
phase = .failed(message: String(appLoc: "没听懂想记什么,再说一次,或直接选菜单"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
Text("bg")
|
||||
.sheet(isPresented: .constant(true)) {
|
||||
VoiceCommandSheet(onResolve: { print($0) }, onOpenMenu: {})
|
||||
}
|
||||
}
|
||||
@@ -3,33 +3,36 @@ import SwiftData
|
||||
import Foundation
|
||||
|
||||
enum TimelineKind: String, CaseIterable, Identifiable {
|
||||
case indicator, report, symptom, diary
|
||||
case diary, symptom, indicator, medication, report
|
||||
var id: String { rawValue }
|
||||
|
||||
var label: String {
|
||||
switch self {
|
||||
case .indicator: return String(appLoc: "指标")
|
||||
case .report: return String(appLoc: "报告")
|
||||
case .symptom: return String(appLoc: "症状")
|
||||
case .diary: return String(appLoc: "日记")
|
||||
case .indicator: return String(appLoc: "指标")
|
||||
case .report: return String(appLoc: "报告")
|
||||
case .symptom: return String(appLoc: "症状")
|
||||
case .diary: return String(appLoc: "日记")
|
||||
case .medication: return String(appLoc: "用药")
|
||||
}
|
||||
}
|
||||
|
||||
var icon: String {
|
||||
switch self {
|
||||
case .indicator: return "drop.fill"
|
||||
case .report: return "doc.fill"
|
||||
case .symptom: return "waveform.path.ecg"
|
||||
case .diary: return "pencil"
|
||||
case .indicator: return "drop.fill"
|
||||
case .report: return "doc.fill"
|
||||
case .symptom: return "waveform.path.ecg"
|
||||
case .diary: return "pencil"
|
||||
case .medication: return "pills.fill"
|
||||
}
|
||||
}
|
||||
|
||||
var accent: Color {
|
||||
switch self {
|
||||
case .indicator: return Tj.Palette.brick
|
||||
case .report: return Tj.Palette.ink2
|
||||
case .symptom: return Tj.Palette.amber
|
||||
case .diary: return Tj.Palette.leaf
|
||||
case .indicator: return Tj.Palette.brick
|
||||
case .report: return Tj.Palette.ink2
|
||||
case .symptom: return Tj.Palette.amber
|
||||
case .diary: return Tj.Palette.leaf
|
||||
case .medication: return Tj.Palette.ink
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -132,13 +135,16 @@ struct TimelineEntry: Identifiable, Hashable {
|
||||
}
|
||||
}
|
||||
|
||||
/// 带「用药」tag 的日记(拍药盒入档)归到 .medication 分类,其余是普通文字日记。
|
||||
/// id 统一用 "diary-" 前缀:TimelineDetail.resolve 两个分类都反查 diaries。
|
||||
static func from(diary d: DiaryEntry) -> TimelineEntry {
|
||||
TimelineEntry(
|
||||
let isMed = d.isMedicationLog
|
||||
return TimelineEntry(
|
||||
id: "diary-\(d.persistentModelID)",
|
||||
kind: .diary,
|
||||
kind: isMed ? .medication : .diary,
|
||||
date: d.createdAt,
|
||||
title: d.content.firstLine(),
|
||||
subtitle: String(appLoc: "文字日记"),
|
||||
subtitle: isMed ? String(appLoc: "用药记录") : String(appLoc: "文字日记"),
|
||||
trailing: nil,
|
||||
trailingIsAlert: false,
|
||||
isOngoing: false
|
||||
|
||||
@@ -22,7 +22,8 @@ enum TimelineDetail {
|
||||
case .report:
|
||||
return reports.first { "report-\($0.persistentModelID)" == entry.id }
|
||||
.map(TimelineDetail.report)
|
||||
case .diary:
|
||||
case .diary, .medication:
|
||||
// 用药记录本质是带「用药」tag 的 DiaryEntry,详情同日记。
|
||||
return diaries.first { "diary-\($0.persistentModelID)" == entry.id }
|
||||
.map(TimelineDetail.diary)
|
||||
case .symptom:
|
||||
|
||||
@@ -54,6 +54,9 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"“%@”" : {
|
||||
|
||||
},
|
||||
"(偏瘦)" : {
|
||||
"localizations" : {
|
||||
@@ -1205,6 +1208,9 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"AI 模型未就绪,可以手动填写" : {
|
||||
|
||||
},
|
||||
"AI 模型未就绪,手动补充" : {
|
||||
|
||||
@@ -1691,6 +1697,9 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"下次试试长按 + ,直接说出想记的内容" : {
|
||||
|
||||
},
|
||||
"下载中" : {
|
||||
"localizations" : {
|
||||
@@ -2832,6 +2841,9 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"保存用药记录" : {
|
||||
|
||||
},
|
||||
"偏低" : {
|
||||
"localizations" : {
|
||||
@@ -3065,6 +3077,9 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"全程本机" : {
|
||||
|
||||
},
|
||||
"全部" : {
|
||||
"localizations" : {
|
||||
@@ -3335,6 +3350,9 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"再加一种" : {
|
||||
|
||||
},
|
||||
"再拍一项" : {
|
||||
"extractionState" : "stale",
|
||||
@@ -3358,6 +3376,9 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"再说一次" : {
|
||||
|
||||
},
|
||||
"再问一轮 · 让 AI 从新角度追问" : {
|
||||
"localizations" : {
|
||||
@@ -3411,6 +3432,12 @@
|
||||
},
|
||||
"写下要整理什么,或先提问补充情况…" : {
|
||||
|
||||
},
|
||||
"写日记" : {
|
||||
|
||||
},
|
||||
"写日记或拍药盒记录用药 · 可让 AI 辅助" : {
|
||||
|
||||
},
|
||||
"冠心病" : {
|
||||
"localizations" : {
|
||||
@@ -5115,6 +5142,9 @@
|
||||
},
|
||||
"导出历史" : {
|
||||
|
||||
},
|
||||
"将记入健康日记(记录页可查),并同步到「当前用药」供 AI 解读参考。不提供任何用药建议。" : {
|
||||
|
||||
},
|
||||
"将追加:" : {
|
||||
"localizations" : {
|
||||
@@ -6579,6 +6609,9 @@
|
||||
},
|
||||
"手动记录" : {
|
||||
|
||||
},
|
||||
"打开新建菜单" : {
|
||||
|
||||
},
|
||||
"抑郁/焦虑" : {
|
||||
"localizations" : {
|
||||
@@ -6840,6 +6873,15 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"拍药盒" : {
|
||||
|
||||
},
|
||||
"拍药盒或说明书,本地识别药名与规格" : {
|
||||
|
||||
},
|
||||
"拍药盒自动识别" : {
|
||||
|
||||
},
|
||||
"拖动方框对准要识别的指标,可拖右下角缩放" : {
|
||||
|
||||
@@ -6909,6 +6951,9 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"持续追踪" : {
|
||||
|
||||
},
|
||||
"指标" : {
|
||||
"localizations" : {
|
||||
@@ -7680,6 +7725,9 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"文字或语音" : {
|
||||
|
||||
},
|
||||
"文字日记" : {
|
||||
"localizations" : {
|
||||
@@ -8584,6 +8632,9 @@
|
||||
},
|
||||
"本机不支持端侧语音识别" : {
|
||||
|
||||
},
|
||||
"本机不支持端侧语音识别,试试下面的新建菜单" : {
|
||||
|
||||
},
|
||||
"本机保存" : {
|
||||
"localizations" : {
|
||||
@@ -8892,6 +8943,9 @@
|
||||
},
|
||||
"核对指标" : {
|
||||
|
||||
},
|
||||
"核对药品" : {
|
||||
|
||||
},
|
||||
"核对识别结果" : {
|
||||
"localizations" : {
|
||||
@@ -9071,6 +9125,9 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"正在听 · %llds" : {
|
||||
|
||||
},
|
||||
"正在听 · 识别在本机完成" : {
|
||||
|
||||
@@ -9143,12 +9200,18 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"正在本地识别药品…" : {
|
||||
|
||||
},
|
||||
"正在查看本地记录…" : {
|
||||
|
||||
},
|
||||
"正在根据这些记录回答…" : {
|
||||
|
||||
},
|
||||
"正在理解…" : {
|
||||
|
||||
},
|
||||
"正常" : {
|
||||
"localizations" : {
|
||||
@@ -9263,6 +9326,9 @@
|
||||
},
|
||||
"每月%lld日" : {
|
||||
|
||||
},
|
||||
"比如:记一下血压 / 我头疼 / 拍个药盒" : {
|
||||
|
||||
},
|
||||
"永久删除" : {
|
||||
"localizations" : {
|
||||
@@ -9329,6 +9395,12 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"没听到内容,再试一次?" : {
|
||||
|
||||
},
|
||||
"没听懂想记什么,再说一次,或直接选菜单" : {
|
||||
|
||||
},
|
||||
"没听清,再试一次" : {
|
||||
|
||||
@@ -9357,12 +9429,18 @@
|
||||
},
|
||||
"没有识别到指标,点「加一项」手动补充,或返回重拍" : {
|
||||
|
||||
},
|
||||
"没识别到文字,拍清楚一点再试" : {
|
||||
|
||||
},
|
||||
"没识别到文字,挪一下框再试" : {
|
||||
|
||||
},
|
||||
"没读出指标,挪一下框再试" : {
|
||||
|
||||
},
|
||||
"没读出药品,可以手动填写" : {
|
||||
|
||||
},
|
||||
"测试 PROMPT" : {
|
||||
"localizations" : {
|
||||
@@ -9501,6 +9579,9 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"照片与文字均不离开设备" : {
|
||||
|
||||
},
|
||||
"特大" : {
|
||||
|
||||
@@ -9783,6 +9864,15 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"用法,如:一日一次,一次一粒" : {
|
||||
|
||||
},
|
||||
"用药" : {
|
||||
|
||||
},
|
||||
"用药记录" : {
|
||||
|
||||
},
|
||||
"甲状腺疾病" : {
|
||||
"localizations" : {
|
||||
@@ -10764,6 +10854,9 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"药品名,如:缬沙坦胶囊" : {
|
||||
|
||||
},
|
||||
"血压" : {
|
||||
"localizations" : {
|
||||
@@ -10833,6 +10926,9 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"规格,如:80mg×7粒" : {
|
||||
|
||||
},
|
||||
"解析失败:%@" : {
|
||||
|
||||
@@ -11043,6 +11139,7 @@
|
||||
}
|
||||
},
|
||||
"记录身体状态、用药、感受 · 可让 AI 辅助" : {
|
||||
"extractionState" : "stale",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
@@ -11063,6 +11160,9 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"记症状" : {
|
||||
|
||||
},
|
||||
"设备上的 AI 模型会尝试把专业指标转述为通俗说明,帮你记录并回顾自己的健康变化。" : {
|
||||
"localizations" : {
|
||||
@@ -11205,6 +11305,9 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"识别用药" : {
|
||||
|
||||
},
|
||||
"识别超时,挪一下框再试或手动补充" : {
|
||||
|
||||
@@ -11318,12 +11421,21 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"语音和文字都只在本机处理,不会上传。" : {
|
||||
|
||||
},
|
||||
"语音记录全程在本机完成,声音和文字都不会上传。请在设置中允许麦克风和语音识别。" : {
|
||||
|
||||
},
|
||||
"说一段" : {
|
||||
|
||||
},
|
||||
"说出想记的内容" : {
|
||||
|
||||
},
|
||||
"说完了" : {
|
||||
|
||||
},
|
||||
"说完了,整理成日记" : {
|
||||
|
||||
@@ -11350,6 +11462,9 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"请开口说话…" : {
|
||||
|
||||
},
|
||||
"请选择名为 %@ 的文件夹" : {
|
||||
"localizations" : {
|
||||
@@ -11677,6 +11792,9 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"轻点打开新建菜单,长按语音直达" : {
|
||||
|
||||
},
|
||||
"载脂蛋白 A1" : {
|
||||
"extractionState" : "stale",
|
||||
|
||||
@@ -178,6 +178,14 @@ final class DiaryEntry {
|
||||
}
|
||||
}
|
||||
|
||||
extension DiaryEntry {
|
||||
/// 「拍药盒入档」落库时打的 tag。是数据标识不是 UI 文案,**不要**走 appLoc 本地化
|
||||
/// (语言切换后旧数据要还能被识别)。时间线据此把该日记归到「用药」分类。
|
||||
static let medicationTag = "用药"
|
||||
|
||||
var isMedicationLog: Bool { tags.contains(Self.medicationTag) }
|
||||
}
|
||||
|
||||
@Model
|
||||
final class Asset {
|
||||
var relativePath: String
|
||||
|
||||
44
康康/Persistence/WidgetSnapshot.swift
Normal file
44
康康/Persistence/WidgetSnapshot.swift
Normal file
@@ -0,0 +1,44 @@
|
||||
import Foundation
|
||||
|
||||
/// 主 App → 桌面 Widget 的数据快照(经 App Group UserDefaults 传递)。
|
||||
///
|
||||
/// 为什么不让 Widget 直接读 SwiftData:store 在 App 沙盒且开了文件保护,
|
||||
/// extension 进程独立、锁屏时不可读;快照是「最后一次看到的值」,锁屏也能显示。
|
||||
///
|
||||
/// ⚠️ 同步契约:`KangkangWidget` extension 里有本结构的独立拷贝
|
||||
/// (extension 不引主 App 代码,避免 Xcode target membership 配置成本)。
|
||||
/// 改字段时两边一起改:KangkangWidget/PinnedIndicatorsWidget.swift。
|
||||
struct WidgetSnapshot: Codable, Equatable {
|
||||
struct Item: Codable, Equatable {
|
||||
var name: String // "收缩压"
|
||||
var value: String // "128"
|
||||
var unit: String // "mmHg"
|
||||
var statusRaw: String // IndicatorStatus.rawValue: high|low|normal
|
||||
var capturedAt: Date
|
||||
}
|
||||
|
||||
var updatedAt: Date
|
||||
var items: [Item]
|
||||
|
||||
// MARK: - App Group 存取
|
||||
|
||||
/// App Group ID。两个 target 的 App Groups capability 都要勾这一个。
|
||||
static let appGroupID = "group.com.xuhuayong.kangkang"
|
||||
static let storeKey = "kk.widget.snapshot.v1"
|
||||
|
||||
/// App Group 未配置(capability 没加)时返回 nil → 调用方静默跳过,App 照常运行。
|
||||
static var sharedDefaults: UserDefaults? {
|
||||
UserDefaults(suiteName: appGroupID)
|
||||
}
|
||||
|
||||
func save(to defaults: UserDefaults? = WidgetSnapshot.sharedDefaults) {
|
||||
guard let defaults, let data = try? JSONEncoder().encode(self) else { return }
|
||||
defaults.set(data, forKey: Self.storeKey)
|
||||
}
|
||||
|
||||
static func load(from defaults: UserDefaults? = WidgetSnapshot.sharedDefaults) -> WidgetSnapshot? {
|
||||
guard let defaults,
|
||||
let data = defaults.data(forKey: storeKey) else { return nil }
|
||||
return try? JSONDecoder().decode(WidgetSnapshot.self, from: data)
|
||||
}
|
||||
}
|
||||
42
康康/Persistence/WidgetSnapshotRefresher.swift
Normal file
42
康康/Persistence/WidgetSnapshotRefresher.swift
Normal file
@@ -0,0 +1,42 @@
|
||||
import Foundation
|
||||
import SwiftData
|
||||
import WidgetKit
|
||||
|
||||
/// 把 pinned 指标的最新值写进 App Group 快照,并请求 WidgetKit 刷新。
|
||||
/// 调用时机:App 进后台 / 启动完成(RootView)。读库很轻(只取 pinned),无 AI、无网络。
|
||||
/// App Group capability 未配置时整体静默 no-op,不影响主 App。
|
||||
enum WidgetSnapshotRefresher {
|
||||
|
||||
/// 每个系列(seriesKey,无则按 name)只取最新一条,最多 6 条。
|
||||
@MainActor
|
||||
static func refresh(in ctx: ModelContext) {
|
||||
let pinnedPredicate = #Predicate<Indicator> { $0.pinned == true }
|
||||
var descriptor = FetchDescriptor<Indicator>(
|
||||
predicate: pinnedPredicate,
|
||||
sortBy: [SortDescriptor(\.capturedAt, order: .reverse)]
|
||||
)
|
||||
descriptor.fetchLimit = 200 // pinned 总量不大,设上限只是兜底
|
||||
guard let pinned = try? ctx.fetch(descriptor) else { return }
|
||||
|
||||
var seenSeries = Set<String>()
|
||||
var items: [WidgetSnapshot.Item] = []
|
||||
for ind in pinned { // 已按 capturedAt 降序,首见即该系列最新
|
||||
let key = ind.seriesKey ?? ind.name
|
||||
guard seenSeries.insert(key).inserted else { continue }
|
||||
items.append(.init(
|
||||
name: ind.name,
|
||||
value: ind.value,
|
||||
unit: ind.unit,
|
||||
statusRaw: ind.statusRaw,
|
||||
capturedAt: ind.capturedAt
|
||||
))
|
||||
if items.count >= 6 { break }
|
||||
}
|
||||
|
||||
let snapshot = WidgetSnapshot(updatedAt: .now, items: items)
|
||||
// 内容没变就不写、不刷新,省 WidgetKit 的刷新预算。
|
||||
if let old = WidgetSnapshot.load(), old.items == snapshot.items { return }
|
||||
snapshot.save()
|
||||
WidgetCenter.shared.reloadAllTimelines()
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
import SwiftUI
|
||||
import SwiftData
|
||||
import UIKit
|
||||
|
||||
enum TjTab: String, Hashable, CaseIterable {
|
||||
case home, records, trend, me
|
||||
@@ -35,6 +37,8 @@ enum ActiveFlow: Identifiable {
|
||||
}
|
||||
|
||||
struct RootView: View {
|
||||
@Environment(\.modelContext) private var ctx
|
||||
@Environment(\.scenePhase) private var scenePhase
|
||||
@State private var tab: TjTab = .home
|
||||
/// 页面 push 过渡的来向:切到右侧 tab 时从 trailing 推入,切到左侧时从 leading 推入。
|
||||
@State private var pushEdge: Edge = .trailing
|
||||
@@ -45,6 +49,23 @@ struct RootView: View {
|
||||
@State private var showIndicator = false
|
||||
@State private var showReminders = false
|
||||
@State private var showHealthExport = false
|
||||
/// 长按 + :语音直达(说一句话 → LLM 意图分类 → 打开对应入口)。
|
||||
@State private var showVoiceCommand = false
|
||||
/// 语音直达「拍药盒」:RootView 层直接弹 MedicationScanFlow,不绕日记 sheet。
|
||||
@State private var showMedicationScan = false
|
||||
|
||||
/// 语音意图 → 打开对应新建入口(与 RecordSheet onPick 的路由一一对应)。
|
||||
private func route(_ intent: VoiceIntent) {
|
||||
switch intent {
|
||||
case .diary: showDiary = true
|
||||
case .medication: showMedicationScan = true
|
||||
case .symptom: showSymptomStart = true
|
||||
case .indicator: showIndicator = true
|
||||
case .archive: activeFlow = .archive
|
||||
case .export: showHealthExport = true
|
||||
case .reminder: showReminders = true
|
||||
}
|
||||
}
|
||||
|
||||
/// 统一的 tab 切换入口:按方向设定 pushEdge,再带动画改 tab。
|
||||
/// 所有改 tab 的地方都走这里,保证过渡方向正确。
|
||||
@@ -70,9 +91,15 @@ struct RootView: View {
|
||||
|
||||
TabBar(active: tab,
|
||||
onTap: { select($0) },
|
||||
onTapRecord: { showRecordSheet = true })
|
||||
onTapRecord: { showRecordSheet = true },
|
||||
onLongPressRecord: { showVoiceCommand = true })
|
||||
}
|
||||
.background(Tj.Palette.sand.ignoresSafeArea())
|
||||
// 桌面 Widget 快照:启动后写一次,进后台时再写一次(轻量读库,App Group 未配置则 no-op)。
|
||||
.task { WidgetSnapshotRefresher.refresh(in: ctx) }
|
||||
.onChange(of: scenePhase) { _, phase in
|
||||
if phase == .background { WidgetSnapshotRefresher.refresh(in: ctx) }
|
||||
}
|
||||
.sheet(isPresented: $showRecordSheet) {
|
||||
RecordSheet { kind in
|
||||
showRecordSheet = false
|
||||
@@ -111,6 +138,30 @@ struct RootView: View {
|
||||
.fullScreenCover(isPresented: $showHealthExport) {
|
||||
HealthExportSheet()
|
||||
}
|
||||
.sheet(isPresented: $showVoiceCommand) {
|
||||
VoiceCommandSheet(
|
||||
onResolve: { intent in
|
||||
showVoiceCommand = false
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) {
|
||||
route(intent)
|
||||
}
|
||||
},
|
||||
onOpenMenu: {
|
||||
showVoiceCommand = false
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) {
|
||||
showRecordSheet = true
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
.fullScreenCover(isPresented: $showMedicationScan) {
|
||||
MedicationScanFlow(
|
||||
onSave: { entries in
|
||||
MedicationArchiver.archive(entries: entries, in: ctx)
|
||||
},
|
||||
onClose: { showMedicationScan = false }
|
||||
)
|
||||
}
|
||||
#if os(iOS)
|
||||
.fullScreenCover(item: $activeFlow) { flow in
|
||||
switch flow {
|
||||
@@ -137,8 +188,11 @@ private struct TabBar: View {
|
||||
let active: TjTab
|
||||
let onTap: (TjTab) -> Void
|
||||
let onTapRecord: () -> Void
|
||||
let onLongPressRecord: () -> Void
|
||||
|
||||
@Namespace private var indicatorNS
|
||||
/// + 号按压态(长按手势驱动的缩放视觉,代替 ButtonStyle)。
|
||||
@State private var recordPressing = false
|
||||
|
||||
private let cornerRadius: CGFloat = 22
|
||||
private let slotHeight: CGFloat = 34
|
||||
@@ -201,33 +255,45 @@ private struct TabBar: View {
|
||||
.buttonStyle(TabPressStyle())
|
||||
}
|
||||
|
||||
/// + 号:点按 → 新建菜单;长按 → 语音直达。
|
||||
/// 不用 Button + simultaneousGesture(长按成功后松手仍可能触发 tap 二次弹菜单),
|
||||
/// 改为 tap / longPress 双手势 + onPressingChanged 驱动按压缩放。
|
||||
private var recordSlot: some View {
|
||||
Button(action: onTapRecord) {
|
||||
VStack(spacing: 4) {
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(Tj.Palette.ink)
|
||||
.overlay(
|
||||
Circle()
|
||||
.strokeBorder(Tj.Palette.paper, lineWidth: 2)
|
||||
)
|
||||
.shadow(color: Tj.Palette.ink.opacity(0.18),
|
||||
radius: 4, x: 0, y: 2)
|
||||
VStack(spacing: 4) {
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(Tj.Palette.ink)
|
||||
.overlay(
|
||||
Circle()
|
||||
.strokeBorder(Tj.Palette.paper, lineWidth: 2)
|
||||
)
|
||||
.shadow(color: Tj.Palette.ink.opacity(0.18),
|
||||
radius: 4, x: 0, y: 2)
|
||||
|
||||
Image(systemName: "plus")
|
||||
.font(.tjScaled( 16, weight: .semibold))
|
||||
.foregroundStyle(Tj.Palette.paper)
|
||||
}
|
||||
.frame(width: slotHeight, height: slotHeight)
|
||||
|
||||
Text("新建")
|
||||
.font(.tjScaled( 11, weight: .semibold))
|
||||
.foregroundStyle(Tj.Palette.ink)
|
||||
Image(systemName: "plus")
|
||||
.font(.tjScaled( 16, weight: .semibold))
|
||||
.foregroundStyle(Tj.Palette.paper)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.contentShape(Rectangle())
|
||||
.frame(width: slotHeight, height: slotHeight)
|
||||
|
||||
Text("新建")
|
||||
.font(.tjScaled( 11, weight: .semibold))
|
||||
.foregroundStyle(Tj.Palette.ink)
|
||||
}
|
||||
.buttonStyle(TabPressStyle())
|
||||
.frame(maxWidth: .infinity)
|
||||
.contentShape(Rectangle())
|
||||
.scaleEffect(recordPressing ? 0.92 : 1.0)
|
||||
.animation(.spring(response: 0.25, dampingFraction: 0.7), value: recordPressing)
|
||||
.onTapGesture { onTapRecord() }
|
||||
.onLongPressGesture(minimumDuration: 0.45) {
|
||||
UIImpactFeedbackGenerator(style: .medium).impactOccurred()
|
||||
onLongPressRecord()
|
||||
} onPressingChanged: { pressing in
|
||||
recordPressing = pressing
|
||||
}
|
||||
.accessibilityElement(children: .combine)
|
||||
.accessibilityLabel("新建")
|
||||
.accessibilityHint("轻点打开新建菜单,长按语音直达")
|
||||
}
|
||||
}
|
||||
// 你好
|
||||
|
||||
114
康康/Services/MedicationScanService.swift
Normal file
114
康康/Services/MedicationScanService.swift
Normal file
@@ -0,0 +1,114 @@
|
||||
import Foundation
|
||||
|
||||
/// 药盒识别结果(结构化,与 UserProfile.currentMedications 的字符串条目解耦)。
|
||||
struct ParsedMedication: Sendable, Identifiable {
|
||||
let id = UUID()
|
||||
var name: String
|
||||
var strength: String // 规格,如 "80mg×7粒"
|
||||
var usage: String // 用法,如 "口服,一次1片,一日2次"
|
||||
|
||||
/// 写入 UserProfile.currentMedications 的单行文本,
|
||||
/// 与手动录入习惯一致(placeholder "如:缬沙坦 80mg qd")。
|
||||
var entryText: String {
|
||||
var s = name.trimmingCharacters(in: .whitespaces)
|
||||
let st = strength.trimmingCharacters(in: .whitespaces)
|
||||
let u = usage.trimmingCharacters(in: .whitespaces)
|
||||
if !st.isEmpty { s += " \(st)" }
|
||||
if !u.isEmpty { s += " · \(u)" }
|
||||
return s
|
||||
}
|
||||
}
|
||||
|
||||
/// 「拍药盒入档」服务:OCR 文本 → LLM(MNN/SME2 主链路)结构化抽药品。
|
||||
/// 与 CaptureService.recognizeIndicators 同构:UI 不直接碰 AIRuntime(§3.1),
|
||||
/// 失败抛 CaptureError,UI 回退手动录入(§3.2)。
|
||||
/// actor 原因同 CaptureService:方法要等 AIRuntime(actor),自身无可变状态。
|
||||
actor MedicationScanService {
|
||||
static let shared = MedicationScanService()
|
||||
private init() {}
|
||||
|
||||
/// 药盒/说明书/处方的 OCR 文本 → [ParsedMedication]。
|
||||
/// 调用方(MainActor)先做 OCR 再传文本进来,避免 UIImage 跨 actor。
|
||||
func recognizeMedications(fromOCRText text: String) async throws -> [ParsedMedication] {
|
||||
do {
|
||||
try await AIRuntime.shared.prepare() // 载 LLM(与 VL 互斥卸载由 AIRuntime 闸门处理)
|
||||
} catch {
|
||||
throw CaptureError.modelNotReady
|
||||
}
|
||||
|
||||
let prompt = MedicationPrompts.medicationsFromText(text)
|
||||
var collected = ""
|
||||
do {
|
||||
// 药盒一般 1-2 种药,512 token 足够;与其他推理由 AIRuntime 闸门串行。
|
||||
let stream = await AIRuntime.shared.generate(prompt: prompt, maxTokens: 512)
|
||||
for try await chunk in stream {
|
||||
collected += chunk.text
|
||||
}
|
||||
} catch {
|
||||
throw CaptureError.inferenceFailed("\(error)")
|
||||
}
|
||||
|
||||
let cleaned = CaptureService.stripThink(collected)
|
||||
do {
|
||||
return try Self.parseMedicationsJSON(cleaned)
|
||||
} catch let CaptureError.parseFailed(msg) {
|
||||
let preview = cleaned.isEmpty ? "(strip 后为空)" : String(cleaned.prefix(60))
|
||||
throw CaptureError.parseFailed("\(msg)〔前缀:\(preview)〕")
|
||||
} catch {
|
||||
throw CaptureError.parseFailed("\(error)")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - JSON parse(static 纯函数 → 方便单测)
|
||||
|
||||
/// 兼容 `{"medications":[...]}` 与裸数组 `[...]`。
|
||||
/// 解析不到任何药品返回空数组(不抛),UI 据此走「手动补充」分支;JSON 不合法才抛。
|
||||
static func parseMedicationsJSON(_ raw: String) throws -> [ParsedMedication] {
|
||||
let jsonString = CaptureService.repairJSON(CaptureService.extractBalancedJSON(from: raw))
|
||||
guard let data = jsonString.data(using: .utf8) else {
|
||||
throw CaptureError.parseFailed("非 UTF-8 输出")
|
||||
}
|
||||
let obj: Any
|
||||
do {
|
||||
obj = try JSONSerialization.jsonObject(with: data, options: [.fragmentsAllowed])
|
||||
} catch {
|
||||
throw CaptureError.parseFailed("JSON 不合法:\(error.localizedDescription)")
|
||||
}
|
||||
let rawList: [[String: Any]]
|
||||
if let dict = obj as? [String: Any] {
|
||||
rawList = arrayValue(dict, keys: ["medications", "meds", "drugs", "药品", "用药", "items"])
|
||||
} else if let arr = obj as? [[String: Any]] {
|
||||
rawList = arr
|
||||
} else {
|
||||
throw CaptureError.parseFailed("根节点既不是对象也不是数组")
|
||||
}
|
||||
var seen = Set<String>()
|
||||
return rawList.compactMap { parseMedication($0) }.filter { seen.insert($0.name).inserted }
|
||||
}
|
||||
|
||||
private static func parseMedication(_ d: [String: Any]) -> ParsedMedication? {
|
||||
guard let name = stringValue(d, keys: ["name", "drug", "medication", "药名", "药品", "名称"])?
|
||||
.trimmingCharacters(in: .whitespaces),
|
||||
!name.isEmpty else { return nil }
|
||||
let strength = stringValue(d, keys: ["strength", "spec", "specification", "规格", "剂量"]) ?? ""
|
||||
let usage = stringValue(d, keys: ["usage", "dosage", "用法", "用量", "用法用量"]) ?? ""
|
||||
return ParsedMedication(name: name,
|
||||
strength: strength.trimmingCharacters(in: .whitespaces),
|
||||
usage: usage.trimmingCharacters(in: .whitespaces))
|
||||
}
|
||||
|
||||
private static func stringValue(_ d: [String: Any], keys: [String]) -> String? {
|
||||
for key in keys {
|
||||
if let s = d[key] as? String { return s }
|
||||
if let n = d[key] as? NSNumber { return n.stringValue }
|
||||
}
|
||||
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]] { return arr }
|
||||
}
|
||||
return []
|
||||
}
|
||||
}
|
||||
96
康康/Services/VoiceIntentService.swift
Normal file
96
康康/Services/VoiceIntentService.swift
Normal file
@@ -0,0 +1,96 @@
|
||||
import Foundation
|
||||
|
||||
/// 「长按 + 语音直达」可路由到的新建入口。rawValue 与 IntentPrompts 的分类 token 一致。
|
||||
enum VoiceIntent: String, CaseIterable, Sendable {
|
||||
case diary, medication, symptom, indicator, archive, export, reminder
|
||||
}
|
||||
|
||||
/// 语音意图分类服务:LLM(MNN/SME2 主链路)优先,6 秒超时或失败回退到关键词匹配(§3.2)。
|
||||
/// 两路都不中返回 nil,UI 走「没听懂 → 再说一次 / 打开新建菜单」。
|
||||
/// 无状态,与 OCRService 同款 enum 形态;UI 不直接碰 AIRuntime(§3.1)。
|
||||
/// nonisolated:模块默认 MainActor,这里全是纯函数 + await,不需要主线程(测试也好调)。
|
||||
nonisolated enum VoiceIntentService {
|
||||
|
||||
static func classify(_ utterance: String) async -> VoiceIntent? {
|
||||
let text = utterance.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !text.isEmpty else { return nil }
|
||||
// 模型冷启动可能要载入十几秒,语音直达等不起:6s 拿不到就走关键词。
|
||||
if let intent = try? await withTimeout(seconds: 6, operation: {
|
||||
try await classifyWithLLM(text)
|
||||
}) {
|
||||
return intent
|
||||
}
|
||||
return keywordMatch(text)
|
||||
}
|
||||
|
||||
// MARK: - LLM 分类
|
||||
|
||||
private static func classifyWithLLM(_ text: String) async throws -> VoiceIntent {
|
||||
try await AIRuntime.shared.prepare()
|
||||
let stream = await AIRuntime.shared.generate(prompt: IntentPrompts.classify(text),
|
||||
maxTokens: 48)
|
||||
var collected = ""
|
||||
for try await chunk in stream {
|
||||
collected += chunk.text
|
||||
}
|
||||
guard let intent = parseIntent(from: collected) else {
|
||||
throw CaptureError.parseFailed("intent")
|
||||
}
|
||||
return intent
|
||||
}
|
||||
|
||||
/// 从模型输出抠 `{"intent":"…"}`。容错:think 块、围栏、裸词。"unknown"/未知值返回 nil。
|
||||
static func parseIntent(from raw: String) -> VoiceIntent? {
|
||||
let cleaned = CaptureService.stripThink(raw)
|
||||
let jsonString = CaptureService.repairJSON(CaptureService.extractBalancedJSON(from: cleaned))
|
||||
if let data = jsonString.data(using: .utf8),
|
||||
let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||
let token = obj["intent"] as? String {
|
||||
return VoiceIntent(rawValue: token.trimmingCharacters(in: .whitespaces).lowercased())
|
||||
}
|
||||
// 兜底:模型偶尔只吐裸词(diary / symptom …)
|
||||
let bare = cleaned.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
.trimmingCharacters(in: CharacterSet(charactersIn: "\"'`。."))
|
||||
.lowercased()
|
||||
return VoiceIntent(rawValue: bare)
|
||||
}
|
||||
|
||||
// MARK: - 关键词回退(纯函数,单测覆盖)
|
||||
|
||||
/// 规则有序:先命中先赢。「提醒我吃药」必须归 reminder,所以 reminder 排最前。
|
||||
static func keywordMatch(_ text: String) -> VoiceIntent? {
|
||||
let t = text.lowercased()
|
||||
let rules: [(VoiceIntent, [String])] = [
|
||||
(.reminder, ["提醒", "别忘", "闹钟"]),
|
||||
(.medication, ["药盒", "用药", "吃药", "吃了药", "服药", "药品", "降压药", "胰岛素"]),
|
||||
(.archive, ["报告", "化验单", "体检", "归档"]),
|
||||
(.export, ["身体档案", "给医生", "健康总结", "导出"]),
|
||||
(.indicator, ["血压", "血糖", "体重", "心率", "体温", "尿酸", "血脂", "指标",
|
||||
"高压", "低压"]),
|
||||
(.symptom, ["症状", "头疼", "头痛", "肚子疼", "胃疼", "牙疼", "嗓子疼", "疼", "痛",
|
||||
"咳嗽", "发烧", "发热", "头晕", "恶心", "不舒服", "难受", "拉肚子", "失眠"]),
|
||||
(.diary, ["日记", "今天", "心情", "感觉", "睡得", "吃了"]),
|
||||
]
|
||||
for (intent, keys) in rules where keys.contains(where: { t.contains($0) }) {
|
||||
return intent
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
/// 简单超时竞速:operation 与 sleep 赛跑,超时抛 CancellationError 并取消未完成方。
|
||||
nonisolated private func withTimeout<T: Sendable>(
|
||||
seconds: Double,
|
||||
operation: @escaping @Sendable () async throws -> T
|
||||
) async throws -> T {
|
||||
try await withThrowingTaskGroup(of: T.self) { group in
|
||||
group.addTask { try await operation() }
|
||||
group.addTask {
|
||||
try await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000))
|
||||
throw CancellationError()
|
||||
}
|
||||
guard let result = try await group.next() else { throw CancellationError() }
|
||||
group.cancelAll()
|
||||
return result
|
||||
}
|
||||
}
|
||||
85
康康Tests/MedicationScanServiceTests.swift
Normal file
85
康康Tests/MedicationScanServiceTests.swift
Normal file
@@ -0,0 +1,85 @@
|
||||
import Testing
|
||||
import Foundation
|
||||
import SwiftData
|
||||
@testable import 康康
|
||||
|
||||
/// MedicationScanService.parseMedicationsJSON 纯函数单测(JSON 容错与去重)。
|
||||
struct MedicationScanServiceTests {
|
||||
|
||||
@Test func parsesStandardObject() throws {
|
||||
let raw = """
|
||||
{"medications":[{"name":"缬沙坦胶囊","strength":"80mg×7粒","usage":""}]}
|
||||
"""
|
||||
let meds = try MedicationScanService.parseMedicationsJSON(raw)
|
||||
#expect(meds.count == 1)
|
||||
#expect(meds[0].name == "缬沙坦胶囊")
|
||||
#expect(meds[0].strength == "80mg×7粒")
|
||||
#expect(meds[0].entryText == "缬沙坦胶囊 80mg×7粒")
|
||||
}
|
||||
|
||||
@Test func parsesBareArrayWithFence() throws {
|
||||
let raw = """
|
||||
```json
|
||||
[{"name":"二甲双胍缓释片","strength":"0.5g×30片","usage":"口服,一次1片,一日2次"}]
|
||||
```
|
||||
"""
|
||||
let meds = try MedicationScanService.parseMedicationsJSON(raw)
|
||||
#expect(meds.count == 1)
|
||||
#expect(meds[0].entryText == "二甲双胍缓释片 0.5g×30片 · 口服,一次1片,一日2次")
|
||||
}
|
||||
|
||||
@Test func parsesChineseKeysAndDedupes() throws {
|
||||
let raw = """
|
||||
{"medications":[
|
||||
{"药名":"阿司匹林肠溶片","规格":"100mg","用法":""},
|
||||
{"name":"阿司匹林肠溶片","strength":"100mg","usage":""}
|
||||
]}
|
||||
"""
|
||||
let meds = try MedicationScanService.parseMedicationsJSON(raw)
|
||||
#expect(meds.count == 1)
|
||||
}
|
||||
|
||||
@Test func emptyNameRowsAreDropped() throws {
|
||||
let raw = #"{"medications":[{"name":"","strength":"10mg","usage":""}]}"#
|
||||
let meds = try MedicationScanService.parseMedicationsJSON(raw)
|
||||
#expect(meds.isEmpty)
|
||||
}
|
||||
|
||||
@Test func trailingCommaIsRepaired() throws {
|
||||
let raw = #"{"medications":[{"name":"氯雷他定片","strength":"10mg×6片","usage":"",},]}"#
|
||||
let meds = try MedicationScanService.parseMedicationsJSON(raw)
|
||||
#expect(meds.count == 1)
|
||||
#expect(meds[0].name == "氯雷他定片")
|
||||
}
|
||||
|
||||
@Test func invalidJSONThrows() {
|
||||
#expect(throws: (any Error).self) {
|
||||
try MedicationScanService.parseMedicationsJSON("识别不出来,抱歉")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 「用药」日记 → 时间线分类映射(拍药盒入档落库后在「记录」tab 的归类)。
|
||||
@MainActor
|
||||
struct MedicationTimelineTests {
|
||||
|
||||
private func makeContext() throws -> ModelContext {
|
||||
let schema = Schema([DiaryEntry.self])
|
||||
let config = ModelConfiguration(schema: schema, isStoredInMemoryOnly: true)
|
||||
return ModelContext(try ModelContainer(for: schema, configurations: [config]))
|
||||
}
|
||||
|
||||
@Test func medicationTaggedDiaryMapsToMedicationKind() throws {
|
||||
let ctx = try makeContext()
|
||||
let med = DiaryEntry(content: "缬沙坦胶囊 80mg×7粒", tags: [DiaryEntry.medicationTag])
|
||||
let plain = DiaryEntry(content: "今天睡得不错")
|
||||
ctx.insert(med); ctx.insert(plain)
|
||||
try ctx.save()
|
||||
|
||||
let medEntry = TimelineEntry.from(diary: med)
|
||||
#expect(medEntry.kind == .medication)
|
||||
#expect(medEntry.title == "缬沙坦胶囊 80mg×7粒")
|
||||
|
||||
#expect(TimelineEntry.from(diary: plain).kind == .diary)
|
||||
}
|
||||
}
|
||||
22
康康Tests/SpeechDictationMergeTests.swift
Normal file
22
康康Tests/SpeechDictationMergeTests.swift
Normal file
@@ -0,0 +1,22 @@
|
||||
import Testing
|
||||
@testable import 康康
|
||||
|
||||
struct SpeechDictationMergeTests {
|
||||
@Test func emptyPrefixReturnsPartial() {
|
||||
#expect(SpeechDictationService.merge(prefix: "", partial: "今天头晕") == "今天头晕")
|
||||
}
|
||||
|
||||
@Test func plainPrefixJoinsWithSpace() {
|
||||
#expect(SpeechDictationService.merge(prefix: "已有内容", partial: "新听写")
|
||||
== "已有内容 新听写")
|
||||
}
|
||||
|
||||
@Test func whitespaceTerminatedPrefixConcatsDirectly() {
|
||||
#expect(SpeechDictationService.merge(prefix: "第一行\n", partial: "新听写")
|
||||
== "第一行\n新听写")
|
||||
}
|
||||
|
||||
@Test func emptyPartialKeepsPrefix() {
|
||||
#expect(SpeechDictationService.merge(prefix: "已有内容", partial: "") == "已有内容")
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,10 @@ struct TimelineGroupingTests {
|
||||
return Calendar(identifier: .gregorian).date(from: c)!
|
||||
}()
|
||||
|
||||
@Test func timelineKindOrderMatchesRecordFilterChips() {
|
||||
#expect(TimelineKind.allCases == [.diary, .symptom, .indicator, .medication, .report])
|
||||
}
|
||||
|
||||
@Test func todaySection() {
|
||||
#expect(TimelineGrouping.section(for: now, now: now) == .today)
|
||||
}
|
||||
|
||||
53
康康Tests/VoiceIntentServiceTests.swift
Normal file
53
康康Tests/VoiceIntentServiceTests.swift
Normal file
@@ -0,0 +1,53 @@
|
||||
import Testing
|
||||
import Foundation
|
||||
@testable import 康康
|
||||
|
||||
/// 语音直达的两个纯函数:LLM 输出解析 + 关键词回退。
|
||||
struct VoiceIntentServiceTests {
|
||||
|
||||
// MARK: - parseIntent(LLM 输出容错)
|
||||
|
||||
@Test func parsesStandardJSON() {
|
||||
#expect(VoiceIntentService.parseIntent(from: #"{"intent":"indicator"}"#) == .indicator)
|
||||
}
|
||||
|
||||
@Test func parsesFencedAndThinkWrapped() {
|
||||
let raw = """
|
||||
<think>用户想记血压</think>
|
||||
```json
|
||||
{"intent": "Indicator"}
|
||||
```
|
||||
"""
|
||||
#expect(VoiceIntentService.parseIntent(from: raw) == .indicator)
|
||||
}
|
||||
|
||||
@Test func parsesBareWord() {
|
||||
#expect(VoiceIntentService.parseIntent(from: "symptom") == .symptom)
|
||||
#expect(VoiceIntentService.parseIntent(from: "\"diary\"。") == .diary)
|
||||
}
|
||||
|
||||
@Test func unknownReturnsNil() {
|
||||
#expect(VoiceIntentService.parseIntent(from: #"{"intent":"unknown"}"#) == nil)
|
||||
#expect(VoiceIntentService.parseIntent(from: "我不知道") == nil)
|
||||
}
|
||||
|
||||
// MARK: - keywordMatch(回退规则与优先级)
|
||||
|
||||
@Test func reminderBeatsMedication() {
|
||||
// 「提醒我吃药」是设提醒,不是记用药 —— reminder 规则必须排最前
|
||||
#expect(VoiceIntentService.keywordMatch("每天八点提醒我吃药") == .reminder)
|
||||
}
|
||||
|
||||
@Test func commonUtterances() {
|
||||
#expect(VoiceIntentService.keywordMatch("记一下血压,高压128") == .indicator)
|
||||
#expect(VoiceIntentService.keywordMatch("我有点头疼") == .symptom)
|
||||
#expect(VoiceIntentService.keywordMatch("拍个药盒") == .medication)
|
||||
#expect(VoiceIntentService.keywordMatch("把体检报告存进去") == .archive)
|
||||
#expect(VoiceIntentService.keywordMatch("整理一份给医生看的") == .export)
|
||||
#expect(VoiceIntentService.keywordMatch("写个日记") == .diary)
|
||||
}
|
||||
|
||||
@Test func gibberishReturnsNil() {
|
||||
#expect(VoiceIntentService.keywordMatch("啦啦啦啦") == nil)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user