feat: major app overhaul — API fixes, glassmorphism UI, health dashboard, notifications

API Integration:
- Fix logHabit: send "date" instead of "completed_at"
- Fix FinanceCategory: "icon" → "emoji" to match API
- Fix task priorities: remove level 4, keep 1-3 matching API
- Fix habit frequencies: map monthly/interval → "custom" for API
- Add token refresh (401 → auto retry with new token)
- Add proper error handling (remove try? in save functions, show errors in UI)
- Add date field to savings transactions
- Add MonthlyPaymentDetail and OverduePayment models
- Fix habit completedToday: compute on client from logs (API doesn't return it)
- Filter habits by day of week on client (daily/weekly/monthly/interval)

Design System (glassmorphism):
- New DesignSystem.swift: Theme colors, GlassCard modifier, GlowIcon, GlowStatCard
- Custom tab bar with per-tab glow colors (VStack layout, not ZStack overlay)
- Deep dark background #06060f across all views
- Glass cards with gradient fill + stroke throughout app
- App icon: glassmorphism style with teal glow

Health Dashboard:
- Compact ReadinessBanner with recommendation text
- 8 metric tiles: sleep, HR, HRV, steps, SpO2, respiratory rate, energy, distance
- Each tile with status indicator (good/ok/bad) and hint text
- Heart rate card (min/avg/max)
- Weekly trends card (averages)
- Recovery score (weighted: 40% sleep, 35% HRV, 25% RHR)
- Tips card with actionable recommendations
- Sleep detail view with hypnogram (step chart of phases)
- Sleep segments timeline from HealthKit (deep/rem/core/awake with exact times)
- Line chart replacing bar chart for weekly data
- Collect respiratory_rate and sleep phases with timestamps from HealthKit
- Background sync every ~30min via BGProcessingTask

Notifications:
- NotificationService for local push notifications
- Morning/evening reminders with native DatePicker (wheel)
- Payment reminders: 5 days, 1 day, and day-of for recurring savings
- Notification settings in Settings tab

UI Fixes:
- Fix color picker overflow: HStack → LazyVGrid 5 columns
- Fix sheet headers: shorter text, proper padding
- Fix task/habit toggle: separate tap zones (checkbox vs edit)
- Fix deprecated onChange syntax for iOS 17+
- Savings overview: real monthly payments and detailed overdues from API
- Settings: timezone as Menu picker, removed Telegram/server notifications sections
- All sheets use .presentationDetents([.large])

Config:
- project.yml: real DEVELOPMENT_TEAM, HealthKit + BackgroundModes capabilities
- Info.plist: BGTaskScheduler + UIBackgroundModes
- Assets.xcassets with AppIcon
- CLAUDE.md project documentation

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-05 23:15:36 +03:00
parent 1146965bcb
commit 28fca1de89
38 changed files with 3608 additions and 1031 deletions

View File

@@ -1,4 +1,5 @@
import SwiftUI
import BackgroundTasks
extension Color {
init(hex: String) {
@@ -20,14 +21,54 @@ extension Color {
struct PulseApp: App {
@StateObject private var authManager = AuthManager()
init() {
BGTaskScheduler.shared.register(forTaskWithIdentifier: "com.daniil.pulsehealth.healthsync", using: nil) { task in
Self.handleHealthSync(task: task as! BGProcessingTask)
}
}
var body: some Scene {
WindowGroup {
if authManager.isLoggedIn {
MainTabView()
.environmentObject(authManager)
} else {
LoginView()
.environmentObject(authManager)
Group {
if authManager.isLoggedIn {
MainTabView()
} else {
LoginView()
}
}
.environmentObject(authManager)
.onAppear {
APIService.shared.authManager = authManager
Self.scheduleHealthSync()
}
}
}
static func scheduleHealthSync() {
let request = BGProcessingTaskRequest(identifier: "com.daniil.pulsehealth.healthsync")
request.earliestBeginDate = Date(timeIntervalSinceNow: 30 * 60) // 30 минут
request.requiresNetworkConnectivity = true
try? BGTaskScheduler.shared.submit(request)
}
static func handleHealthSync(task: BGProcessingTask) {
// Запланировать следующий синк
scheduleHealthSync()
let syncTask = Task {
let service = HealthKitService()
let apiKey = UserDefaults.standard.string(forKey: "healthApiKey") ?? "health-cosmo-2026"
try await service.syncToServer(apiKey: apiKey)
}
task.expirationHandler = { syncTask.cancel() }
Task {
do {
try await syncTask.value
task.setTaskCompleted(success: true)
} catch {
task.setTaskCompleted(success: false)
}
}
}
@@ -36,31 +77,45 @@ struct PulseApp: App {
class AuthManager: ObservableObject {
@Published var isLoggedIn: Bool = false
@Published var token: String = ""
@Published var refreshToken: String = ""
@Published var userName: String = ""
@Published var userId: Int = 0
@Published var healthApiKey: String = "health-cosmo-2026"
init() {
token = UserDefaults.standard.string(forKey: "pulseToken") ?? ""
refreshToken = UserDefaults.standard.string(forKey: "pulseRefreshToken") ?? ""
userName = UserDefaults.standard.string(forKey: "userName") ?? ""
userId = UserDefaults.standard.integer(forKey: "userId")
healthApiKey = UserDefaults.standard.string(forKey: "healthApiKey") ?? "health-cosmo-2026"
isLoggedIn = !token.isEmpty
}
func login(token: String, user: UserInfo) {
func login(token: String, refreshToken: String? = nil, user: UserInfo) {
self.token = token
self.refreshToken = refreshToken ?? ""
self.userName = user.displayName
self.userId = user.id
UserDefaults.standard.set(token, forKey: "pulseToken")
if let rt = refreshToken { UserDefaults.standard.set(rt, forKey: "pulseRefreshToken") }
UserDefaults.standard.set(user.displayName, forKey: "userName")
UserDefaults.standard.set(user.id, forKey: "userId")
isLoggedIn = true
}
func updateTokens(accessToken: String, refreshToken: String?) {
self.token = accessToken
UserDefaults.standard.set(accessToken, forKey: "pulseToken")
if let rt = refreshToken {
self.refreshToken = rt
UserDefaults.standard.set(rt, forKey: "pulseRefreshToken")
}
}
func logout() {
token = ""; userName = ""; userId = 0
token = ""; refreshToken = ""; userName = ""; userId = 0
UserDefaults.standard.removeObject(forKey: "pulseToken")
UserDefaults.standard.removeObject(forKey: "pulseRefreshToken")
UserDefaults.standard.removeObject(forKey: "userName")
UserDefaults.standard.removeObject(forKey: "userId")
isLoggedIn = false