Files
kangkang/KangkangWidget-src/PinnedIndicatorsWidget.swift
link2026 6c6a950140 ```
feat: 添加拍药盒功能和语音直达入口

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

250 lines
8.9 KiB
Swift
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}