feat: Initial iOS Health Dashboard app (Swift + SwiftUI)
This commit is contained in:
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
*.xcodeproj/
|
||||||
|
DerivedData/
|
||||||
|
.DS_Store
|
||||||
|
*.ipa
|
||||||
|
build/
|
||||||
58
PulseHealth/App.swift
Normal file
58
PulseHealth/App.swift
Normal 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
21
PulseHealth/Info.plist
Normal 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>
|
||||||
5
PulseHealth/Models/AuthModels.swift
Normal file
5
PulseHealth/Models/AuthModels.swift
Normal 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? }
|
||||||
19
PulseHealth/Models/HealthModels.swift
Normal file
19
PulseHealth/Models/HealthModels.swift
Normal 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? }
|
||||||
8
PulseHealth/PulseHealth.entitlements
Normal file
8
PulseHealth/PulseHealth.entitlements
Normal 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>
|
||||||
52
PulseHealth/Services/APIService.swift
Normal file
52
PulseHealth/Services/APIService.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
32
PulseHealth/Services/HealthKitService.swift
Normal file
32
PulseHealth/Services/HealthKitService.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
29
README.md
Normal file
29
README.md
Normal file
@@ -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
|
||||||
19
project.yml
Normal file
19
project.yml
Normal file
@@ -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
|
||||||
Reference in New Issue
Block a user