feat: Initial iOS Health Dashboard app (Swift + SwiftUI)

This commit is contained in:
Cosmo
2026-03-25 10:38:58 +00:00
commit 7cda5deaab
14 changed files with 443 additions and 0 deletions

58
PulseHealth/App.swift Normal file
View File

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

21
PulseHealth/Info.plist Normal file
View File

@@ -0,0 +1,21 @@
<?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>ru</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>$(PRODUCT_BUNDLE_TYPE)</string>
<key>CFBundleShortVersionString</key><string>1.0</string>
<key>CFBundleVersion</key><string>1</string>
<key>NSHealthShareUsageDescription</key><string>Для отправки данных здоровья на ваш персональный дашборд</string>
<key>NSHealthUpdateUsageDescription</key><string>Для записи данных тренировок</string>
<key>UIApplicationSceneManifest</key>
<dict>
<key>UIApplicationSupportsMultipleScenes</key><false/>
</dict>
<key>UILaunchScreen</key><dict/>
</dict>
</plist>

View File

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

View File

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

View File

@@ -0,0 +1,8 @@
<?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>com.apple.developer.healthkit</key>
<true/>
</dict>
</plist>

View File

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

View File

@@ -0,0 +1,32 @@
import HealthKit
class HealthKitService: ObservableObject {
let healthStore = HKHealthStore()
var isAvailable: Bool { HKHealthStore.isHealthDataAvailable() }
func requestAuthorization() async throws {
let typesToRead: Set<HKObjectType> = [
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)
}
}
}

View File

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

View File

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

View File

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

View File

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