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:
@@ -6,9 +6,9 @@
|
|||||||
<true/>
|
<true/>
|
||||||
<key>com.apple.developer.healthkit.background-delivery</key>
|
<key>com.apple.developer.healthkit.background-delivery</key>
|
||||||
<true/>
|
<true/>
|
||||||
<key>keychain-access-groups</key>
|
<key>com.apple.security.application-groups</key>
|
||||||
<array>
|
<array>
|
||||||
<string>$(AppIdentifierPrefix)com.daniil.pulsehealth.shared</string>
|
<string>group.com.daniil.pulsehealth</string>
|
||||||
</array>
|
</array>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|||||||
25
PulseHealth/Services/WidgetDataService.swift
Normal file
25
PulseHealth/Services/WidgetDataService.swift
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import Foundation
|
||||||
|
import WidgetKit
|
||||||
|
|
||||||
|
enum WidgetDataService {
|
||||||
|
static let suiteName = "group.com.daniil.pulsehealth"
|
||||||
|
|
||||||
|
static var shared: UserDefaults? {
|
||||||
|
UserDefaults(suiteName: suiteName)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func updateHabits(completed: Int, total: Int, tasksCount: Int) {
|
||||||
|
shared?.set(completed, forKey: "w_habits_completed")
|
||||||
|
shared?.set(total, forKey: "w_habits_total")
|
||||||
|
shared?.set(tasksCount, forKey: "w_tasks_count")
|
||||||
|
WidgetCenter.shared.reloadTimelines(ofKind: "HabitsProgress")
|
||||||
|
}
|
||||||
|
|
||||||
|
static func updateHealth(steps: Int, sleep: Double, heartRate: Int, readiness: Int) {
|
||||||
|
shared?.set(steps, forKey: "w_steps")
|
||||||
|
shared?.set(sleep, forKey: "w_sleep")
|
||||||
|
shared?.set(heartRate, forKey: "w_heart_rate")
|
||||||
|
shared?.set(readiness, forKey: "w_readiness")
|
||||||
|
WidgetCenter.shared.reloadTimelines(ofKind: "HealthSummary")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -203,6 +203,11 @@ struct DashboardView: View {
|
|||||||
}
|
}
|
||||||
todayHabits = habits
|
todayHabits = habits
|
||||||
isLoading = false
|
isLoading = false
|
||||||
|
|
||||||
|
// Update widget
|
||||||
|
let completed = habits.filter { $0.completedToday == true }.count
|
||||||
|
let activeTasks = todayTasks.filter { !$0.completed }.count
|
||||||
|
WidgetDataService.updateHabits(completed: completed, total: habits.count, tasksCount: activeTasks)
|
||||||
}
|
}
|
||||||
|
|
||||||
func filterHabitsForToday(_ habits: [Habit]) -> [Habit] {
|
func filterHabitsForToday(_ habits: [Habit]) -> [Habit] {
|
||||||
|
|||||||
@@ -200,6 +200,14 @@ struct HealthView: View {
|
|||||||
async let h = HealthAPIService.shared.getHeatmap(days: 7)
|
async let h = HealthAPIService.shared.getHeatmap(days: 7)
|
||||||
readiness = try? await r; latest = try? await l; heatmapData = (try? await h) ?? []
|
readiness = try? await r; latest = try? await l; heatmapData = (try? await h) ?? []
|
||||||
isLoading = false
|
isLoading = false
|
||||||
|
|
||||||
|
// Update widget
|
||||||
|
WidgetDataService.updateHealth(
|
||||||
|
steps: latest?.steps?.total ?? 0,
|
||||||
|
sleep: latest?.sleep?.totalSleep ?? 0,
|
||||||
|
heartRate: Int(latest?.restingHeartRate?.value ?? 0),
|
||||||
|
readiness: readiness?.score ?? 0
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
func syncHealthKit() async {
|
func syncHealthKit() async {
|
||||||
|
|||||||
@@ -1,116 +1,41 @@
|
|||||||
import WidgetKit
|
import WidgetKit
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
// MARK: - Data
|
|
||||||
|
|
||||||
struct HabitsEntry: TimelineEntry {
|
struct HabitsEntry: TimelineEntry {
|
||||||
let date: Date
|
let date: Date
|
||||||
let completed: Int
|
let completed: Int
|
||||||
let total: Int
|
let total: Int
|
||||||
let tasksCount: Int
|
let tasksCount: Int
|
||||||
let streakDays: Int
|
var progress: Double { total > 0 ? Double(completed) / Double(total) : 0 }
|
||||||
|
|
||||||
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 {
|
struct HabitsProvider: TimelineProvider {
|
||||||
func placeholder(in context: Context) -> HabitsEntry { .placeholder }
|
func placeholder(in context: Context) -> HabitsEntry {
|
||||||
|
HabitsEntry(date: Date(), completed: 3, total: 5, tasksCount: 2)
|
||||||
|
}
|
||||||
|
|
||||||
func getSnapshot(in context: Context, completion: @escaping (HabitsEntry) -> Void) {
|
func getSnapshot(in context: Context, completion: @escaping (HabitsEntry) -> Void) {
|
||||||
completion(.placeholder)
|
completion(currentEntry())
|
||||||
}
|
}
|
||||||
|
|
||||||
func getTimeline(in context: Context, completion: @escaping (Timeline<HabitsEntry>) -> Void) {
|
func getTimeline(in context: Context, completion: @escaping (Timeline<HabitsEntry>) -> Void) {
|
||||||
Task {
|
let entry = currentEntry()
|
||||||
let entry = await fetchData()
|
let next = Calendar.current.date(byAdding: .minute, value: 15, to: Date()) ?? Date()
|
||||||
let nextUpdate = Calendar.current.date(byAdding: .minute, value: 30, to: Date()) ?? Date()
|
completion(Timeline(entries: [entry], policy: .after(next)))
|
||||||
completion(Timeline(entries: [entry], policy: .after(nextUpdate)))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func fetchData() async -> HabitsEntry {
|
private func currentEntry() -> HabitsEntry {
|
||||||
guard let token = KeychainService.load(key: KeychainService.tokenKey), !token.isEmpty else {
|
HabitsEntry(
|
||||||
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(),
|
date: Date(),
|
||||||
completed: completed,
|
completed: WidgetData.habitsCompleted,
|
||||||
total: todayHabits.count,
|
total: WidgetData.habitsTotal,
|
||||||
tasksCount: activeTasks,
|
tasksCount: WidgetData.tasksCount
|
||||||
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 {
|
struct HabitsProgressWidget: Widget {
|
||||||
let kind = "HabitsProgress"
|
let kind = "HabitsProgress"
|
||||||
|
|
||||||
var body: some WidgetConfiguration {
|
var body: some WidgetConfiguration {
|
||||||
StaticConfiguration(kind: kind, provider: HabitsProvider()) { entry in
|
StaticConfiguration(kind: kind, provider: HabitsProvider()) { entry in
|
||||||
HabitsWidgetView(entry: entry)
|
HabitsWidgetView(entry: entry)
|
||||||
@@ -127,120 +52,65 @@ struct HabitsWidgetView: View {
|
|||||||
@Environment(\.widgetFamily) var family
|
@Environment(\.widgetFamily) var family
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
switch family {
|
Group {
|
||||||
case .systemSmall: smallView
|
if family == .systemMedium { mediumView } else { smallView }
|
||||||
case .systemMedium: mediumView
|
|
||||||
default: smallView
|
|
||||||
}
|
}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
.background(Color(hex: "06060f"))
|
||||||
}
|
}
|
||||||
|
|
||||||
var smallView: some View {
|
var smallView: some View {
|
||||||
VStack(spacing: 10) {
|
VStack(spacing: 10) {
|
||||||
// Ring
|
|
||||||
ZStack {
|
ZStack {
|
||||||
Circle()
|
Circle().stroke(Color.white.opacity(0.1), lineWidth: 8).frame(width: 64, height: 64)
|
||||||
.stroke(Color.white.opacity(0.1), lineWidth: 8)
|
Circle().trim(from: 0, to: entry.progress)
|
||||||
.frame(width: 64, height: 64)
|
|
||||||
Circle()
|
|
||||||
.trim(from: 0, to: entry.progress)
|
|
||||||
.stroke(Color(hex: "0D9488"), style: StrokeStyle(lineWidth: 8, lineCap: .round))
|
.stroke(Color(hex: "0D9488"), style: StrokeStyle(lineWidth: 8, lineCap: .round))
|
||||||
.frame(width: 64, height: 64)
|
.frame(width: 64, height: 64).rotationEffect(.degrees(-90))
|
||||||
.rotationEffect(.degrees(-90))
|
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
Text("\(entry.completed)")
|
Text("\(entry.completed)").font(.system(size: 20, weight: .bold, design: .rounded)).foregroundColor(.white)
|
||||||
.font(.system(size: 20, weight: .bold, design: .rounded))
|
Text("/\(entry.total)").font(.system(size: 11)).foregroundColor(.gray)
|
||||||
.foregroundColor(.white)
|
|
||||||
Text("/\(entry.total)")
|
|
||||||
.font(.system(size: 11))
|
|
||||||
.foregroundColor(.gray)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Text("Привычки").font(.caption2).foregroundColor(.gray)
|
||||||
Text("Привычки")
|
|
||||||
.font(.caption2)
|
|
||||||
.foregroundColor(.gray)
|
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
||||||
.background(Color(hex: "06060f"))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var mediumView: some View {
|
var mediumView: some View {
|
||||||
HStack(spacing: 16) {
|
HStack(spacing: 16) {
|
||||||
// Left: ring
|
|
||||||
ZStack {
|
ZStack {
|
||||||
Circle()
|
Circle().stroke(Color.white.opacity(0.1), lineWidth: 8).frame(width: 72, height: 72)
|
||||||
.stroke(Color.white.opacity(0.1), lineWidth: 8)
|
Circle().trim(from: 0, to: entry.progress)
|
||||||
.frame(width: 72, height: 72)
|
|
||||||
Circle()
|
|
||||||
.trim(from: 0, to: entry.progress)
|
|
||||||
.stroke(Color(hex: "0D9488"), style: StrokeStyle(lineWidth: 8, lineCap: .round))
|
.stroke(Color(hex: "0D9488"), style: StrokeStyle(lineWidth: 8, lineCap: .round))
|
||||||
.frame(width: 72, height: 72)
|
.frame(width: 72, height: 72).rotationEffect(.degrees(-90))
|
||||||
.rotationEffect(.degrees(-90))
|
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
Text("\(entry.completed)")
|
Text("\(entry.completed)").font(.system(size: 22, weight: .bold, design: .rounded)).foregroundColor(.white)
|
||||||
.font(.system(size: 22, weight: .bold, design: .rounded))
|
Text("/\(entry.total)").font(.system(size: 12)).foregroundColor(.gray)
|
||||||
.foregroundColor(.white)
|
|
||||||
Text("/\(entry.total)")
|
|
||||||
.font(.system(size: 12))
|
|
||||||
.foregroundColor(.gray)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Right: stats
|
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
Text("Прогресс дня")
|
Text("Прогресс дня").font(.subheadline.bold()).foregroundColor(.white)
|
||||||
.font(.subheadline.bold())
|
|
||||||
.foregroundColor(.white)
|
|
||||||
|
|
||||||
HStack(spacing: 12) {
|
HStack(spacing: 12) {
|
||||||
StatBadge(icon: "checkmark.circle.fill", value: "\(entry.completed)", label: "Готово", color: Color(hex: "0D9488"))
|
Label("\(entry.completed) готово", systemImage: "checkmark.circle.fill").font(.caption2).foregroundColor(Color(hex: "0D9488"))
|
||||||
StatBadge(icon: "calendar", value: "\(entry.tasksCount)", label: "Задач", color: Color(hex: "6366f1"))
|
Label("\(entry.tasksCount) задач", systemImage: "calendar").font(.caption2).foregroundColor(Color(hex: "6366f1"))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Progress bar
|
|
||||||
GeometryReader { geo in
|
GeometryReader { geo in
|
||||||
ZStack(alignment: .leading) {
|
ZStack(alignment: .leading) {
|
||||||
RoundedRectangle(cornerRadius: 3).fill(Color.white.opacity(0.1))
|
RoundedRectangle(cornerRadius: 3).fill(Color.white.opacity(0.1))
|
||||||
RoundedRectangle(cornerRadius: 3)
|
RoundedRectangle(cornerRadius: 3).fill(Color(hex: "0D9488")).frame(width: geo.size.width * entry.progress)
|
||||||
.fill(Color(hex: "0D9488"))
|
|
||||||
.frame(width: geo.size.width * entry.progress)
|
|
||||||
}
|
}
|
||||||
|
}.frame(height: 6)
|
||||||
}
|
}
|
||||||
.frame(height: 6)
|
}.padding(16)
|
||||||
}
|
|
||||||
}
|
|
||||||
.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 {
|
extension Color {
|
||||||
init(hex: String) {
|
init(hex: String) {
|
||||||
let hex = hex.trimmingCharacters(in: CharacterSet.alphanumerics.inverted)
|
let hex = hex.trimmingCharacters(in: CharacterSet.alphanumerics.inverted)
|
||||||
var int: UInt64 = 0
|
var int: UInt64 = 0; Scanner(string: hex).scanHexInt64(&int)
|
||||||
Scanner(string: hex).scanHexInt64(&int)
|
|
||||||
let r, g, b: UInt64
|
let r, g, b: UInt64
|
||||||
switch hex.count {
|
if hex.count == 6 { (r, g, b) = (int >> 16, int >> 8 & 0xFF, int & 0xFF) }
|
||||||
case 6: (r, g, b) = (int >> 16, int >> 8 & 0xFF, int & 0xFF)
|
else { (r, g, b) = (0, 0, 0) }
|
||||||
default: (r, g, b) = (0, 0, 0)
|
|
||||||
}
|
|
||||||
self.init(.sRGB, red: Double(r)/255, green: Double(g)/255, blue: Double(b)/255)
|
self.init(.sRGB, red: Double(r)/255, green: Double(g)/255, blue: Double(b)/255)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,90 +1,42 @@
|
|||||||
import WidgetKit
|
import WidgetKit
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
// MARK: - Data
|
|
||||||
|
|
||||||
struct HealthEntry: TimelineEntry {
|
struct HealthEntry: TimelineEntry {
|
||||||
let date: Date
|
let date: Date
|
||||||
let steps: Int
|
let steps: Int
|
||||||
let sleep: Double
|
let sleep: Double
|
||||||
let heartRate: Int
|
let heartRate: Int
|
||||||
let readinessScore: Int
|
let readinessScore: Int
|
||||||
|
|
||||||
static let placeholder = HealthEntry(date: Date(), steps: 6234, sleep: 7.5, heartRate: 68, readinessScore: 80)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Provider
|
|
||||||
|
|
||||||
struct HealthProvider: TimelineProvider {
|
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) {
|
func getSnapshot(in context: Context, completion: @escaping (HealthEntry) -> Void) {
|
||||||
completion(.placeholder)
|
completion(currentEntry())
|
||||||
}
|
}
|
||||||
|
|
||||||
func getTimeline(in context: Context, completion: @escaping (Timeline<HealthEntry>) -> Void) {
|
func getTimeline(in context: Context, completion: @escaping (Timeline<HealthEntry>) -> Void) {
|
||||||
Task {
|
let entry = currentEntry()
|
||||||
let entry = await fetchHealthData()
|
let next = Calendar.current.date(byAdding: .minute, value: 15, to: Date()) ?? Date()
|
||||||
let nextUpdate = Calendar.current.date(byAdding: .minute, value: 30, to: Date()) ?? Date()
|
completion(Timeline(entries: [entry], policy: .after(next)))
|
||||||
completion(Timeline(entries: [entry], policy: .after(nextUpdate)))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func fetchHealthData() async -> HealthEntry {
|
private func currentEntry() -> HealthEntry {
|
||||||
let baseURL = "https://health.digital-home.site"
|
HealthEntry(
|
||||||
guard let token = KeychainService.load(key: KeychainService.healthTokenKey) else {
|
date: Date(),
|
||||||
return .placeholder
|
steps: WidgetData.steps,
|
||||||
}
|
sleep: WidgetData.sleep,
|
||||||
|
heartRate: WidgetData.heartRate,
|
||||||
// Fetch latest
|
readinessScore: WidgetData.readinessScore
|
||||||
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 {
|
struct HealthSummaryWidget: Widget {
|
||||||
let kind = "HealthSummary"
|
let kind = "HealthSummary"
|
||||||
|
|
||||||
var body: some WidgetConfiguration {
|
var body: some WidgetConfiguration {
|
||||||
StaticConfiguration(kind: kind, provider: HealthProvider()) { entry in
|
StaticConfiguration(kind: kind, provider: HealthProvider()) { entry in
|
||||||
HealthWidgetView(entry: entry)
|
HealthWidgetView(entry: entry)
|
||||||
@@ -96,29 +48,26 @@ struct HealthSummaryWidget: Widget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Views
|
|
||||||
|
|
||||||
struct HealthWidgetView: View {
|
struct HealthWidgetView: View {
|
||||||
let entry: HealthEntry
|
let entry: HealthEntry
|
||||||
@Environment(\.widgetFamily) var family
|
@Environment(\.widgetFamily) var family
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
switch family {
|
|
||||||
case .systemSmall: smallView
|
|
||||||
case .systemMedium: mediumView
|
|
||||||
default: smallView
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var readinessColor: Color {
|
var readinessColor: Color {
|
||||||
if entry.readinessScore >= 80 { return Color(hex: "0D9488") }
|
if entry.readinessScore >= 80 { return Color(hex: "0D9488") }
|
||||||
if entry.readinessScore >= 60 { return Color(hex: "ffa502") }
|
if entry.readinessScore >= 60 { return Color(hex: "ffa502") }
|
||||||
return Color(hex: "ff4757")
|
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 {
|
var smallView: some View {
|
||||||
VStack(spacing: 8) {
|
VStack(spacing: 8) {
|
||||||
// Readiness
|
|
||||||
ZStack {
|
ZStack {
|
||||||
Circle().stroke(Color.white.opacity(0.1), lineWidth: 6).frame(width: 50, height: 50)
|
Circle().stroke(Color.white.opacity(0.1), lineWidth: 6).frame(width: 50, height: 50)
|
||||||
Circle().trim(from: 0, to: CGFloat(entry.readinessScore) / 100)
|
Circle().trim(from: 0, to: CGFloat(entry.readinessScore) / 100)
|
||||||
@@ -126,7 +75,6 @@ struct HealthWidgetView: View {
|
|||||||
.frame(width: 50, height: 50).rotationEffect(.degrees(-90))
|
.frame(width: 50, height: 50).rotationEffect(.degrees(-90))
|
||||||
Text("\(entry.readinessScore)").font(.system(size: 16, weight: .bold, design: .rounded)).foregroundColor(readinessColor)
|
Text("\(entry.readinessScore)").font(.system(size: 16, weight: .bold, design: .rounded)).foregroundColor(readinessColor)
|
||||||
}
|
}
|
||||||
|
|
||||||
VStack(spacing: 4) {
|
VStack(spacing: 4) {
|
||||||
HStack(spacing: 4) {
|
HStack(spacing: 4) {
|
||||||
Image(systemName: "figure.walk").font(.system(size: 9)).foregroundColor(Color(hex: "ffa502"))
|
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 {
|
var mediumView: some View {
|
||||||
HStack(spacing: 16) {
|
HStack(spacing: 16) {
|
||||||
// Readiness ring
|
|
||||||
ZStack {
|
ZStack {
|
||||||
Circle().stroke(Color.white.opacity(0.1), lineWidth: 8).frame(width: 68, height: 68)
|
Circle().stroke(Color.white.opacity(0.1), lineWidth: 8).frame(width: 68, height: 68)
|
||||||
Circle().trim(from: 0, to: CGFloat(entry.readinessScore) / 100)
|
Circle().trim(from: 0, to: CGFloat(entry.readinessScore) / 100)
|
||||||
@@ -155,32 +100,23 @@ struct HealthWidgetView: View {
|
|||||||
Text("балл").font(.system(size: 9)).foregroundColor(.gray)
|
Text("балл").font(.system(size: 9)).foregroundColor(.gray)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Metrics
|
|
||||||
VStack(alignment: .leading, spacing: 6) {
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
Text("Здоровье").font(.subheadline.bold()).foregroundColor(.white)
|
Text("Здоровье").font(.subheadline.bold()).foregroundColor(.white)
|
||||||
HStack(spacing: 14) {
|
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) {
|
VStack(spacing: 3) {
|
||||||
Image(systemName: icon).font(.caption).foregroundColor(color)
|
Image(systemName: "figure.walk").font(.caption).foregroundColor(Color(hex: "ffa502"))
|
||||||
Text(value).font(.caption2.bold()).foregroundColor(.white)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
29
PulseWidget/Info.plist
Normal file
29
PulseWidget/Info.plist
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>CFBundleDevelopmentRegion</key>
|
||||||
|
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||||
|
<key>CFBundleDisplayName</key>
|
||||||
|
<string>Pulse Widget</string>
|
||||||
|
<key>CFBundleExecutable</key>
|
||||||
|
<string>$(EXECUTABLE_NAME)</string>
|
||||||
|
<key>CFBundleIdentifier</key>
|
||||||
|
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||||
|
<key>CFBundleInfoDictionaryVersion</key>
|
||||||
|
<string>6.0</string>
|
||||||
|
<key>CFBundleName</key>
|
||||||
|
<string>$(PRODUCT_NAME)</string>
|
||||||
|
<key>CFBundlePackageType</key>
|
||||||
|
<string>XPC!</string>
|
||||||
|
<key>CFBundleShortVersionString</key>
|
||||||
|
<string>1.0</string>
|
||||||
|
<key>CFBundleVersion</key>
|
||||||
|
<string>1</string>
|
||||||
|
<key>NSExtension</key>
|
||||||
|
<dict>
|
||||||
|
<key>NSExtensionPointIdentifier</key>
|
||||||
|
<string>com.apple.widgetkit-extension</string>
|
||||||
|
</dict>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -1,49 +1,18 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import Security
|
|
||||||
|
|
||||||
enum KeychainService {
|
// Widget reads data from shared UserDefaults (App Group), not Keychain
|
||||||
static let service = "com.daniil.pulsehealth"
|
enum WidgetData {
|
||||||
|
static let suiteName = "group.com.daniil.pulsehealth"
|
||||||
|
|
||||||
static func save(key: String, value: String) {
|
static var shared: UserDefaults? {
|
||||||
guard let data = value.data(using: .utf8) else { return }
|
UserDefaults(suiteName: suiteName)
|
||||||
let query: [String: Any] = [
|
|
||||||
kSecClass as String: kSecClassGenericPassword,
|
|
||||||
kSecAttrService as String: service,
|
|
||||||
kSecAttrAccount as String: key
|
|
||||||
]
|
|
||||||
SecItemDelete(query as CFDictionary)
|
|
||||||
var add = query
|
|
||||||
add[kSecValueData as String] = data
|
|
||||||
add[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlock
|
|
||||||
SecItemAdd(add as CFDictionary, nil)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static func load(key: String) -> String? {
|
static var habitsCompleted: Int { shared?.integer(forKey: "w_habits_completed") ?? 0 }
|
||||||
let query: [String: Any] = [
|
static var habitsTotal: Int { shared?.integer(forKey: "w_habits_total") ?? 0 }
|
||||||
kSecClass as String: kSecClassGenericPassword,
|
static var tasksCount: Int { shared?.integer(forKey: "w_tasks_count") ?? 0 }
|
||||||
kSecAttrService as String: service,
|
static var steps: Int { shared?.integer(forKey: "w_steps") ?? 0 }
|
||||||
kSecAttrAccount as String: key,
|
static var sleep: Double { shared?.double(forKey: "w_sleep") ?? 0 }
|
||||||
kSecReturnData as String: true,
|
static var heartRate: Int { shared?.integer(forKey: "w_heart_rate") ?? 0 }
|
||||||
kSecMatchLimit as String: kSecMatchLimitOne
|
static var readinessScore: Int { shared?.integer(forKey: "w_readiness") ?? 0 }
|
||||||
]
|
|
||||||
var result: AnyObject?
|
|
||||||
guard SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess,
|
|
||||||
let data = result as? Data else { return nil }
|
|
||||||
return String(data: data, encoding: .utf8)
|
|
||||||
}
|
|
||||||
|
|
||||||
static func delete(key: String) {
|
|
||||||
let query: [String: Any] = [
|
|
||||||
kSecClass as String: kSecClassGenericPassword,
|
|
||||||
kSecAttrService as String: service,
|
|
||||||
kSecAttrAccount as String: key
|
|
||||||
]
|
|
||||||
SecItemDelete(query as CFDictionary)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Keys
|
|
||||||
static let tokenKey = "auth_token"
|
|
||||||
static let refreshTokenKey = "auth_refresh_token"
|
|
||||||
static let healthTokenKey = "health_jwt_token"
|
|
||||||
static let healthApiKeyKey = "health_api_key"
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,9 +2,9 @@
|
|||||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
<plist version="1.0">
|
<plist version="1.0">
|
||||||
<dict>
|
<dict>
|
||||||
<key>keychain-access-groups</key>
|
<key>com.apple.security.application-groups</key>
|
||||||
<array>
|
<array>
|
||||||
<string>$(AppIdentifierPrefix)com.daniil.pulsehealth.shared</string>
|
<string>group.com.daniil.pulsehealth</string>
|
||||||
</array>
|
</array>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|||||||
@@ -25,11 +25,16 @@ targets:
|
|||||||
BackgroundModes:
|
BackgroundModes:
|
||||||
modes:
|
modes:
|
||||||
- processing
|
- processing
|
||||||
|
AppGroups:
|
||||||
|
groups:
|
||||||
|
- group.com.daniil.pulsehealth
|
||||||
|
|
||||||
PulseWidgetExtension:
|
PulseWidgetExtension:
|
||||||
type: app-extension
|
type: app-extension
|
||||||
platform: iOS
|
platform: iOS
|
||||||
sources: PulseWidget
|
sources: PulseWidget
|
||||||
|
entitlements:
|
||||||
|
path: PulseWidget/PulseWidget.entitlements
|
||||||
settings:
|
settings:
|
||||||
base:
|
base:
|
||||||
PRODUCT_BUNDLE_IDENTIFIER: com.daniil.pulsehealth.widget
|
PRODUCT_BUNDLE_IDENTIFIER: com.daniil.pulsehealth.widget
|
||||||
@@ -40,3 +45,7 @@ targets:
|
|||||||
MARKETING_VERSION: "1.0"
|
MARKETING_VERSION: "1.0"
|
||||||
CURRENT_PROJECT_VERSION: "1"
|
CURRENT_PROJECT_VERSION: "1"
|
||||||
INFOPLIST_KEY_CFBundleDisplayName: Pulse Widget
|
INFOPLIST_KEY_CFBundleDisplayName: Pulse Widget
|
||||||
|
capabilities:
|
||||||
|
AppGroups:
|
||||||
|
groups:
|
||||||
|
- group.com.daniil.pulsehealth
|
||||||
|
|||||||
Reference in New Issue
Block a user