fix: widgets use App Group shared UserDefaults instead of Keychain
- 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>
This commit is contained in:
@@ -1,90 +1,42 @@
|
||||
import WidgetKit
|
||||
import SwiftUI
|
||||
|
||||
// MARK: - Data
|
||||
|
||||
struct HealthEntry: TimelineEntry {
|
||||
let date: Date
|
||||
let steps: Int
|
||||
let sleep: Double
|
||||
let heartRate: Int
|
||||
let readinessScore: Int
|
||||
|
||||
static let placeholder = HealthEntry(date: Date(), steps: 6234, sleep: 7.5, heartRate: 68, readinessScore: 80)
|
||||
}
|
||||
|
||||
// MARK: - Provider
|
||||
|
||||
struct HealthProvider: TimelineProvider {
|
||||
func placeholder(in context: Context) -> HealthEntry { .placeholder }
|
||||
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(.placeholder)
|
||||
completion(currentEntry())
|
||||
}
|
||||
|
||||
func getTimeline(in context: Context, completion: @escaping (Timeline<HealthEntry>) -> Void) {
|
||||
Task {
|
||||
let entry = await fetchHealthData()
|
||||
let nextUpdate = Calendar.current.date(byAdding: .minute, value: 30, to: Date()) ?? Date()
|
||||
completion(Timeline(entries: [entry], policy: .after(nextUpdate)))
|
||||
}
|
||||
let entry = currentEntry()
|
||||
let next = Calendar.current.date(byAdding: .minute, value: 15, to: Date()) ?? Date()
|
||||
completion(Timeline(entries: [entry], policy: .after(next)))
|
||||
}
|
||||
|
||||
private func fetchHealthData() async -> HealthEntry {
|
||||
let baseURL = "https://health.digital-home.site"
|
||||
guard let token = KeychainService.load(key: KeychainService.healthTokenKey) else {
|
||||
return .placeholder
|
||||
}
|
||||
|
||||
// Fetch latest
|
||||
var steps = 0, sleep = 0.0, hr = 0
|
||||
|
||||
if let url = URL(string: "\(baseURL)/api/health/latest") {
|
||||
var req = URLRequest(url: url)
|
||||
req.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
|
||||
req.timeoutInterval = 10
|
||||
if let (data, resp) = try? await URLSession.shared.data(for: req),
|
||||
(resp as? HTTPURLResponse)?.statusCode == 200,
|
||||
let json = try? JSONDecoder().decode(WidgetHealthLatest.self, from: data) {
|
||||
steps = json.steps?.total ?? 0
|
||||
sleep = json.sleep?.totalSleep ?? 0
|
||||
hr = Int(json.restingHeartRate?.value ?? 0)
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch readiness
|
||||
var score = 0
|
||||
if let url = URL(string: "\(baseURL)/api/health/readiness") {
|
||||
var req = URLRequest(url: url)
|
||||
req.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
|
||||
req.timeoutInterval = 10
|
||||
if let (data, resp) = try? await URLSession.shared.data(for: req),
|
||||
(resp as? HTTPURLResponse)?.statusCode == 200,
|
||||
let json = try? JSONDecoder().decode(WidgetReadiness.self, from: data) {
|
||||
score = json.score
|
||||
}
|
||||
}
|
||||
|
||||
return HealthEntry(date: Date(), steps: steps, sleep: sleep, heartRate: hr, readinessScore: score)
|
||||
private func currentEntry() -> HealthEntry {
|
||||
HealthEntry(
|
||||
date: Date(),
|
||||
steps: WidgetData.steps,
|
||||
sleep: WidgetData.sleep,
|
||||
heartRate: WidgetData.heartRate,
|
||||
readinessScore: WidgetData.readinessScore
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Lightweight models
|
||||
struct WidgetHealthLatest: Codable {
|
||||
let steps: WidgetSteps?
|
||||
let sleep: WidgetSleep?
|
||||
let restingHeartRate: WidgetRHR?
|
||||
}
|
||||
struct WidgetSteps: Codable { let total: Int? }
|
||||
struct WidgetSleep: Codable { let totalSleep: Double? }
|
||||
struct WidgetRHR: Codable { let value: Double? }
|
||||
struct WidgetReadiness: Codable { let score: Int }
|
||||
|
||||
// MARK: - Widget
|
||||
|
||||
struct HealthSummaryWidget: Widget {
|
||||
let kind = "HealthSummary"
|
||||
|
||||
var body: some WidgetConfiguration {
|
||||
StaticConfiguration(kind: kind, provider: HealthProvider()) { entry in
|
||||
HealthWidgetView(entry: entry)
|
||||
@@ -96,29 +48,26 @@ struct HealthSummaryWidget: Widget {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Views
|
||||
|
||||
struct HealthWidgetView: View {
|
||||
let entry: HealthEntry
|
||||
@Environment(\.widgetFamily) var family
|
||||
|
||||
var body: some View {
|
||||
switch family {
|
||||
case .systemSmall: smallView
|
||||
case .systemMedium: mediumView
|
||||
default: smallView
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
// Readiness
|
||||
ZStack {
|
||||
Circle().stroke(Color.white.opacity(0.1), lineWidth: 6).frame(width: 50, height: 50)
|
||||
Circle().trim(from: 0, to: CGFloat(entry.readinessScore) / 100)
|
||||
@@ -126,7 +75,6 @@ struct HealthWidgetView: View {
|
||||
.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"))
|
||||
@@ -138,13 +86,10 @@ struct HealthWidgetView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.background(Color(hex: "06060f"))
|
||||
}
|
||||
|
||||
var mediumView: some View {
|
||||
HStack(spacing: 16) {
|
||||
// Readiness ring
|
||||
ZStack {
|
||||
Circle().stroke(Color.white.opacity(0.1), lineWidth: 8).frame(width: 68, height: 68)
|
||||
Circle().trim(from: 0, to: CGFloat(entry.readinessScore) / 100)
|
||||
@@ -155,32 +100,23 @@ struct HealthWidgetView: View {
|
||||
Text("балл").font(.system(size: 9)).foregroundColor(.gray)
|
||||
}
|
||||
}
|
||||
|
||||
// Metrics
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text("Здоровье").font(.subheadline.bold()).foregroundColor(.white)
|
||||
HStack(spacing: 14) {
|
||||
HealthBadge(icon: "figure.walk", value: "\(entry.steps)", color: Color(hex: "ffa502"))
|
||||
HealthBadge(icon: "moon.fill", value: String(format: "%.1fч", entry.sleep), color: Color(hex: "7c3aed"))
|
||||
HealthBadge(icon: "heart.fill", value: "\(entry.heartRate)", color: Color(hex: "ff4757"))
|
||||
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)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.background(Color(hex: "06060f"))
|
||||
}
|
||||
}
|
||||
|
||||
struct HealthBadge: View {
|
||||
let icon: String
|
||||
let value: String
|
||||
let color: Color
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 3) {
|
||||
Image(systemName: icon).font(.caption).foregroundColor(color)
|
||||
Text(value).font(.caption2.bold()).foregroundColor(.white)
|
||||
}
|
||||
}.padding(16)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user