From 7cda5deaabf50284d396cbab129b24d18af49e98 Mon Sep 17 00:00:00 2001 From: Cosmo Date: Wed, 25 Mar 2026 10:38:58 +0000 Subject: [PATCH] feat: Initial iOS Health Dashboard app (Swift + SwiftUI) --- .gitignore | 5 ++ PulseHealth/App.swift | 58 +++++++++++++++++ PulseHealth/Info.plist | 21 +++++++ PulseHealth/Models/AuthModels.swift | 5 ++ PulseHealth/Models/HealthModels.swift | 19 ++++++ PulseHealth/PulseHealth.entitlements | 8 +++ PulseHealth/Services/APIService.swift | 52 ++++++++++++++++ PulseHealth/Services/HealthKitService.swift | 32 ++++++++++ PulseHealth/Views/DashboardView.swift | 69 +++++++++++++++++++++ PulseHealth/Views/LoginView.swift | 55 ++++++++++++++++ PulseHealth/Views/MetricCardView.swift | 14 +++++ PulseHealth/Views/ReadinessCardView.swift | 57 +++++++++++++++++ README.md | 29 +++++++++ project.yml | 19 ++++++ 14 files changed, 443 insertions(+) create mode 100644 .gitignore create mode 100644 PulseHealth/App.swift create mode 100644 PulseHealth/Info.plist create mode 100644 PulseHealth/Models/AuthModels.swift create mode 100644 PulseHealth/Models/HealthModels.swift create mode 100644 PulseHealth/PulseHealth.entitlements create mode 100644 PulseHealth/Services/APIService.swift create mode 100644 PulseHealth/Services/HealthKitService.swift create mode 100644 PulseHealth/Views/DashboardView.swift create mode 100644 PulseHealth/Views/LoginView.swift create mode 100644 PulseHealth/Views/MetricCardView.swift create mode 100644 PulseHealth/Views/ReadinessCardView.swift create mode 100644 README.md create mode 100644 project.yml diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a9202c5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +*.xcodeproj/ +DerivedData/ +.DS_Store +*.ipa +build/ diff --git a/PulseHealth/App.swift b/PulseHealth/App.swift new file mode 100644 index 0000000..3bdc6e3 --- /dev/null +++ b/PulseHealth/App.swift @@ -0,0 +1,58 @@ +import SwiftUI + +extension Color { + init(hex: String) { + let hex = hex.trimmingCharacters(in: CharacterSet.alphanumerics.inverted) + var int: UInt64 = 0 + Scanner(string: hex).scanHexInt64(&int) + let a, r, g, b: UInt64 + switch hex.count { + case 3: (a, r, g, b) = (255, (int >> 8) * 17, (int >> 4 & 0xF) * 17, (int & 0xF) * 17) + case 6: (a, r, g, b) = (255, int >> 16, int >> 8 & 0xFF, int & 0xFF) + case 8: (a, r, g, b) = (int >> 24, int >> 16 & 0xFF, int >> 8 & 0xFF, int & 0xFF) + default: (a, r, g, b) = (255, 0, 0, 0) + } + self.init(.sRGB, red: Double(r)/255, green: Double(g)/255, blue: Double(b)/255, opacity: Double(a)/255) + } +} + +@main +struct PulseHealthApp: App { + @StateObject private var authManager = AuthManager() + var body: some Scene { + WindowGroup { + if authManager.isLoggedIn { + DashboardView().environmentObject(authManager) + } else { + LoginView().environmentObject(authManager) + } + } + } +} + +class AuthManager: ObservableObject { + @Published var isLoggedIn: Bool = false + @Published var token: String = "" + @Published var userName: String = "" + @Published var apiKey: String = "" + init() { + token = UserDefaults.standard.string(forKey: "authToken") ?? "" + userName = UserDefaults.standard.string(forKey: "userName") ?? "" + apiKey = UserDefaults.standard.string(forKey: "apiKey") ?? "" + isLoggedIn = !token.isEmpty + } + func login(token: String, name: String, apiKey: String) { + self.token = token; self.userName = name; self.apiKey = apiKey + UserDefaults.standard.set(token, forKey: "authToken") + UserDefaults.standard.set(name, forKey: "userName") + UserDefaults.standard.set(apiKey, forKey: "apiKey") + isLoggedIn = true + } + func logout() { + token = ""; userName = ""; apiKey = "" + UserDefaults.standard.removeObject(forKey: "authToken") + UserDefaults.standard.removeObject(forKey: "userName") + UserDefaults.standard.removeObject(forKey: "apiKey") + isLoggedIn = false + } +} diff --git a/PulseHealth/Info.plist b/PulseHealth/Info.plist new file mode 100644 index 0000000..783e6c3 --- /dev/null +++ b/PulseHealth/Info.plist @@ -0,0 +1,21 @@ + + + + + CFBundleDevelopmentRegionru + CFBundleExecutable$(EXECUTABLE_NAME) + CFBundleIdentifier$(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion6.0 + CFBundleName$(PRODUCT_NAME) + CFBundlePackageType$(PRODUCT_BUNDLE_TYPE) + CFBundleShortVersionString1.0 + CFBundleVersion1 + NSHealthShareUsageDescriptionДля отправки данных здоровья на ваш персональный дашборд + NSHealthUpdateUsageDescriptionДля записи данных тренировок + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + UILaunchScreen + + diff --git a/PulseHealth/Models/AuthModels.swift b/PulseHealth/Models/AuthModels.swift new file mode 100644 index 0000000..2b3d12c --- /dev/null +++ b/PulseHealth/Models/AuthModels.swift @@ -0,0 +1,5 @@ +import Foundation +struct LoginRequest: Codable { let email: String; let password: String } +struct LoginResponse: Codable { let token: String; let user: UserInfo } +struct UserInfo: Codable { let id: Int; let email: String; let name: String } +struct ProfileResponse: Codable { let user: UserInfo; let apiKey: String? } diff --git a/PulseHealth/Models/HealthModels.swift b/PulseHealth/Models/HealthModels.swift new file mode 100644 index 0000000..3cc3076 --- /dev/null +++ b/PulseHealth/Models/HealthModels.swift @@ -0,0 +1,19 @@ +import Foundation +struct ReadinessResponse: Codable { + let score: Int; let status: String; let recommendation: String + let date: String?; let factors: ReadinessFactors? +} +struct ReadinessFactors: Codable { + let sleep: FactorScore; let hrv: FactorScore; let rhr: FactorScore; let activity: FactorScore +} +struct FactorScore: Codable { let score: Int; let value: String; let baseline: String? } +struct LatestHealthResponse: Codable { + let sleep: SleepData?; let heartRate: HeartRateData?; let restingHeartRate: RestingHRData? + let hrv: HRVData?; let steps: StepsData?; let activeEnergy: EnergyData? +} +struct SleepData: Codable { let totalSleep: Double?; let deep: Double?; let rem: Double?; let core: Double? } +struct HeartRateData: Codable { let avg: Int?; let min: Int?; let max: Int? } +struct RestingHRData: Codable { let value: Double? } +struct HRVData: Codable { let avg: Double?; let latest: Double? } +struct StepsData: Codable { let total: Int? } +struct EnergyData: Codable { let total: Int?; let units: String? } diff --git a/PulseHealth/PulseHealth.entitlements b/PulseHealth/PulseHealth.entitlements new file mode 100644 index 0000000..b3187f1 --- /dev/null +++ b/PulseHealth/PulseHealth.entitlements @@ -0,0 +1,8 @@ + + + + + com.apple.developer.healthkit + + + diff --git a/PulseHealth/Services/APIService.swift b/PulseHealth/Services/APIService.swift new file mode 100644 index 0000000..3acdfe7 --- /dev/null +++ b/PulseHealth/Services/APIService.swift @@ -0,0 +1,52 @@ +import Foundation + +enum APIError: Error, LocalizedError { + case unauthorized, networkError, decodingError + var errorDescription: String? { + switch self { + case .unauthorized: return "Неверный email или пароль" + case .networkError: return "Ошибка сети" + case .decodingError: return "Ошибка данных" + } + } +} + +class APIService { + static let shared = APIService() + let baseURL = "https://health.digital-home.site" + + func login(email: String, password: String) async throws -> LoginResponse { + let url = URL(string: "\(baseURL)/api/auth/login")! + var req = URLRequest(url: url) + req.httpMethod = "POST" + req.setValue("application/json", forHTTPHeaderField: "Content-Type") + req.httpBody = try JSONEncoder().encode(LoginRequest(email: email, password: password)) + let (data, response) = try await URLSession.shared.data(for: req) + guard let r = response as? HTTPURLResponse, r.statusCode == 200 else { throw APIError.unauthorized } + return try JSONDecoder().decode(LoginResponse.self, from: data) + } + + func getProfile(token: String) async throws -> ProfileResponse { + let url = URL(string: "\(baseURL)/api/profile")! + var req = URLRequest(url: url) + req.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + let (data, _) = try await URLSession.shared.data(for: req) + return try JSONDecoder().decode(ProfileResponse.self, from: data) + } + + func getReadiness(token: String) async throws -> ReadinessResponse { + let url = URL(string: "\(baseURL)/api/health/readiness")! + var req = URLRequest(url: url) + req.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + let (data, _) = try await URLSession.shared.data(for: req) + return try JSONDecoder().decode(ReadinessResponse.self, from: data) + } + + func getLatest(token: String) async throws -> LatestHealthResponse { + let url = URL(string: "\(baseURL)/api/health/latest")! + var req = URLRequest(url: url) + req.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + let (data, _) = try await URLSession.shared.data(for: req) + return try JSONDecoder().decode(LatestHealthResponse.self, from: data) + } +} diff --git a/PulseHealth/Services/HealthKitService.swift b/PulseHealth/Services/HealthKitService.swift new file mode 100644 index 0000000..1d77e95 --- /dev/null +++ b/PulseHealth/Services/HealthKitService.swift @@ -0,0 +1,32 @@ +import HealthKit + +class HealthKitService: ObservableObject { + let healthStore = HKHealthStore() + var isAvailable: Bool { HKHealthStore.isHealthDataAvailable() } + + func requestAuthorization() async throws { + let typesToRead: Set = [ + HKQuantityType(.heartRate), + HKQuantityType(.restingHeartRate), + HKQuantityType(.heartRateVariabilitySDNN), + HKQuantityType(.stepCount), + HKQuantityType(.activeEnergyBurned), + HKQuantityType(.oxygenSaturation), + HKCategoryType(.sleepAnalysis), + ] + try await healthStore.requestAuthorization(toShare: [], read: typesToRead) + } + + func fetchTodaySteps() async -> Int { + guard let type = HKQuantityType.quantityType(forIdentifier: .stepCount) else { return 0 } + let now = Date() + let startOfDay = Calendar.current.startOfDay(for: now) + let predicate = HKQuery.predicateForSamples(withStart: startOfDay, end: now) + return await withCheckedContinuation { cont in + let q = HKStatisticsQuery(quantityType: type, quantitySamplePredicate: predicate, options: .cumulativeSum) { _, result, _ in + cont.resume(returning: Int(result?.sumQuantity()?.doubleValue(for: .count()) ?? 0)) + } + healthStore.execute(q) + } + } +} diff --git a/PulseHealth/Views/DashboardView.swift b/PulseHealth/Views/DashboardView.swift new file mode 100644 index 0000000..ec7ab30 --- /dev/null +++ b/PulseHealth/Views/DashboardView.swift @@ -0,0 +1,69 @@ +import SwiftUI + +struct DashboardView: View { + @EnvironmentObject var authManager: AuthManager + @StateObject private var healthKit = HealthKitService() + @State private var readiness: ReadinessResponse? + @State private var latest: LatestHealthResponse? + @State private var isLoading = true + + var body: some View { + ZStack { + LinearGradient(colors: [Color(hex: "1a1a2e"), Color(hex: "16213e")], startPoint: .top, endPoint: .bottom) + .ignoresSafeArea() + ScrollView { + VStack(spacing: 20) { + HStack { + Text("Привет, \(authManager.userName) 👋").font(.title2.bold()).foregroundColor(.white) + Spacer() + Button(action: { authManager.logout() }) { + Image(systemName: "rectangle.portrait.and.arrow.right").foregroundColor(.white.opacity(0.5)) + } + }.padding(.horizontal).padding(.top) + + if isLoading { + ProgressView().tint(Color(hex: "00d4aa")).padding(.top, 60) + } else { + if let r = readiness { ReadinessCardView(readiness: r) } + if let l = latest { + LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 16) { + if let sleep = l.sleep { + MetricCardView(icon: "moon.fill", title: "Сон", + value: String(format: "%.1f ч", sleep.totalSleep ?? 0), + subtitle: "Глубокий: \(String(format: "%.0f мин", (sleep.deep ?? 0) * 60))", + color: Color(hex: "6c63ff")) + } + if let rhr = l.restingHeartRate { + MetricCardView(icon: "heart.fill", title: "Пульс покоя", + value: "\(Int(rhr.value ?? 0)) уд/мин", subtitle: "Resting HR", + color: Color(hex: "ff6b6b")) + } + if let hrv = l.hrv { + MetricCardView(icon: "waveform.path.ecg", title: "HRV", + value: "\(Int(hrv.avg ?? 0)) мс", subtitle: "Вариабельность", + color: Color(hex: "00d4aa")) + } + if let steps = l.steps { + MetricCardView(icon: "figure.walk", title: "Шаги", + value: "\(steps.total ?? 0)", subtitle: "Сегодня", + color: Color(hex: "ffa500")) + } + }.padding(.horizontal) + } + } + Spacer(minLength: 20) + } + } + } + .task { await loadData() } + } + + func loadData() async { + isLoading = true + async let r = APIService.shared.getReadiness(token: authManager.token) + async let l = APIService.shared.getLatest(token: authManager.token) + readiness = try? await r + latest = try? await l + isLoading = false + } +} diff --git a/PulseHealth/Views/LoginView.swift b/PulseHealth/Views/LoginView.swift new file mode 100644 index 0000000..34d6574 --- /dev/null +++ b/PulseHealth/Views/LoginView.swift @@ -0,0 +1,55 @@ +import SwiftUI + +struct LoginView: View { + @EnvironmentObject var authManager: AuthManager + @State private var email = "" + @State private var password = "" + @State private var isLoading = false + @State private var errorMessage = "" + + var body: some View { + ZStack { + LinearGradient(colors: [Color(hex: "1a1a2e"), Color(hex: "16213e")], startPoint: .top, endPoint: .bottom) + .ignoresSafeArea() + VStack(spacing: 32) { + VStack(spacing: 8) { + Text("🫀").font(.system(size: 60)) + Text("Pulse Health").font(.largeTitle.bold()).foregroundColor(.white) + Text("Персональный дашборд здоровья").font(.subheadline).foregroundColor(.white.opacity(0.6)) + }.padding(.top, 60) + VStack(spacing: 16) { + TextField("Email", text: $email) + .keyboardType(.emailAddress).autocapitalization(.none) + .padding().background(Color.white.opacity(0.1)).cornerRadius(12).foregroundColor(.white) + SecureField("Пароль", text: $password) + .padding().background(Color.white.opacity(0.1)).cornerRadius(12).foregroundColor(.white) + if !errorMessage.isEmpty { + Text(errorMessage).foregroundColor(.red).font(.caption) + } + Button(action: login) { + if isLoading { ProgressView().tint(.white) } + else { Text("Войти").font(.headline).foregroundColor(.black) } + } + .frame(maxWidth: .infinity).padding() + .background(Color(hex: "00d4aa")).cornerRadius(12).disabled(isLoading) + }.padding(.horizontal, 24) + Spacer() + } + } + } + + func login() { + isLoading = true; errorMessage = "" + Task { + do { + let response = try await APIService.shared.login(email: email, password: password) + let profile = try await APIService.shared.getProfile(token: response.token) + await MainActor.run { + authManager.login(token: response.token, name: response.user.name, apiKey: profile.apiKey ?? "") + } + } catch { + await MainActor.run { errorMessage = error.localizedDescription; isLoading = false } + } + } + } +} diff --git a/PulseHealth/Views/MetricCardView.swift b/PulseHealth/Views/MetricCardView.swift new file mode 100644 index 0000000..909511e --- /dev/null +++ b/PulseHealth/Views/MetricCardView.swift @@ -0,0 +1,14 @@ +import SwiftUI + +struct MetricCardView: View { + let icon: String; let title: String; let value: String; let subtitle: String; let color: Color + var body: some View { + VStack(alignment: .leading, spacing: 12) { + Image(systemName: icon).foregroundColor(color).font(.title2) + Text(value).font(.title2.bold()).foregroundColor(.white) + Text(title).font(.subheadline).foregroundColor(.white.opacity(0.7)) + Text(subtitle).font(.caption).foregroundColor(.white.opacity(0.5)) + } + .padding(16).background(Color.white.opacity(0.05)).cornerRadius(16) + } +} diff --git a/PulseHealth/Views/ReadinessCardView.swift b/PulseHealth/Views/ReadinessCardView.swift new file mode 100644 index 0000000..e13cd8b --- /dev/null +++ b/PulseHealth/Views/ReadinessCardView.swift @@ -0,0 +1,57 @@ +import SwiftUI + +struct ReadinessCardView: View { + let readiness: ReadinessResponse + var statusColor: Color { + switch readiness.status { + case "ready": return Color(hex: "00d4aa") + case "moderate": return Color(hex: "ffa500") + default: return Color(hex: "ff6b6b") + } + } + var statusEmoji: String { + switch readiness.status { case "ready": return "💪"; case "moderate": return "🚶"; default: return "😴" } + } + var body: some View { + VStack(spacing: 16) { + Text("Готовность").font(.headline).foregroundColor(.white.opacity(0.7)) + ZStack { + Circle().stroke(Color.white.opacity(0.1), lineWidth: 12).frame(width: 140, height: 140) + Circle().trim(from: 0, to: CGFloat(readiness.score) / 100) + .stroke(statusColor, style: StrokeStyle(lineWidth: 12, lineCap: .round)) + .frame(width: 140, height: 140).rotationEffect(.degrees(-90)) + .animation(.easeInOut(duration: 1), value: readiness.score) + VStack(spacing: 4) { + Text("\(readiness.score)").font(.system(size: 44, weight: .bold)).foregroundColor(statusColor) + Text(statusEmoji).font(.title2) + } + } + Text(readiness.recommendation).font(.subheadline).foregroundColor(.white.opacity(0.8)).multilineTextAlignment(.center).padding(.horizontal) + if let f = readiness.factors { + VStack(spacing: 8) { + FactorRow(name: "Сон", score: f.sleep.score, value: f.sleep.value) + FactorRow(name: "HRV", score: f.hrv.score, value: f.hrv.value) + FactorRow(name: "Пульс", score: f.rhr.score, value: f.rhr.value) + FactorRow(name: "Активность", score: f.activity.score, value: f.activity.value) + }.padding(.horizontal) + } + } + .padding(24).background(Color.white.opacity(0.05)).cornerRadius(20).padding(.horizontal) + } +} + +struct FactorRow: View { + let name: String; let score: Int; let value: String + var body: some View { + HStack { + Text(name).font(.caption).foregroundColor(.white.opacity(0.6)).frame(width: 70, alignment: .leading) + GeometryReader { geo in + ZStack(alignment: .leading) { + RoundedRectangle(cornerRadius: 4).fill(Color.white.opacity(0.1)) + RoundedRectangle(cornerRadius: 4).fill(Color(hex: "00d4aa")).frame(width: geo.size.width * CGFloat(score) / 100) + } + }.frame(height: 6) + Text(value).font(.caption).foregroundColor(.white.opacity(0.6)).frame(width: 60, alignment: .trailing) + } + } +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..ae96c66 --- /dev/null +++ b/README.md @@ -0,0 +1,29 @@ +# Pulse Health — iOS App + +Health Dashboard для health.digital-home.site + +## Требования +- macOS 14+, Xcode 15+, iPhone iOS 17+ + +## Сборка + +1. Установить XcodeGen: + ```bash + brew install xcodegen + ``` + +2. Сгенерировать проект: + ```bash + xcodegen generate + ``` + +3. Открыть и запустить: + ```bash + open PulseHealth.xcodeproj + ``` + +4. Xcode → Signing & Capabilities → выбрать свой Team (Apple ID) +5. Выбрать iPhone как устройство → Run (⌘R) + +## Credentials +Войди с email/паролем от health.digital-home.site diff --git a/project.yml b/project.yml new file mode 100644 index 0000000..ab5c1cd --- /dev/null +++ b/project.yml @@ -0,0 +1,19 @@ +name: PulseHealth +options: + bundleIdPrefix: com.daniil + deploymentTarget: + iOS: "17.0" +targets: + PulseHealth: + type: application + platform: iOS + sources: PulseHealth + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: com.daniil.pulsehealth + SWIFT_VERSION: 5.9 + INFOPLIST_FILE: PulseHealth/Info.plist + CODE_SIGN_STYLE: Automatic + DEVELOPMENT_TEAM: "" + entitlements: + path: PulseHealth/PulseHealth.entitlements