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