Files
pulse-mobile/PulseWidget/HabitsProgressWidget.swift
Daniil Klimov a07696bd55 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>
2026-04-06 14:47:26 +03:00

117 lines
4.7 KiB
Swift

import WidgetKit
import SwiftUI
struct HabitsEntry: TimelineEntry {
let date: Date
let completed: Int
let total: Int
let tasksCount: Int
var progress: Double { total > 0 ? Double(completed) / Double(total) : 0 }
}
struct HabitsProvider: TimelineProvider {
func placeholder(in context: Context) -> HabitsEntry {
HabitsEntry(date: Date(), completed: 3, total: 5, tasksCount: 2)
}
func getSnapshot(in context: Context, completion: @escaping (HabitsEntry) -> Void) {
completion(currentEntry())
}
func getTimeline(in context: Context, completion: @escaping (Timeline<HabitsEntry>) -> 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() -> HabitsEntry {
HabitsEntry(
date: Date(),
completed: WidgetData.habitsCompleted,
total: WidgetData.habitsTotal,
tasksCount: WidgetData.tasksCount
)
}
}
struct HabitsProgressWidget: Widget {
let kind = "HabitsProgress"
var body: some WidgetConfiguration {
StaticConfiguration(kind: kind, provider: HabitsProvider()) { entry in
HabitsWidgetView(entry: entry)
.containerBackground(.fill.tertiary, for: .widget)
}
.configurationDisplayName("Прогресс дня")
.description("Привычки и задачи на сегодня")
.supportedFamilies([.systemSmall, .systemMedium])
}
}
struct HabitsWidgetView: View {
let entry: HabitsEntry
@Environment(\.widgetFamily) var family
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: 10) {
ZStack {
Circle().stroke(Color.white.opacity(0.1), lineWidth: 8).frame(width: 64, height: 64)
Circle().trim(from: 0, to: entry.progress)
.stroke(Color(hex: "0D9488"), style: StrokeStyle(lineWidth: 8, lineCap: .round))
.frame(width: 64, height: 64).rotationEffect(.degrees(-90))
VStack(spacing: 0) {
Text("\(entry.completed)").font(.system(size: 20, weight: .bold, design: .rounded)).foregroundColor(.white)
Text("/\(entry.total)").font(.system(size: 11)).foregroundColor(.gray)
}
}
Text("Привычки").font(.caption2).foregroundColor(.gray)
}
}
var mediumView: some View {
HStack(spacing: 16) {
ZStack {
Circle().stroke(Color.white.opacity(0.1), lineWidth: 8).frame(width: 72, height: 72)
Circle().trim(from: 0, to: entry.progress)
.stroke(Color(hex: "0D9488"), style: StrokeStyle(lineWidth: 8, lineCap: .round))
.frame(width: 72, height: 72).rotationEffect(.degrees(-90))
VStack(spacing: 0) {
Text("\(entry.completed)").font(.system(size: 22, weight: .bold, design: .rounded)).foregroundColor(.white)
Text("/\(entry.total)").font(.system(size: 12)).foregroundColor(.gray)
}
}
VStack(alignment: .leading, spacing: 8) {
Text("Прогресс дня").font(.subheadline.bold()).foregroundColor(.white)
HStack(spacing: 12) {
Label("\(entry.completed) готово", systemImage: "checkmark.circle.fill").font(.caption2).foregroundColor(Color(hex: "0D9488"))
Label("\(entry.tasksCount) задач", systemImage: "calendar").font(.caption2).foregroundColor(Color(hex: "6366f1"))
}
GeometryReader { geo in
ZStack(alignment: .leading) {
RoundedRectangle(cornerRadius: 3).fill(Color.white.opacity(0.1))
RoundedRectangle(cornerRadius: 3).fill(Color(hex: "0D9488")).frame(width: geo.size.width * entry.progress)
}
}.frame(height: 6)
}
}.padding(16)
}
}
extension Color {
init(hex: String) {
let hex = hex.trimmingCharacters(in: CharacterSet.alphanumerics.inverted)
var int: UInt64 = 0; Scanner(string: hex).scanHexInt64(&int)
let r, g, b: UInt64
if hex.count == 6 { (r, g, b) = (int >> 16, int >> 8 & 0xFF, int & 0xFF) }
else { (r, g, b) = (0, 0, 0) }
self.init(.sRGB, red: Double(r)/255, green: Double(g)/255, blue: Double(b)/255)
}
}