- 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>
117 lines
4.7 KiB
Swift
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)
|
|
}
|
|
}
|