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