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:
@@ -6,5 +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>
|
||||||
|
<array>
|
||||||
|
<string>$(AppIdentifierPrefix)com.daniil.pulsehealth.shared</string>
|
||||||
|
</array>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|||||||
@@ -154,10 +154,16 @@ class HealthKitService: ObservableObject {
|
|||||||
private func fetchSleepData(dateFormatter: DateFormatter) async -> [[String: Any]] {
|
private func fetchSleepData(dateFormatter: DateFormatter) async -> [[String: Any]] {
|
||||||
guard let sleepType = HKCategoryType.categoryType(forIdentifier: .sleepAnalysis) else { return [] }
|
guard let sleepType = HKCategoryType.categoryType(forIdentifier: .sleepAnalysis) else { return [] }
|
||||||
|
|
||||||
// Берём последние 24 часа, чтобы захватить ночной сон
|
// Ночной сон: с 18:00 вчера до сейчас (захватывает засыпание вечером + пробуждение утром)
|
||||||
let now = Date()
|
let now = Date()
|
||||||
guard let yesterday = Calendar.current.date(byAdding: .hour, value: -24, to: now) else { return [] }
|
let cal = Calendar.current
|
||||||
let sleepPredicate = HKQuery.predicateForSamples(withStart: yesterday, end: now)
|
var startComponents = cal.dateComponents([.year, .month, .day], from: now)
|
||||||
|
startComponents.hour = 18
|
||||||
|
startComponents.minute = 0
|
||||||
|
guard let todayEvening = cal.date(from: startComponents),
|
||||||
|
let yesterdayEvening = cal.date(byAdding: .day, value: -1, to: todayEvening) else { return [] }
|
||||||
|
let sleepStart = now.timeIntervalSince(todayEvening) > 0 ? todayEvening : yesterdayEvening
|
||||||
|
let sleepPredicate = HKQuery.predicateForSamples(withStart: sleepStart, end: now)
|
||||||
|
|
||||||
return await withCheckedContinuation { cont in
|
return await withCheckedContinuation { cont in
|
||||||
let sort = NSSortDescriptor(key: HKSampleSortIdentifierStartDate, ascending: true)
|
let sort = NSSortDescriptor(key: HKSampleSortIdentifierStartDate, ascending: true)
|
||||||
@@ -328,8 +334,13 @@ class HealthKitService: ObservableObject {
|
|||||||
func fetchSleepSegments() async -> [SleepSegment] {
|
func fetchSleepSegments() async -> [SleepSegment] {
|
||||||
guard let sleepType = HKCategoryType.categoryType(forIdentifier: .sleepAnalysis) else { return [] }
|
guard let sleepType = HKCategoryType.categoryType(forIdentifier: .sleepAnalysis) else { return [] }
|
||||||
let now = Date()
|
let now = Date()
|
||||||
guard let yesterday = Calendar.current.date(byAdding: .hour, value: -24, to: now) else { return [] }
|
let cal = Calendar.current
|
||||||
let predicate = HKQuery.predicateForSamples(withStart: yesterday, end: now)
|
var startComp = cal.dateComponents([.year, .month, .day], from: now)
|
||||||
|
startComp.hour = 18
|
||||||
|
guard let todayEvening = cal.date(from: startComp),
|
||||||
|
let yesterdayEvening = cal.date(byAdding: .day, value: -1, to: todayEvening) else { return [] }
|
||||||
|
let sleepStart = now.timeIntervalSince(todayEvening) > 0 ? todayEvening : yesterdayEvening
|
||||||
|
let predicate = HKQuery.predicateForSamples(withStart: sleepStart, end: now)
|
||||||
|
|
||||||
return await withCheckedContinuation { cont in
|
return await withCheckedContinuation { cont in
|
||||||
let sort = NSSortDescriptor(key: HKSampleSortIdentifierStartDate, ascending: true)
|
let sort = NSSortDescriptor(key: HKSampleSortIdentifierStartDate, ascending: true)
|
||||||
|
|||||||
@@ -3,13 +3,15 @@ import Security
|
|||||||
|
|
||||||
enum KeychainService {
|
enum KeychainService {
|
||||||
static let service = "com.daniil.pulsehealth"
|
static let service = "com.daniil.pulsehealth"
|
||||||
|
static let accessGroup = "V9AG8JTFLC.com.daniil.pulsehealth.shared"
|
||||||
|
|
||||||
static func save(key: String, value: String) {
|
static func save(key: String, value: String) {
|
||||||
guard let data = value.data(using: .utf8) else { return }
|
guard let data = value.data(using: .utf8) else { return }
|
||||||
let query: [String: Any] = [
|
let query: [String: Any] = [
|
||||||
kSecClass as String: kSecClassGenericPassword,
|
kSecClass as String: kSecClassGenericPassword,
|
||||||
kSecAttrService as String: service,
|
kSecAttrService as String: service,
|
||||||
kSecAttrAccount as String: key
|
kSecAttrAccount as String: key,
|
||||||
|
kSecAttrAccessGroup as String: accessGroup
|
||||||
]
|
]
|
||||||
SecItemDelete(query as CFDictionary)
|
SecItemDelete(query as CFDictionary)
|
||||||
var add = query
|
var add = query
|
||||||
@@ -23,6 +25,7 @@ enum KeychainService {
|
|||||||
kSecClass as String: kSecClassGenericPassword,
|
kSecClass as String: kSecClassGenericPassword,
|
||||||
kSecAttrService as String: service,
|
kSecAttrService as String: service,
|
||||||
kSecAttrAccount as String: key,
|
kSecAttrAccount as String: key,
|
||||||
|
kSecAttrAccessGroup as String: accessGroup,
|
||||||
kSecReturnData as String: true,
|
kSecReturnData as String: true,
|
||||||
kSecMatchLimit as String: kSecMatchLimitOne
|
kSecMatchLimit as String: kSecMatchLimitOne
|
||||||
]
|
]
|
||||||
@@ -36,7 +39,8 @@ enum KeychainService {
|
|||||||
let query: [String: Any] = [
|
let query: [String: Any] = [
|
||||||
kSecClass as String: kSecClassGenericPassword,
|
kSecClass as String: kSecClassGenericPassword,
|
||||||
kSecAttrService as String: service,
|
kSecAttrService as String: service,
|
||||||
kSecAttrAccount as String: key
|
kSecAttrAccount as String: key,
|
||||||
|
kSecAttrAccessGroup as String: accessGroup
|
||||||
]
|
]
|
||||||
SecItemDelete(query as CFDictionary)
|
SecItemDelete(query as CFDictionary)
|
||||||
}
|
}
|
||||||
|
|||||||
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
186
PulseWidget/HealthSummaryWidget.swift
Normal file
186
PulseWidget/HealthSummaryWidget.swift
Normal file
@@ -0,0 +1,186 @@
|
|||||||
|
import WidgetKit
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
// MARK: - Data
|
||||||
|
|
||||||
|
struct HealthEntry: TimelineEntry {
|
||||||
|
let date: Date
|
||||||
|
let steps: Int
|
||||||
|
let sleep: Double
|
||||||
|
let heartRate: Int
|
||||||
|
let readinessScore: Int
|
||||||
|
|
||||||
|
static let placeholder = HealthEntry(date: Date(), steps: 6234, sleep: 7.5, heartRate: 68, readinessScore: 80)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Provider
|
||||||
|
|
||||||
|
struct HealthProvider: TimelineProvider {
|
||||||
|
func placeholder(in context: Context) -> HealthEntry { .placeholder }
|
||||||
|
|
||||||
|
func getSnapshot(in context: Context, completion: @escaping (HealthEntry) -> Void) {
|
||||||
|
completion(.placeholder)
|
||||||
|
}
|
||||||
|
|
||||||
|
func getTimeline(in context: Context, completion: @escaping (Timeline<HealthEntry>) -> Void) {
|
||||||
|
Task {
|
||||||
|
let entry = await fetchHealthData()
|
||||||
|
let nextUpdate = Calendar.current.date(byAdding: .minute, value: 30, to: Date()) ?? Date()
|
||||||
|
completion(Timeline(entries: [entry], policy: .after(nextUpdate)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func fetchHealthData() async -> HealthEntry {
|
||||||
|
let baseURL = "https://health.digital-home.site"
|
||||||
|
guard let token = KeychainService.load(key: KeychainService.healthTokenKey) else {
|
||||||
|
return .placeholder
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch latest
|
||||||
|
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 {
|
||||||
|
let kind = "HealthSummary"
|
||||||
|
|
||||||
|
var body: some WidgetConfiguration {
|
||||||
|
StaticConfiguration(kind: kind, provider: HealthProvider()) { entry in
|
||||||
|
HealthWidgetView(entry: entry)
|
||||||
|
.containerBackground(.fill.tertiary, for: .widget)
|
||||||
|
}
|
||||||
|
.configurationDisplayName("Здоровье")
|
||||||
|
.description("Шаги, сон, пульс и готовность")
|
||||||
|
.supportedFamilies([.systemSmall, .systemMedium])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Views
|
||||||
|
|
||||||
|
struct HealthWidgetView: View {
|
||||||
|
let entry: HealthEntry
|
||||||
|
@Environment(\.widgetFamily) var family
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
switch family {
|
||||||
|
case .systemSmall: smallView
|
||||||
|
case .systemMedium: mediumView
|
||||||
|
default: smallView
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var readinessColor: Color {
|
||||||
|
if entry.readinessScore >= 80 { return Color(hex: "0D9488") }
|
||||||
|
if entry.readinessScore >= 60 { return Color(hex: "ffa502") }
|
||||||
|
return Color(hex: "ff4757")
|
||||||
|
}
|
||||||
|
|
||||||
|
var smallView: some View {
|
||||||
|
VStack(spacing: 8) {
|
||||||
|
// Readiness
|
||||||
|
ZStack {
|
||||||
|
Circle().stroke(Color.white.opacity(0.1), lineWidth: 6).frame(width: 50, height: 50)
|
||||||
|
Circle().trim(from: 0, to: CGFloat(entry.readinessScore) / 100)
|
||||||
|
.stroke(readinessColor, style: StrokeStyle(lineWidth: 6, lineCap: .round))
|
||||||
|
.frame(width: 50, height: 50).rotationEffect(.degrees(-90))
|
||||||
|
Text("\(entry.readinessScore)").font(.system(size: 16, weight: .bold, design: .rounded)).foregroundColor(readinessColor)
|
||||||
|
}
|
||||||
|
|
||||||
|
VStack(spacing: 4) {
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
Image(systemName: "figure.walk").font(.system(size: 9)).foregroundColor(Color(hex: "ffa502"))
|
||||||
|
Text("\(entry.steps)").font(.caption2.bold()).foregroundColor(.white)
|
||||||
|
}
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
Image(systemName: "moon.fill").font(.system(size: 9)).foregroundColor(Color(hex: "7c3aed"))
|
||||||
|
Text(String(format: "%.1fч", entry.sleep)).font(.caption2.bold()).foregroundColor(.white)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
.background(Color(hex: "06060f"))
|
||||||
|
}
|
||||||
|
|
||||||
|
var mediumView: some View {
|
||||||
|
HStack(spacing: 16) {
|
||||||
|
// Readiness ring
|
||||||
|
ZStack {
|
||||||
|
Circle().stroke(Color.white.opacity(0.1), lineWidth: 8).frame(width: 68, height: 68)
|
||||||
|
Circle().trim(from: 0, to: CGFloat(entry.readinessScore) / 100)
|
||||||
|
.stroke(readinessColor, style: StrokeStyle(lineWidth: 8, lineCap: .round))
|
||||||
|
.frame(width: 68, height: 68).rotationEffect(.degrees(-90))
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
Text("\(entry.readinessScore)").font(.system(size: 20, weight: .bold, design: .rounded)).foregroundColor(readinessColor)
|
||||||
|
Text("балл").font(.system(size: 9)).foregroundColor(.gray)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Metrics
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
Text("Здоровье").font(.subheadline.bold()).foregroundColor(.white)
|
||||||
|
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) {
|
||||||
|
Image(systemName: icon).font(.caption).foregroundColor(color)
|
||||||
|
Text(value).font(.caption2.bold()).foregroundColor(.white)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
53
PulseWidget/KeychainService.swift
Normal file
53
PulseWidget/KeychainService.swift
Normal 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"
|
||||||
|
}
|
||||||
10
PulseWidget/PulseWidget.entitlements
Normal file
10
PulseWidget/PulseWidget.entitlements
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
<?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>keychain-access-groups</key>
|
||||||
|
<array>
|
||||||
|
<string>$(AppIdentifierPrefix)com.daniil.pulsehealth.shared</string>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
10
PulseWidget/PulseWidgetBundle.swift
Normal file
10
PulseWidget/PulseWidgetBundle.swift
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import WidgetKit
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
@main
|
||||||
|
struct PulseWidgetBundle: WidgetBundle {
|
||||||
|
var body: some Widget {
|
||||||
|
HabitsProgressWidget()
|
||||||
|
HealthSummaryWidget()
|
||||||
|
}
|
||||||
|
}
|
||||||
22
project.yml
22
project.yml
@@ -10,6 +10,8 @@ targets:
|
|||||||
sources: PulseHealth
|
sources: PulseHealth
|
||||||
entitlements:
|
entitlements:
|
||||||
path: PulseHealth/PulseHealth.entitlements
|
path: PulseHealth/PulseHealth.entitlements
|
||||||
|
dependencies:
|
||||||
|
- target: PulseWidgetExtension
|
||||||
settings:
|
settings:
|
||||||
base:
|
base:
|
||||||
PRODUCT_BUNDLE_IDENTIFIER: com.daniil.pulsehealth
|
PRODUCT_BUNDLE_IDENTIFIER: com.daniil.pulsehealth
|
||||||
@@ -23,3 +25,23 @@ targets:
|
|||||||
BackgroundModes:
|
BackgroundModes:
|
||||||
modes:
|
modes:
|
||||||
- processing
|
- processing
|
||||||
|
|
||||||
|
PulseWidgetExtension:
|
||||||
|
type: app-extension
|
||||||
|
platform: iOS
|
||||||
|
sources: PulseWidget
|
||||||
|
entitlements:
|
||||||
|
path: PulseWidget/PulseWidget.entitlements
|
||||||
|
settings:
|
||||||
|
base:
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER: com.daniil.pulsehealth.widget
|
||||||
|
SWIFT_VERSION: 5.9
|
||||||
|
CODE_SIGN_STYLE: Automatic
|
||||||
|
DEVELOPMENT_TEAM: V9AG8JTFLC
|
||||||
|
INFOPLIST_FILE: ""
|
||||||
|
info:
|
||||||
|
path: PulseWidget/Info.plist
|
||||||
|
properties:
|
||||||
|
CFBundleDisplayName: Pulse Widget
|
||||||
|
NSExtension:
|
||||||
|
NSExtensionPointIdentifier: com.apple.widgetkit-extension
|
||||||
|
|||||||
Reference in New Issue
Block a user