Files
pulse-mobile/PulseWidget/HealthSummaryWidget.swift
Daniil Klimov f2580eb69f feat: iOS widgets + fix sleep showing yesterday's data
Widgets:
- HabitsProgressWidget (small/medium): progress ring, completed/total habits, tasks count
- HealthSummaryWidget (small/medium): readiness score, steps, sleep, heart rate
- Shared Keychain access group for app ↔ widget token sharing
- Widget data refreshes every 30 minutes

Sleep fix:
- Changed sleep window from "24 hours back" to "6 PM yesterday → now"
- Captures overnight sleep correctly without showing previous day's data
- Applied to both fetchSleepData (sync) and fetchSleepSegments (detail view)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 14:22:37 +03:00

187 lines
6.9 KiB
Swift

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 getSnapshot(in context: Context, completion: @escaping (HealthEntry) -> Void) {
completion(.placeholder)
}
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)))
}
}
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)
}
}
// 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)
.containerBackground(.fill.tertiary, for: .widget)
}
.configurationDisplayName("Здоровье")
.description("Шаги, сон, пульс и готовность")
.supportedFamilies([.systemSmall, .systemMedium])
}
}
// 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 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)
.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)
}
}
}
.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)
.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)
}
}
// 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"))
}
}
}
.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)
}
}
}