- Widgets can't access app's Keychain (different sandbox) - App writes data to shared UserDefaults (group.com.daniil.pulsehealth) - Widgets read from shared UserDefaults — no API calls needed - WidgetDataService: updates widget data + reloads timelines - DashboardView: pushes habits/tasks data to widget after load - HealthView: pushes health data to widget after load - App Group capability added to both app and widget entitlements - Widgets update every 15 minutes from cached data Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
123 lines
5.0 KiB
Swift
123 lines
5.0 KiB
Swift
import WidgetKit
|
|
import SwiftUI
|
|
|
|
struct HealthEntry: TimelineEntry {
|
|
let date: Date
|
|
let steps: Int
|
|
let sleep: Double
|
|
let heartRate: Int
|
|
let readinessScore: Int
|
|
}
|
|
|
|
struct HealthProvider: TimelineProvider {
|
|
func placeholder(in context: Context) -> HealthEntry {
|
|
HealthEntry(date: Date(), steps: 6234, sleep: 7.5, heartRate: 68, readinessScore: 80)
|
|
}
|
|
|
|
func getSnapshot(in context: Context, completion: @escaping (HealthEntry) -> Void) {
|
|
completion(currentEntry())
|
|
}
|
|
|
|
func getTimeline(in context: Context, completion: @escaping (Timeline<HealthEntry>) -> Void) {
|
|
let entry = currentEntry()
|
|
let next = Calendar.current.date(byAdding: .minute, value: 15, to: Date()) ?? Date()
|
|
completion(Timeline(entries: [entry], policy: .after(next)))
|
|
}
|
|
|
|
private func currentEntry() -> HealthEntry {
|
|
HealthEntry(
|
|
date: Date(),
|
|
steps: WidgetData.steps,
|
|
sleep: WidgetData.sleep,
|
|
heartRate: WidgetData.heartRate,
|
|
readinessScore: WidgetData.readinessScore
|
|
)
|
|
}
|
|
}
|
|
|
|
struct HealthSummaryWidget: Widget {
|
|
let kind = "HealthSummary"
|
|
var body: some WidgetConfiguration {
|
|
StaticConfiguration(kind: kind, provider: HealthProvider()) { entry in
|
|
HealthWidgetView(entry: entry)
|
|
.containerBackground(.fill.tertiary, for: .widget)
|
|
}
|
|
.configurationDisplayName("Здоровье")
|
|
.description("Шаги, сон, пульс и готовность")
|
|
.supportedFamilies([.systemSmall, .systemMedium])
|
|
}
|
|
}
|
|
|
|
struct HealthWidgetView: View {
|
|
let entry: HealthEntry
|
|
@Environment(\.widgetFamily) var family
|
|
|
|
var readinessColor: Color {
|
|
if entry.readinessScore >= 80 { return Color(hex: "0D9488") }
|
|
if entry.readinessScore >= 60 { return Color(hex: "ffa502") }
|
|
return Color(hex: "ff4757")
|
|
}
|
|
|
|
var body: some View {
|
|
Group {
|
|
if family == .systemMedium { mediumView } else { smallView }
|
|
}
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
.background(Color(hex: "06060f"))
|
|
}
|
|
|
|
var smallView: some View {
|
|
VStack(spacing: 8) {
|
|
ZStack {
|
|
Circle().stroke(Color.white.opacity(0.1), lineWidth: 6).frame(width: 50, height: 50)
|
|
Circle().trim(from: 0, to: CGFloat(entry.readinessScore) / 100)
|
|
.stroke(readinessColor, style: StrokeStyle(lineWidth: 6, lineCap: .round))
|
|
.frame(width: 50, height: 50).rotationEffect(.degrees(-90))
|
|
Text("\(entry.readinessScore)").font(.system(size: 16, weight: .bold, design: .rounded)).foregroundColor(readinessColor)
|
|
}
|
|
VStack(spacing: 4) {
|
|
HStack(spacing: 4) {
|
|
Image(systemName: "figure.walk").font(.system(size: 9)).foregroundColor(Color(hex: "ffa502"))
|
|
Text("\(entry.steps)").font(.caption2.bold()).foregroundColor(.white)
|
|
}
|
|
HStack(spacing: 4) {
|
|
Image(systemName: "moon.fill").font(.system(size: 9)).foregroundColor(Color(hex: "7c3aed"))
|
|
Text(String(format: "%.1fч", entry.sleep)).font(.caption2.bold()).foregroundColor(.white)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
var mediumView: some View {
|
|
HStack(spacing: 16) {
|
|
ZStack {
|
|
Circle().stroke(Color.white.opacity(0.1), lineWidth: 8).frame(width: 68, height: 68)
|
|
Circle().trim(from: 0, to: CGFloat(entry.readinessScore) / 100)
|
|
.stroke(readinessColor, style: StrokeStyle(lineWidth: 8, lineCap: .round))
|
|
.frame(width: 68, height: 68).rotationEffect(.degrees(-90))
|
|
VStack(spacing: 0) {
|
|
Text("\(entry.readinessScore)").font(.system(size: 20, weight: .bold, design: .rounded)).foregroundColor(readinessColor)
|
|
Text("балл").font(.system(size: 9)).foregroundColor(.gray)
|
|
}
|
|
}
|
|
VStack(alignment: .leading, spacing: 6) {
|
|
Text("Здоровье").font(.subheadline.bold()).foregroundColor(.white)
|
|
HStack(spacing: 14) {
|
|
VStack(spacing: 3) {
|
|
Image(systemName: "figure.walk").font(.caption).foregroundColor(Color(hex: "ffa502"))
|
|
Text("\(entry.steps)").font(.caption2.bold()).foregroundColor(.white)
|
|
}
|
|
VStack(spacing: 3) {
|
|
Image(systemName: "moon.fill").font(.caption).foregroundColor(Color(hex: "7c3aed"))
|
|
Text(String(format: "%.1fч", entry.sleep)).font(.caption2.bold()).foregroundColor(.white)
|
|
}
|
|
VStack(spacing: 3) {
|
|
Image(systemName: "heart.fill").font(.caption).foregroundColor(Color(hex: "ff4757"))
|
|
Text("\(entry.heartRate)").font(.caption2.bold()).foregroundColor(.white)
|
|
}
|
|
}
|
|
}
|
|
}.padding(16)
|
|
}
|
|
}
|