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:
2026-04-06 14:22:37 +03:00
parent d7d3eec2e5
commit f2580eb69f
9 changed files with 553 additions and 7 deletions

View File

@@ -0,0 +1,53 @@
import Foundation
import Security
enum KeychainService {
static let service = "com.daniil.pulsehealth"
static let accessGroup = "V9AG8JTFLC.com.daniil.pulsehealth.shared"
static func save(key: String, value: String) {
guard let data = value.data(using: .utf8) else { return }
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: key,
kSecAttrAccessGroup as String: accessGroup
]
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? {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: key,
kSecAttrAccessGroup as String: accessGroup,
kSecReturnData as String: true,
kSecMatchLimit as String: kSecMatchLimitOne
]
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,
kSecAttrAccessGroup as String: accessGroup
]
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"
}