feat: Initial iOS Health Dashboard app (Swift + SwiftUI)
This commit is contained in:
69
PulseHealth/Views/DashboardView.swift
Normal file
69
PulseHealth/Views/DashboardView.swift
Normal 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
|
||||
}
|
||||
}
|
||||
55
PulseHealth/Views/LoginView.swift
Normal file
55
PulseHealth/Views/LoginView.swift
Normal 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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
14
PulseHealth/Views/MetricCardView.swift
Normal file
14
PulseHealth/Views/MetricCardView.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
57
PulseHealth/Views/ReadinessCardView.swift
Normal file
57
PulseHealth/Views/ReadinessCardView.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user