feat: 添加拍药盒功能和语音直达入口 - 实现拍药盒扫描流程,支持本地OCR识别药品信息 - 在日记页面添加拍药盒和记症状的三选一入口 - 优化按钮点击区域,确保符合苹果HIG最小命中区标准 - 添加用药记录到时间线的独立分类显示 - 实现长按+号语音直达功能,支持语音意图分类跳转 - 更新项目配置文件,启用代码分析和死代码剥离选项 - 增加多项本地化字符串支持新功能 ```
250 lines
8.9 KiB
Swift
250 lines
8.9 KiB
Swift
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)
|
||
}
|