feat: 添加拍药盒功能和语音直达入口

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

View 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)
}