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>
This commit is contained in:
246
PulseWidget/HabitsProgressWidget.swift
Normal file
246
PulseWidget/HabitsProgressWidget.swift
Normal file
@@ -0,0 +1,246 @@
|
||||
import WidgetKit
|
||||
import SwiftUI
|
||||
|
||||
// MARK: - Data
|
||||
|
||||
struct HabitsEntry: TimelineEntry {
|
||||
let date: Date
|
||||
let completed: Int
|
||||
let total: Int
|
||||
let tasksCount: Int
|
||||
let streakDays: Int
|
||||
|
||||
var progress: Double {
|
||||
total > 0 ? Double(completed) / Double(total) : 0
|
||||
}
|
||||
|
||||
static let placeholder = HabitsEntry(date: Date(), completed: 3, total: 5, tasksCount: 2, streakDays: 7)
|
||||
}
|
||||
|
||||
// MARK: - Provider
|
||||
|
||||
struct HabitsProvider: TimelineProvider {
|
||||
func placeholder(in context: Context) -> HabitsEntry { .placeholder }
|
||||
|
||||
func getSnapshot(in context: Context, completion: @escaping (HabitsEntry) -> Void) {
|
||||
completion(.placeholder)
|
||||
}
|
||||
|
||||
func getTimeline(in context: Context, completion: @escaping (Timeline<HabitsEntry>) -> Void) {
|
||||
Task {
|
||||
let entry = await fetchData()
|
||||
let nextUpdate = Calendar.current.date(byAdding: .minute, value: 30, to: Date()) ?? Date()
|
||||
completion(Timeline(entries: [entry], policy: .after(nextUpdate)))
|
||||
}
|
||||
}
|
||||
|
||||
private func fetchData() async -> HabitsEntry {
|
||||
guard let token = KeychainService.load(key: KeychainService.tokenKey), !token.isEmpty else {
|
||||
return .placeholder
|
||||
}
|
||||
|
||||
let baseURL = "https://api.digital-home.site"
|
||||
|
||||
// Fetch habits
|
||||
var habits: [WidgetHabit] = []
|
||||
if let url = URL(string: "\(baseURL)/habits") {
|
||||
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 {
|
||||
habits = (try? JSONDecoder().decode([WidgetHabit].self, from: data)) ?? []
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch today's tasks
|
||||
var tasks: [WidgetTask] = []
|
||||
if let url = URL(string: "\(baseURL)/tasks/today") {
|
||||
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 {
|
||||
tasks = (try? JSONDecoder().decode([WidgetTask].self, from: data)) ?? []
|
||||
}
|
||||
}
|
||||
|
||||
let activeHabits = habits.filter { !($0.isArchived ?? false) }
|
||||
let todayWeekday = Calendar.current.component(.weekday, from: Date()) - 1
|
||||
let todayHabits = activeHabits.filter { habit in
|
||||
guard habit.frequency == "weekly", let days = habit.targetDays, !days.isEmpty else { return true }
|
||||
return days.contains(todayWeekday)
|
||||
}
|
||||
|
||||
// Check completed today (simplified — check completedToday from API or logs)
|
||||
let completed = todayHabits.filter { $0.completedToday ?? false }.count
|
||||
let activeTasks = tasks.filter { !$0.completed }.count
|
||||
|
||||
return HabitsEntry(
|
||||
date: Date(),
|
||||
completed: completed,
|
||||
total: todayHabits.count,
|
||||
tasksCount: activeTasks,
|
||||
streakDays: 0
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Lightweight models for widget
|
||||
struct WidgetHabit: Codable {
|
||||
let id: Int
|
||||
let frequency: String
|
||||
let targetDays: [Int]?
|
||||
let isArchived: Bool?
|
||||
let completedToday: Bool?
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id, frequency
|
||||
case targetDays = "target_days"
|
||||
case isArchived = "is_archived"
|
||||
case completedToday = "completed_today"
|
||||
}
|
||||
}
|
||||
|
||||
struct WidgetTask: Codable {
|
||||
let id: Int
|
||||
let completed: Bool
|
||||
}
|
||||
|
||||
// MARK: - Views
|
||||
|
||||
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 {
|
||||
switch family {
|
||||
case .systemSmall: smallView
|
||||
case .systemMedium: mediumView
|
||||
default: smallView
|
||||
}
|
||||
}
|
||||
|
||||
var smallView: some View {
|
||||
VStack(spacing: 10) {
|
||||
// Ring
|
||||
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)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.background(Color(hex: "06060f"))
|
||||
}
|
||||
|
||||
var mediumView: some View {
|
||||
HStack(spacing: 16) {
|
||||
// Left: ring
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// Right: stats
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Прогресс дня")
|
||||
.font(.subheadline.bold())
|
||||
.foregroundColor(.white)
|
||||
|
||||
HStack(spacing: 12) {
|
||||
StatBadge(icon: "checkmark.circle.fill", value: "\(entry.completed)", label: "Готово", color: Color(hex: "0D9488"))
|
||||
StatBadge(icon: "calendar", value: "\(entry.tasksCount)", label: "Задач", color: Color(hex: "6366f1"))
|
||||
}
|
||||
|
||||
// Progress bar
|
||||
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)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.background(Color(hex: "06060f"))
|
||||
}
|
||||
}
|
||||
|
||||
struct StatBadge: View {
|
||||
let icon: String
|
||||
let value: String
|
||||
let label: String
|
||||
let color: Color
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: icon).font(.caption2).foregroundColor(color)
|
||||
Text(value).font(.caption.bold()).foregroundColor(.white)
|
||||
Text(label).font(.caption2).foregroundColor(.gray)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Color extension for widget
|
||||
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
|
||||
switch hex.count {
|
||||
case 6: (r, g, b) = (int >> 16, int >> 8 & 0xFF, int & 0xFF)
|
||||
default: (r, g, b) = (0, 0, 0)
|
||||
}
|
||||
self.init(.sRGB, red: Double(r)/255, green: Double(g)/255, blue: Double(b)/255)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user