```
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 = {
|
attributes = {
|
||||||
BuildIndependentTargetsInParallel = 1;
|
BuildIndependentTargetsInParallel = 1;
|
||||||
LastSwiftUpdateCheck = 2600;
|
LastSwiftUpdateCheck = 2600;
|
||||||
LastUpgradeCheck = 2600;
|
LastUpgradeCheck = 2650;
|
||||||
TargetAttributes = {
|
TargetAttributes = {
|
||||||
5E463CF82FC403BB0089145B = {
|
5E463CF82FC403BB0089145B = {
|
||||||
CreatedOnToolsVersion = 26.0.1;
|
CreatedOnToolsVersion = 26.0.1;
|
||||||
@@ -296,6 +296,7 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||||
|
CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES;
|
||||||
CLANG_ANALYZER_NONNULL = YES;
|
CLANG_ANALYZER_NONNULL = YES;
|
||||||
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
|
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
|
||||||
@@ -325,6 +326,7 @@
|
|||||||
CLANG_WARN_UNREACHABLE_CODE = YES;
|
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||||
COPY_PHASE_STRIP = NO;
|
COPY_PHASE_STRIP = NO;
|
||||||
|
DEAD_CODE_STRIPPING = YES;
|
||||||
DEBUG_INFORMATION_FORMAT = dwarf;
|
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||||
DEVELOPMENT_TEAM = F2C8C774FG;
|
DEVELOPMENT_TEAM = F2C8C774FG;
|
||||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||||
@@ -348,6 +350,7 @@
|
|||||||
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
||||||
MTL_FAST_MATH = YES;
|
MTL_FAST_MATH = YES;
|
||||||
ONLY_ACTIVE_ARCH = YES;
|
ONLY_ACTIVE_ARCH = YES;
|
||||||
|
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
|
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
|
||||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||||
};
|
};
|
||||||
@@ -358,6 +361,7 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||||
|
CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES;
|
||||||
CLANG_ANALYZER_NONNULL = YES;
|
CLANG_ANALYZER_NONNULL = YES;
|
||||||
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
|
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
|
||||||
@@ -387,6 +391,7 @@
|
|||||||
CLANG_WARN_UNREACHABLE_CODE = YES;
|
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||||
COPY_PHASE_STRIP = NO;
|
COPY_PHASE_STRIP = NO;
|
||||||
|
DEAD_CODE_STRIPPING = YES;
|
||||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||||
DEVELOPMENT_TEAM = F2C8C774FG;
|
DEVELOPMENT_TEAM = F2C8C774FG;
|
||||||
ENABLE_NS_ASSERTIONS = NO;
|
ENABLE_NS_ASSERTIONS = NO;
|
||||||
@@ -403,6 +408,7 @@
|
|||||||
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||||
MTL_ENABLE_DEBUG_INFO = NO;
|
MTL_ENABLE_DEBUG_INFO = NO;
|
||||||
MTL_FAST_MATH = YES;
|
MTL_FAST_MATH = YES;
|
||||||
|
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||||
SWIFT_COMPILATION_MODE = wholemodule;
|
SWIFT_COMPILATION_MODE = wholemodule;
|
||||||
};
|
};
|
||||||
name = Release;
|
name = Release;
|
||||||
@@ -415,6 +421,7 @@
|
|||||||
CODE_SIGN_ENTITLEMENTS = "康康/康康.entitlements";
|
CODE_SIGN_ENTITLEMENTS = "康康/康康.entitlements";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 5;
|
CURRENT_PROJECT_VERSION = 5;
|
||||||
|
DEAD_CODE_STRIPPING = YES;
|
||||||
DEVELOPMENT_TEAM = F2C8C774FG;
|
DEVELOPMENT_TEAM = F2C8C774FG;
|
||||||
ENABLE_APP_SANDBOX = YES;
|
ENABLE_APP_SANDBOX = YES;
|
||||||
ENABLE_HARDENED_RUNTIME = YES;
|
ENABLE_HARDENED_RUNTIME = YES;
|
||||||
@@ -473,6 +480,7 @@
|
|||||||
CODE_SIGN_ENTITLEMENTS = "康康/康康.entitlements";
|
CODE_SIGN_ENTITLEMENTS = "康康/康康.entitlements";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 5;
|
CURRENT_PROJECT_VERSION = 5;
|
||||||
|
DEAD_CODE_STRIPPING = YES;
|
||||||
DEVELOPMENT_TEAM = F2C8C774FG;
|
DEVELOPMENT_TEAM = F2C8C774FG;
|
||||||
ENABLE_APP_SANDBOX = YES;
|
ENABLE_APP_SANDBOX = YES;
|
||||||
ENABLE_HARDENED_RUNTIME = YES;
|
ENABLE_HARDENED_RUNTIME = YES;
|
||||||
@@ -529,6 +537,7 @@
|
|||||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 5;
|
CURRENT_PROJECT_VERSION = 5;
|
||||||
|
DEAD_CODE_STRIPPING = YES;
|
||||||
DEVELOPMENT_TEAM = F2C8C774FG;
|
DEVELOPMENT_TEAM = F2C8C774FG;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||||
@@ -556,6 +565,7 @@
|
|||||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 5;
|
CURRENT_PROJECT_VERSION = 5;
|
||||||
|
DEAD_CODE_STRIPPING = YES;
|
||||||
DEVELOPMENT_TEAM = F2C8C774FG;
|
DEVELOPMENT_TEAM = F2C8C774FG;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||||
@@ -582,6 +592,7 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 5;
|
CURRENT_PROJECT_VERSION = 5;
|
||||||
|
DEAD_CODE_STRIPPING = YES;
|
||||||
DEVELOPMENT_TEAM = F2C8C774FG;
|
DEVELOPMENT_TEAM = F2C8C774FG;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||||
@@ -608,6 +619,7 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 5;
|
CURRENT_PROJECT_VERSION = 5;
|
||||||
|
DEAD_CODE_STRIPPING = YES;
|
||||||
DEVELOPMENT_TEAM = F2C8C774FG;
|
DEVELOPMENT_TEAM = F2C8C774FG;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<Scheme
|
<Scheme
|
||||||
LastUpgradeVersion = "2600"
|
LastUpgradeVersion = "2650"
|
||||||
version = "1.7">
|
version = "1.7">
|
||||||
<BuildAction
|
<BuildAction
|
||||||
parallelizeBuildables = "YES"
|
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())
|
.clipShape(Capsule())
|
||||||
}
|
}
|
||||||
|
|
||||||
Button("取消", action: onCancel)
|
Button(action: onCancel) {
|
||||||
|
Text("取消")
|
||||||
.foregroundStyle(Tj.Palette.text3)
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
|
.padding(.horizontal, 24)
|
||||||
|
.frame(minHeight: 44) // HIG 最小命中区
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
|
||||||
if loading {
|
if loading {
|
||||||
ProgressView().tint(Tj.Palette.ink)
|
ProgressView().tint(Tj.Palette.ink)
|
||||||
|
|||||||
@@ -362,9 +362,15 @@ private struct AnalyzingView: View {
|
|||||||
.foregroundStyle(Tj.Palette.amber)
|
.foregroundStyle(Tj.Palette.amber)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Button("取消识别 · 改为手动录入", action: onCancel)
|
Button(action: onCancel) {
|
||||||
|
Text("取消识别 · 改为手动录入")
|
||||||
.font(.tjScaled( 13, weight: .medium))
|
.font(.tjScaled( 13, weight: .medium))
|
||||||
.foregroundStyle(Tj.Palette.text3)
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
|
.padding(.horizontal, 20)
|
||||||
|
.frame(minHeight: 44) // HIG 最小命中区
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
.padding(.top, 4)
|
.padding(.top, 4)
|
||||||
Spacer()
|
Spacer()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,10 @@ struct DiaryQuickSheet: View {
|
|||||||
|
|
||||||
@State private var content: String = ""
|
@State private var content: String = ""
|
||||||
@State private var createdAt: Date = .now
|
@State private var createdAt: Date = .now
|
||||||
|
/// 「拍药盒」分支:全屏扫描流程,确认后存为带「用药」tag 的日记。
|
||||||
|
@State private var showMedicationScan = false
|
||||||
|
/// 「记症状」分支:嵌套弹出 SymptomStartSheet(自带保存/取消,关闭后回到本页)。
|
||||||
|
@State private var showSymptomStart = false
|
||||||
|
|
||||||
/// AI 辅助状态
|
/// AI 辅助状态
|
||||||
enum AssistPhase {
|
enum AssistPhase {
|
||||||
@@ -92,6 +96,24 @@ struct DiaryQuickSheet: View {
|
|||||||
.foregroundStyle(Tj.Palette.text3)
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 20)
|
.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)
|
.padding(.bottom, 14)
|
||||||
|
|
||||||
ScrollViewReader { proxy in
|
ScrollViewReader { proxy in
|
||||||
@@ -228,6 +250,20 @@ struct DiaryQuickSheet: View {
|
|||||||
.presentationDragIndicator(.hidden)
|
.presentationDragIndicator(.hidden)
|
||||||
.presentationBackground(Tj.Palette.sand)
|
.presentationBackground(Tj.Palette.sand)
|
||||||
.presentationCornerRadius(Tj.Radius.xl)
|
.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 {
|
.onDisappear {
|
||||||
suggestTask?.cancel()
|
suggestTask?.cancel()
|
||||||
voiceFlowTask?.cancel()
|
voiceFlowTask?.cancel()
|
||||||
@@ -555,6 +591,41 @@ struct DiaryQuickSheet: View {
|
|||||||
.foregroundStyle(Tj.Palette.text2)
|
.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: 语音输入流程
|
// MARK: 语音输入流程
|
||||||
|
|
||||||
private func startVoice() {
|
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 healthImportDraft: HealthProfileImportDraft?
|
||||||
@State private var healthImportError: String?
|
@State private var healthImportError: String?
|
||||||
@State private var isImportingHealthProfile = false
|
@State private var isImportingHealthProfile = false
|
||||||
|
@State private var showMedicationScan = false
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Form {
|
Form {
|
||||||
@@ -88,7 +89,8 @@ private struct ProfileEditForm: View {
|
|||||||
StringListSection(title: String(appLoc: "家族史"), placeholder: String(appLoc: "如:母亲 高血压"),
|
StringListSection(title: String(appLoc: "家族史"), placeholder: String(appLoc: "如:母亲 高血压"),
|
||||||
items: $profile.familyHistory)
|
items: $profile.familyHistory)
|
||||||
StringListSection(title: String(appLoc: "当前用药"), placeholder: String(appLoc: "如:缬沙坦 80mg qd"),
|
StringListSection(title: String(appLoc: "当前用药"), placeholder: String(appLoc: "如:缬沙坦 80mg qd"),
|
||||||
items: $profile.currentMedications)
|
items: $profile.currentMedications,
|
||||||
|
onScan: { showMedicationScan = true })
|
||||||
}
|
}
|
||||||
.navigationTitle("个人资料")
|
.navigationTitle("个人资料")
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
@@ -98,6 +100,16 @@ private struct ProfileEditForm: View {
|
|||||||
profile.updatedAt = .now
|
profile.updatedAt = .now
|
||||||
try? ctx.save()
|
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
|
.sheet(item: $healthImportDraft) { draft in
|
||||||
HealthProfileImportPreviewSheet(
|
HealthProfileImportPreviewSheet(
|
||||||
draft: draft,
|
draft: draft,
|
||||||
@@ -456,10 +468,27 @@ private struct StringListSection: View {
|
|||||||
let title: String
|
let title: String
|
||||||
let placeholder: String
|
let placeholder: String
|
||||||
@Binding var items: [String]
|
@Binding var items: [String]
|
||||||
|
/// 非 nil 时在节内显示「拍药盒自动识别」入口(目前仅「当前用药」用)。
|
||||||
|
var onScan: (() -> Void)? = nil
|
||||||
@State private var newInput = ""
|
@State private var newInput = ""
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Section(title) {
|
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
|
ForEach(items, id: \.self) { item in
|
||||||
HStack {
|
HStack {
|
||||||
Text(item)
|
Text(item)
|
||||||
|
|||||||
@@ -32,8 +32,9 @@ struct QuickRegionCaptureFlow: View {
|
|||||||
private var content: some View {
|
private var content: some View {
|
||||||
switch phase {
|
switch phase {
|
||||||
case .idle:
|
case .idle:
|
||||||
|
// 不再整体 ignoresSafeArea:相机/框选内部已各自做全屏黑底,
|
||||||
|
// 这里再忽略安全区会把「取消」顶进灵动岛,几乎点不到。
|
||||||
captureEntry
|
captureEntry
|
||||||
.ignoresSafeArea()
|
|
||||||
|
|
||||||
case .adjust(let image):
|
case .adjust(let image):
|
||||||
RegionAdjustView(
|
RegionAdjustView(
|
||||||
@@ -45,7 +46,6 @@ struct QuickRegionCaptureFlow: View {
|
|||||||
onRetake: { phase = .idle },
|
onRetake: { phase = .idle },
|
||||||
onCancel: { onClose() }
|
onCancel: { onClose() }
|
||||||
)
|
)
|
||||||
.ignoresSafeArea()
|
|
||||||
|
|
||||||
case .confirm(let image, let items, let warning):
|
case .confirm(let image, let items, let warning):
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
|
|||||||
@@ -50,7 +50,11 @@ struct RegionAdjustView: View {
|
|||||||
Text("取消")
|
Text("取消")
|
||||||
.font(.tjScaled( 16, weight: .medium))
|
.font(.tjScaled( 16, weight: .medium))
|
||||||
.foregroundStyle(.white)
|
.foregroundStyle(.white)
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
.frame(minWidth: 60, minHeight: 44) // HIG 最小命中区,命中整块而非文字
|
||||||
|
.contentShape(Rectangle())
|
||||||
}
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
Spacer()
|
Spacer()
|
||||||
Text("框住异常指标")
|
Text("框住异常指标")
|
||||||
.font(.tjScaled( 16, weight: .semibold))
|
.font(.tjScaled( 16, weight: .semibold))
|
||||||
@@ -63,10 +67,14 @@ struct RegionAdjustView: View {
|
|||||||
Text("重拍")
|
Text("重拍")
|
||||||
.font(.tjScaled( 16, weight: .medium))
|
.font(.tjScaled( 16, weight: .medium))
|
||||||
.foregroundStyle(.white)
|
.foregroundStyle(.white)
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
.frame(minWidth: 60, minHeight: 44)
|
||||||
|
.contentShape(Rectangle())
|
||||||
}
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 18)
|
.padding(.horizontal, 8)
|
||||||
.padding(.vertical, 12)
|
.padding(.vertical, 4)
|
||||||
.background(Color.black)
|
.background(Color.black)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -49,13 +49,15 @@ struct SingleShotCameraView: View {
|
|||||||
Text("取消")
|
Text("取消")
|
||||||
.font(.tjScaled( 16, weight: .medium))
|
.font(.tjScaled( 16, weight: .medium))
|
||||||
.foregroundStyle(.white)
|
.foregroundStyle(.white)
|
||||||
.padding(.horizontal, 14)
|
.padding(.horizontal, 18)
|
||||||
.padding(.vertical, 8)
|
.frame(minHeight: 44) // 苹果 HIG 最小命中区
|
||||||
.background(Capsule().fill(.black.opacity(0.35)))
|
.background(Capsule().fill(.black.opacity(0.35)))
|
||||||
|
.contentShape(Capsule())
|
||||||
}
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
Spacer()
|
Spacer()
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 18)
|
.padding(.horizontal, 16)
|
||||||
.padding(.top, 8)
|
.padding(.top, 8)
|
||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
|
|||||||
@@ -5,8 +5,15 @@ enum RecordKind: String, Identifiable, CaseIterable {
|
|||||||
var id: String { rawValue }
|
var id: String { rawValue }
|
||||||
|
|
||||||
/// RecordSheet 列表的展示顺序(从上到下)。与 enum 声明序解耦,改顺序只动这里。
|
/// RecordSheet 列表的展示顺序(从上到下)。与 enum 声明序解耦,改顺序只动这里。
|
||||||
/// 注:`.quick`(指标速记)已并入 `.indicator`(记录指标)内的「拍照识别」,不再单列。
|
/// 注:`.quick`(指标速记)已并入 `.indicator`(记录指标)内的「拍照识别」;
|
||||||
static let displayOrder: [RecordKind] = [.diary, .reminder, .symptom, .indicator, .healthExport, .archive]
|
/// `.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 {
|
var title: String {
|
||||||
switch self {
|
switch self {
|
||||||
@@ -25,7 +32,7 @@ enum RecordKind: String, Identifiable, CaseIterable {
|
|||||||
case .indicator: return String(appLoc: "手动填写,或拍照自动识别")
|
case .indicator: return String(appLoc: "手动填写,或拍照自动识别")
|
||||||
case .healthExport: return String(appLoc: "多轮问答后生成给医生看的整理报告")
|
case .healthExport: return String(appLoc: "多轮问答后生成给医生看的整理报告")
|
||||||
case .archive: 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 .symptom: return String(appLoc: "开始一个持续症状,结束时再点结束")
|
||||||
case .reminder: return String(appLoc: "管理用药、复查、监测的周期提醒")
|
case .reminder: return String(appLoc: "管理用药、复查、监测的周期提醒")
|
||||||
}
|
}
|
||||||
@@ -93,14 +100,28 @@ struct RecordSheet: View {
|
|||||||
}
|
}
|
||||||
.frame(width: 44, height: 44)
|
.frame(width: 44, height: 44)
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
VStack(alignment: .leading, spacing: 3) {
|
||||||
Text(kind.title)
|
Text(kind.title)
|
||||||
.font(.tjScaled( 15, weight: .semibold))
|
.font(.tjScaled( 15, weight: .semibold))
|
||||||
.foregroundStyle(Tj.Palette.text)
|
.foregroundStyle(Tj.Palette.text)
|
||||||
|
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)
|
Text(kind.subtitle)
|
||||||
.font(.tjScaled( 12))
|
.font(.tjScaled( 12))
|
||||||
.foregroundStyle(Tj.Palette.text3)
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
Spacer()
|
Spacer()
|
||||||
Image(systemName: "chevron.right")
|
Image(systemName: "chevron.right")
|
||||||
.font(.tjScaled( 14, weight: .medium))
|
.font(.tjScaled( 14, weight: .medium))
|
||||||
@@ -111,6 +132,17 @@ struct RecordSheet: View {
|
|||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.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)
|
.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,7 +3,7 @@ import SwiftData
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
enum TimelineKind: String, CaseIterable, Identifiable {
|
enum TimelineKind: String, CaseIterable, Identifiable {
|
||||||
case indicator, report, symptom, diary
|
case diary, symptom, indicator, medication, report
|
||||||
var id: String { rawValue }
|
var id: String { rawValue }
|
||||||
|
|
||||||
var label: String {
|
var label: String {
|
||||||
@@ -12,6 +12,7 @@ enum TimelineKind: String, CaseIterable, Identifiable {
|
|||||||
case .report: return String(appLoc: "报告")
|
case .report: return String(appLoc: "报告")
|
||||||
case .symptom: return String(appLoc: "症状")
|
case .symptom: return String(appLoc: "症状")
|
||||||
case .diary: return String(appLoc: "日记")
|
case .diary: return String(appLoc: "日记")
|
||||||
|
case .medication: return String(appLoc: "用药")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -21,6 +22,7 @@ enum TimelineKind: String, CaseIterable, Identifiable {
|
|||||||
case .report: return "doc.fill"
|
case .report: return "doc.fill"
|
||||||
case .symptom: return "waveform.path.ecg"
|
case .symptom: return "waveform.path.ecg"
|
||||||
case .diary: return "pencil"
|
case .diary: return "pencil"
|
||||||
|
case .medication: return "pills.fill"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -30,6 +32,7 @@ enum TimelineKind: String, CaseIterable, Identifiable {
|
|||||||
case .report: return Tj.Palette.ink2
|
case .report: return Tj.Palette.ink2
|
||||||
case .symptom: return Tj.Palette.amber
|
case .symptom: return Tj.Palette.amber
|
||||||
case .diary: return Tj.Palette.leaf
|
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 {
|
static func from(diary d: DiaryEntry) -> TimelineEntry {
|
||||||
TimelineEntry(
|
let isMed = d.isMedicationLog
|
||||||
|
return TimelineEntry(
|
||||||
id: "diary-\(d.persistentModelID)",
|
id: "diary-\(d.persistentModelID)",
|
||||||
kind: .diary,
|
kind: isMed ? .medication : .diary,
|
||||||
date: d.createdAt,
|
date: d.createdAt,
|
||||||
title: d.content.firstLine(),
|
title: d.content.firstLine(),
|
||||||
subtitle: String(appLoc: "文字日记"),
|
subtitle: isMed ? String(appLoc: "用药记录") : String(appLoc: "文字日记"),
|
||||||
trailing: nil,
|
trailing: nil,
|
||||||
trailingIsAlert: false,
|
trailingIsAlert: false,
|
||||||
isOngoing: false
|
isOngoing: false
|
||||||
|
|||||||
@@ -22,7 +22,8 @@ enum TimelineDetail {
|
|||||||
case .report:
|
case .report:
|
||||||
return reports.first { "report-\($0.persistentModelID)" == entry.id }
|
return reports.first { "report-\($0.persistentModelID)" == entry.id }
|
||||||
.map(TimelineDetail.report)
|
.map(TimelineDetail.report)
|
||||||
case .diary:
|
case .diary, .medication:
|
||||||
|
// 用药记录本质是带「用药」tag 的 DiaryEntry,详情同日记。
|
||||||
return diaries.first { "diary-\($0.persistentModelID)" == entry.id }
|
return diaries.first { "diary-\($0.persistentModelID)" == entry.id }
|
||||||
.map(TimelineDetail.diary)
|
.map(TimelineDetail.diary)
|
||||||
case .symptom:
|
case .symptom:
|
||||||
|
|||||||
@@ -54,6 +54,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"“%@”" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"(偏瘦)" : {
|
"(偏瘦)" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@@ -1205,6 +1208,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"AI 模型未就绪,可以手动填写" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"AI 模型未就绪,手动补充" : {
|
"AI 模型未就绪,手动补充" : {
|
||||||
|
|
||||||
@@ -1691,6 +1697,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"下次试试长按 + ,直接说出想记的内容" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"下载中" : {
|
"下载中" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@@ -2832,6 +2841,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"保存用药记录" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"偏低" : {
|
"偏低" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@@ -3065,6 +3077,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"全程本机" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"全部" : {
|
"全部" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@@ -3335,6 +3350,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"再加一种" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"再拍一项" : {
|
"再拍一项" : {
|
||||||
"extractionState" : "stale",
|
"extractionState" : "stale",
|
||||||
@@ -3358,6 +3376,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"再说一次" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"再问一轮 · 让 AI 从新角度追问" : {
|
"再问一轮 · 让 AI 从新角度追问" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@@ -3411,6 +3432,12 @@
|
|||||||
},
|
},
|
||||||
"写下要整理什么,或先提问补充情况…" : {
|
"写下要整理什么,或先提问补充情况…" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"写日记" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"写日记或拍药盒记录用药 · 可让 AI 辅助" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"冠心病" : {
|
"冠心病" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@@ -5115,6 +5142,9 @@
|
|||||||
},
|
},
|
||||||
"导出历史" : {
|
"导出历史" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"将记入健康日记(记录页可查),并同步到「当前用药」供 AI 解读参考。不提供任何用药建议。" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"将追加:" : {
|
"将追加:" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@@ -6579,6 +6609,9 @@
|
|||||||
},
|
},
|
||||||
"手动记录" : {
|
"手动记录" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"打开新建菜单" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"抑郁/焦虑" : {
|
"抑郁/焦虑" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@@ -6840,6 +6873,15 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"拍药盒" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"拍药盒或说明书,本地识别药名与规格" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"拍药盒自动识别" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"拖动方框对准要识别的指标,可拖右下角缩放" : {
|
"拖动方框对准要识别的指标,可拖右下角缩放" : {
|
||||||
|
|
||||||
@@ -6909,6 +6951,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"持续追踪" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"指标" : {
|
"指标" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@@ -7680,6 +7725,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"文字或语音" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"文字日记" : {
|
"文字日记" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@@ -8584,6 +8632,9 @@
|
|||||||
},
|
},
|
||||||
"本机不支持端侧语音识别" : {
|
"本机不支持端侧语音识别" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"本机不支持端侧语音识别,试试下面的新建菜单" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"本机保存" : {
|
"本机保存" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@@ -8892,6 +8943,9 @@
|
|||||||
},
|
},
|
||||||
"核对指标" : {
|
"核对指标" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"核对药品" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"核对识别结果" : {
|
"核对识别结果" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@@ -9071,6 +9125,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"正在听 · %llds" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"正在听 · 识别在本机完成" : {
|
"正在听 · 识别在本机完成" : {
|
||||||
|
|
||||||
@@ -9143,12 +9200,18 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"正在本地识别药品…" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"正在查看本地记录…" : {
|
"正在查看本地记录…" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"正在根据这些记录回答…" : {
|
"正在根据这些记录回答…" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"正在理解…" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"正常" : {
|
"正常" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@@ -9263,6 +9326,9 @@
|
|||||||
},
|
},
|
||||||
"每月%lld日" : {
|
"每月%lld日" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"比如:记一下血压 / 我头疼 / 拍个药盒" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"永久删除" : {
|
"永久删除" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@@ -9329,6 +9395,12 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"没听到内容,再试一次?" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"没听懂想记什么,再说一次,或直接选菜单" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"没听清,再试一次" : {
|
"没听清,再试一次" : {
|
||||||
|
|
||||||
@@ -9357,12 +9429,18 @@
|
|||||||
},
|
},
|
||||||
"没有识别到指标,点「加一项」手动补充,或返回重拍" : {
|
"没有识别到指标,点「加一项」手动补充,或返回重拍" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"没识别到文字,拍清楚一点再试" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"没识别到文字,挪一下框再试" : {
|
"没识别到文字,挪一下框再试" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"没读出指标,挪一下框再试" : {
|
"没读出指标,挪一下框再试" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"没读出药品,可以手动填写" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"测试 PROMPT" : {
|
"测试 PROMPT" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@@ -9501,6 +9579,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"照片与文字均不离开设备" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"特大" : {
|
"特大" : {
|
||||||
|
|
||||||
@@ -9783,6 +9864,15 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"用法,如:一日一次,一次一粒" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"用药" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"用药记录" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"甲状腺疾病" : {
|
"甲状腺疾病" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@@ -10764,6 +10854,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"药品名,如:缬沙坦胶囊" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"血压" : {
|
"血压" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@@ -10833,6 +10926,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"规格,如:80mg×7粒" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"解析失败:%@" : {
|
"解析失败:%@" : {
|
||||||
|
|
||||||
@@ -11043,6 +11139,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"记录身体状态、用药、感受 · 可让 AI 辅助" : {
|
"记录身体状态、用药、感受 · 可让 AI 辅助" : {
|
||||||
|
"extractionState" : "stale",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"en" : {
|
"en" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
@@ -11063,6 +11160,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"记症状" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"设备上的 AI 模型会尝试把专业指标转述为通俗说明,帮你记录并回顾自己的健康变化。" : {
|
"设备上的 AI 模型会尝试把专业指标转述为通俗说明,帮你记录并回顾自己的健康变化。" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@@ -11205,6 +11305,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"识别用药" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"识别超时,挪一下框再试或手动补充" : {
|
"识别超时,挪一下框再试或手动补充" : {
|
||||||
|
|
||||||
@@ -11318,12 +11421,21 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"语音和文字都只在本机处理,不会上传。" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"语音记录全程在本机完成,声音和文字都不会上传。请在设置中允许麦克风和语音识别。" : {
|
"语音记录全程在本机完成,声音和文字都不会上传。请在设置中允许麦克风和语音识别。" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"说一段" : {
|
"说一段" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"说出想记的内容" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"说完了" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"说完了,整理成日记" : {
|
"说完了,整理成日记" : {
|
||||||
|
|
||||||
@@ -11350,6 +11462,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"请开口说话…" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"请选择名为 %@ 的文件夹" : {
|
"请选择名为 %@ 的文件夹" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@@ -11677,6 +11792,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"轻点打开新建菜单,长按语音直达" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"载脂蛋白 A1" : {
|
"载脂蛋白 A1" : {
|
||||||
"extractionState" : "stale",
|
"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
|
@Model
|
||||||
final class Asset {
|
final class Asset {
|
||||||
var relativePath: String
|
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 SwiftUI
|
||||||
|
import SwiftData
|
||||||
|
import UIKit
|
||||||
|
|
||||||
enum TjTab: String, Hashable, CaseIterable {
|
enum TjTab: String, Hashable, CaseIterable {
|
||||||
case home, records, trend, me
|
case home, records, trend, me
|
||||||
@@ -35,6 +37,8 @@ enum ActiveFlow: Identifiable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
struct RootView: View {
|
struct RootView: View {
|
||||||
|
@Environment(\.modelContext) private var ctx
|
||||||
|
@Environment(\.scenePhase) private var scenePhase
|
||||||
@State private var tab: TjTab = .home
|
@State private var tab: TjTab = .home
|
||||||
/// 页面 push 过渡的来向:切到右侧 tab 时从 trailing 推入,切到左侧时从 leading 推入。
|
/// 页面 push 过渡的来向:切到右侧 tab 时从 trailing 推入,切到左侧时从 leading 推入。
|
||||||
@State private var pushEdge: Edge = .trailing
|
@State private var pushEdge: Edge = .trailing
|
||||||
@@ -45,6 +49,23 @@ struct RootView: View {
|
|||||||
@State private var showIndicator = false
|
@State private var showIndicator = false
|
||||||
@State private var showReminders = false
|
@State private var showReminders = false
|
||||||
@State private var showHealthExport = 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 切换入口:按方向设定 pushEdge,再带动画改 tab。
|
||||||
/// 所有改 tab 的地方都走这里,保证过渡方向正确。
|
/// 所有改 tab 的地方都走这里,保证过渡方向正确。
|
||||||
@@ -70,9 +91,15 @@ struct RootView: View {
|
|||||||
|
|
||||||
TabBar(active: tab,
|
TabBar(active: tab,
|
||||||
onTap: { select($0) },
|
onTap: { select($0) },
|
||||||
onTapRecord: { showRecordSheet = true })
|
onTapRecord: { showRecordSheet = true },
|
||||||
|
onLongPressRecord: { showVoiceCommand = true })
|
||||||
}
|
}
|
||||||
.background(Tj.Palette.sand.ignoresSafeArea())
|
.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) {
|
.sheet(isPresented: $showRecordSheet) {
|
||||||
RecordSheet { kind in
|
RecordSheet { kind in
|
||||||
showRecordSheet = false
|
showRecordSheet = false
|
||||||
@@ -111,6 +138,30 @@ struct RootView: View {
|
|||||||
.fullScreenCover(isPresented: $showHealthExport) {
|
.fullScreenCover(isPresented: $showHealthExport) {
|
||||||
HealthExportSheet()
|
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)
|
#if os(iOS)
|
||||||
.fullScreenCover(item: $activeFlow) { flow in
|
.fullScreenCover(item: $activeFlow) { flow in
|
||||||
switch flow {
|
switch flow {
|
||||||
@@ -137,8 +188,11 @@ private struct TabBar: View {
|
|||||||
let active: TjTab
|
let active: TjTab
|
||||||
let onTap: (TjTab) -> Void
|
let onTap: (TjTab) -> Void
|
||||||
let onTapRecord: () -> Void
|
let onTapRecord: () -> Void
|
||||||
|
let onLongPressRecord: () -> Void
|
||||||
|
|
||||||
@Namespace private var indicatorNS
|
@Namespace private var indicatorNS
|
||||||
|
/// + 号按压态(长按手势驱动的缩放视觉,代替 ButtonStyle)。
|
||||||
|
@State private var recordPressing = false
|
||||||
|
|
||||||
private let cornerRadius: CGFloat = 22
|
private let cornerRadius: CGFloat = 22
|
||||||
private let slotHeight: CGFloat = 34
|
private let slotHeight: CGFloat = 34
|
||||||
@@ -201,8 +255,10 @@ private struct TabBar: View {
|
|||||||
.buttonStyle(TabPressStyle())
|
.buttonStyle(TabPressStyle())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// + 号:点按 → 新建菜单;长按 → 语音直达。
|
||||||
|
/// 不用 Button + simultaneousGesture(长按成功后松手仍可能触发 tap 二次弹菜单),
|
||||||
|
/// 改为 tap / longPress 双手势 + onPressingChanged 驱动按压缩放。
|
||||||
private var recordSlot: some View {
|
private var recordSlot: some View {
|
||||||
Button(action: onTapRecord) {
|
|
||||||
VStack(spacing: 4) {
|
VStack(spacing: 4) {
|
||||||
ZStack {
|
ZStack {
|
||||||
Circle()
|
Circle()
|
||||||
@@ -226,8 +282,18 @@ private struct TabBar: View {
|
|||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
.contentShape(Rectangle())
|
.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
|
||||||
}
|
}
|
||||||
.buttonStyle(TabPressStyle())
|
.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)!
|
return Calendar(identifier: .gregorian).date(from: c)!
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
@Test func timelineKindOrderMatchesRecordFilterChips() {
|
||||||
|
#expect(TimelineKind.allCases == [.diary, .symptom, .indicator, .medication, .report])
|
||||||
|
}
|
||||||
|
|
||||||
@Test func todaySection() {
|
@Test func todaySection() {
|
||||||
#expect(TimelineGrouping.section(for: now, now: now) == .today)
|
#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