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:
2026-04-06 14:47:26 +03:00
parent 0c21a14cb9
commit a07696bd55
10 changed files with 165 additions and 314 deletions

View File

@@ -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>

View 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")
}
}

View File

@@ -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] {

View File

@@ -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 {

View File

@@ -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)
} }
} }

View File

@@ -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")) VStack(spacing: 3) {
HealthBadge(icon: "moon.fill", value: String(format: "%.1fч", entry.sleep), color: Color(hex: "7c3aed")) Image(systemName: "figure.walk").font(.caption).foregroundColor(Color(hex: "ffa502"))
HealthBadge(icon: "heart.fill", value: "\(entry.heartRate)", color: Color(hex: "ff4757")) 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)
.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)
}
} }
} }

29
PulseWidget/Info.plist Normal file
View 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>

View File

@@ -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"
} }

View File

@@ -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>

View File

@@ -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