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

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