diff --git a/PulseHealth/PulseHealth.entitlements b/PulseHealth/PulseHealth.entitlements index 54bc426..2f178e1 100644 --- a/PulseHealth/PulseHealth.entitlements +++ b/PulseHealth/PulseHealth.entitlements @@ -6,5 +6,9 @@ com.apple.developer.healthkit.background-delivery + keychain-access-groups + + $(AppIdentifierPrefix)com.daniil.pulsehealth.shared + diff --git a/PulseHealth/Services/HealthKitService.swift b/PulseHealth/Services/HealthKitService.swift index 5f438c0..c746454 100644 --- a/PulseHealth/Services/HealthKitService.swift +++ b/PulseHealth/Services/HealthKitService.swift @@ -154,10 +154,16 @@ class HealthKitService: ObservableObject { private func fetchSleepData(dateFormatter: DateFormatter) async -> [[String: Any]] { guard let sleepType = HKCategoryType.categoryType(forIdentifier: .sleepAnalysis) else { return [] } - // Берём последние 24 часа, чтобы захватить ночной сон + // Ночной сон: с 18:00 вчера до сейчас (захватывает засыпание вечером + пробуждение утром) let now = Date() - guard let yesterday = Calendar.current.date(byAdding: .hour, value: -24, to: now) else { return [] } - let sleepPredicate = HKQuery.predicateForSamples(withStart: yesterday, end: now) + let cal = Calendar.current + 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 let sort = NSSortDescriptor(key: HKSampleSortIdentifierStartDate, ascending: true) @@ -328,8 +334,13 @@ class HealthKitService: ObservableObject { func fetchSleepSegments() async -> [SleepSegment] { guard let sleepType = HKCategoryType.categoryType(forIdentifier: .sleepAnalysis) else { return [] } let now = Date() - guard let yesterday = Calendar.current.date(byAdding: .hour, value: -24, to: now) else { return [] } - let predicate = HKQuery.predicateForSamples(withStart: yesterday, end: now) + let cal = Calendar.current + 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 let sort = NSSortDescriptor(key: HKSampleSortIdentifierStartDate, ascending: true) diff --git a/PulseHealth/Services/KeychainService.swift b/PulseHealth/Services/KeychainService.swift index 782fa28..434763f 100644 --- a/PulseHealth/Services/KeychainService.swift +++ b/PulseHealth/Services/KeychainService.swift @@ -3,13 +3,15 @@ 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 + kSecAttrAccount as String: key, + kSecAttrAccessGroup as String: accessGroup ] SecItemDelete(query as CFDictionary) var add = query @@ -23,6 +25,7 @@ enum KeychainService { kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: service, kSecAttrAccount as String: key, + kSecAttrAccessGroup as String: accessGroup, kSecReturnData as String: true, kSecMatchLimit as String: kSecMatchLimitOne ] @@ -36,7 +39,8 @@ enum KeychainService { let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: service, - kSecAttrAccount as String: key + kSecAttrAccount as String: key, + kSecAttrAccessGroup as String: accessGroup ] SecItemDelete(query as CFDictionary) } diff --git a/PulseWidget/HabitsProgressWidget.swift b/PulseWidget/HabitsProgressWidget.swift new file mode 100644 index 0000000..7162911 --- /dev/null +++ b/PulseWidget/HabitsProgressWidget.swift @@ -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) -> 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) + } +} diff --git a/PulseWidget/HealthSummaryWidget.swift b/PulseWidget/HealthSummaryWidget.swift new file mode 100644 index 0000000..f6a394f --- /dev/null +++ b/PulseWidget/HealthSummaryWidget.swift @@ -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) -> 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) + } + } +} diff --git a/PulseWidget/KeychainService.swift b/PulseWidget/KeychainService.swift new file mode 100644 index 0000000..434763f --- /dev/null +++ b/PulseWidget/KeychainService.swift @@ -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" +} diff --git a/PulseWidget/PulseWidget.entitlements b/PulseWidget/PulseWidget.entitlements new file mode 100644 index 0000000..5b8fed1 --- /dev/null +++ b/PulseWidget/PulseWidget.entitlements @@ -0,0 +1,10 @@ + + + + + keychain-access-groups + + $(AppIdentifierPrefix)com.daniil.pulsehealth.shared + + + diff --git a/PulseWidget/PulseWidgetBundle.swift b/PulseWidget/PulseWidgetBundle.swift new file mode 100644 index 0000000..8a87e7e --- /dev/null +++ b/PulseWidget/PulseWidgetBundle.swift @@ -0,0 +1,10 @@ +import WidgetKit +import SwiftUI + +@main +struct PulseWidgetBundle: WidgetBundle { + var body: some Widget { + HabitsProgressWidget() + HealthSummaryWidget() + } +} diff --git a/project.yml b/project.yml index 89d497a..2d34d37 100644 --- a/project.yml +++ b/project.yml @@ -10,6 +10,8 @@ targets: sources: PulseHealth entitlements: path: PulseHealth/PulseHealth.entitlements + dependencies: + - target: PulseWidgetExtension settings: base: PRODUCT_BUNDLE_IDENTIFIER: com.daniil.pulsehealth @@ -23,3 +25,23 @@ targets: BackgroundModes: modes: - 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