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

@@ -15,6 +15,7 @@ class HealthKitService: ObservableObject {
HKQuantityType(.activeEnergyBurned),
HKQuantityType(.oxygenSaturation),
HKQuantityType(.distanceWalkingRunning),
HKQuantityType(.respiratoryRate),
HKCategoryType(.sleepAnalysis),
]
@@ -116,15 +117,136 @@ class HealthKitService: ObservableObject {
])
}
// Respiratory Rate (дыхание)
let rrValues = await fetchAllSamples(.respiratoryRate, unit: HKUnit.count().unitDivided(by: .minute()), predicate: predicate)
if !rrValues.isEmpty {
let rrData = rrValues.map { sample -> [String: Any] in
["date": dateFormatter.string(from: sample.startDate), "qty": sample.quantity.doubleValue(for: HKUnit.count().unitDivided(by: .minute()))]
}
metrics.append([
"name": "respiratory_rate",
"units": "breaths/min",
"data": rrData
])
}
// Sleep Analysis расширенный диапазон (последние 24 ча<EFBFBD><EFBFBD>а, чтобы захватить ночной сон)
let sleepData = await fetchSleepData(dateFormatter: dateFormatter)
if !sleepData.isEmpty {
metrics.append([
"name": "sleep_analysis",
"units": "hr",
"data": sleepData
])
}
return metrics
}
// MARK: - Sleep Data Collection
private func fetchSleepData(dateFormatter: DateFormatter) async -> [[String: Any]] {
guard let sleepType = HKCategoryType.categoryType(forIdentifier: .sleepAnalysis) else { return [] }
// Берём последние 24 часа, чтобы захватить ночной сон
let now = Date()
let yesterday = Calendar.current.date(byAdding: .hour, value: -24, to: now)!
let sleepPredicate = HKQuery.predicateForSamples(withStart: yesterday, end: now)
return await withCheckedContinuation { cont in
let sort = NSSortDescriptor(key: HKSampleSortIdentifierStartDate, ascending: true)
let query = HKSampleQuery(sampleType: sleepType, predicate: sleepPredicate, limit: HKObjectQueryNoLimit, sortDescriptors: [sort]) { _, samples, _ in
guard let samples = samples as? [HKCategorySample], !samples.isEmpty else {
cont.resume(returning: [])
return
}
// Считаем время по каждой стадии сна в часах
var deepSeconds = 0.0
var remSeconds = 0.0
var coreSeconds = 0.0
var awakeSeconds = 0.0
var asleepSeconds = 0.0
var inBedStart: Date?
var inBedEnd: Date?
for sample in samples {
let duration = sample.endDate.timeIntervalSince(sample.startDate)
switch sample.value {
case HKCategoryValueSleepAnalysis.inBed.rawValue:
if inBedStart == nil || sample.startDate < inBedStart! { inBedStart = sample.startDate }
if inBedEnd == nil || sample.endDate > inBedEnd! { inBedEnd = sample.endDate }
case HKCategoryValueSleepAnalysis.asleepDeep.rawValue:
deepSeconds += duration
asleepSeconds += duration
case HKCategoryValueSleepAnalysis.asleepREM.rawValue:
remSeconds += duration
asleepSeconds += duration
case HKCategoryValueSleepAnalysis.asleepCore.rawValue:
coreSeconds += duration
asleepSeconds += duration
case HKCategoryValueSleepAnalysis.asleepUnspecified.rawValue:
asleepSeconds += duration
case HKCategoryValueSleepAnalysis.awake.rawValue:
awakeSeconds += duration
default:
break
}
}
// Если нет inBed, берём из asleep-сэмплов
if inBedStart == nil {
let asleepSamples = samples.filter { $0.value != HKCategoryValueSleepAnalysis.awake.rawValue && $0.value != HKCategoryValueSleepAnalysis.inBed.rawValue }
inBedStart = asleepSamples.first?.startDate
inBedEnd = asleepSamples.last?.endDate
}
let totalSleep = asleepSeconds / 3600.0
guard totalSleep > 0 else {
cont.resume(returning: [])
return
}
let startOfDay = Calendar.current.startOfDay(for: now)
// Формат, который ожидает health-webhook API
var entry: [String: Any] = [
"date": dateFormatter.string(from: startOfDay),
"totalSleep": round(totalSleep * 1000) / 1000,
"deep": round((deepSeconds / 3600.0) * 1000) / 1000,
"rem": round((remSeconds / 3600.0) * 1000) / 1000,
"core": round((coreSeconds / 3600.0) * 1000) / 1000,
"awake": round((awakeSeconds / 3600.0) * 1000) / 1000,
"asleep": round(totalSleep * 1000) / 1000,
"source": "Apple Watch"
]
if let start = inBedStart {
entry["inBedStart"] = dateFormatter.string(from: start)
entry["sleepStart"] = dateFormatter.string(from: start)
}
if let end = inBedEnd {
entry["inBedEnd"] = dateFormatter.string(from: end)
entry["sleepEnd"] = dateFormatter.string(from: end)
}
cont.resume(returning: [entry])
}
self.healthStore.execute(query)
}
}
// MARK: - Send to Server
func syncToServer(apiKey: String) async throws {
await MainActor.run { isSyncing = true }
defer { Task { @MainActor in isSyncing = false } }
guard isAvailable else {
throw HealthKitError.notAvailable
}
try await requestAuthorization()
let metrics = await collectAllMetrics()
@@ -151,11 +273,12 @@ class HealthKitService: ObservableObject {
request.httpBody = jsonData
request.timeoutInterval = 30
let (_, response) = try await URLSession.shared.data(for: request)
let (data, response) = try await URLSession.shared.data(for: request)
guard let httpResponse = response as? HTTPURLResponse,
(200...299).contains(httpResponse.statusCode) else {
let code = (response as? HTTPURLResponse)?.statusCode ?? 0
throw HealthKitError.serverError(code)
let body = String(data: data, encoding: .utf8) ?? ""
throw HealthKitError.serverError(code, body)
}
}
@@ -193,20 +316,57 @@ class HealthKitService: ObservableObject {
healthStore.execute(query)
}
}
// MARK: - Sleep Segments (timeline)
func fetchSleepSegments() async -> [SleepSegment] {
guard let sleepType = HKCategoryType.categoryType(forIdentifier: .sleepAnalysis) else { return [] }
let now = Date()
let yesterday = Calendar.current.date(byAdding: .hour, value: -24, to: now)!
let predicate = HKQuery.predicateForSamples(withStart: yesterday, end: now)
return await withCheckedContinuation { cont in
let sort = NSSortDescriptor(key: HKSampleSortIdentifierStartDate, ascending: true)
let query = HKSampleQuery(sampleType: sleepType, predicate: predicate, limit: HKObjectQueryNoLimit, sortDescriptors: [sort]) { _, samples, _ in
guard let samples = samples as? [HKCategorySample] else {
cont.resume(returning: [])
return
}
let segments: [SleepSegment] = samples.compactMap { sample in
let phase: SleepPhaseType?
switch sample.value {
case HKCategoryValueSleepAnalysis.asleepDeep.rawValue: phase = .deep
case HKCategoryValueSleepAnalysis.asleepREM.rawValue: phase = .rem
case HKCategoryValueSleepAnalysis.asleepCore.rawValue: phase = .core
case HKCategoryValueSleepAnalysis.asleepUnspecified.rawValue: phase = .core
case HKCategoryValueSleepAnalysis.awake.rawValue: phase = .awake
default: phase = nil
}
guard let p = phase else { return nil }
return SleepSegment(phase: p, start: sample.startDate, end: sample.endDate)
}
cont.resume(returning: segments)
}
self.healthStore.execute(query)
}
}
}
// MARK: - Errors
enum HealthKitError: Error, LocalizedError {
case notAvailable
case noData
case invalidURL
case serverError(Int)
case serverError(Int, String)
var errorDescription: String? {
switch self {
case .noData: return "Нет данных HealthKit за сегодня"
case .notAvailable: return "HealthKit недоступен на этом устройстве"
case .noData: return "Нет данных HealthKit за сегодня. Убедитесь, что Apple Watch синхронизированы"
case .invalidURL: return "Неверный URL сервера"
case .serverError(let code): return "Ошибка сервера: \(code)"
case .serverError(let code, let body): return "Ошибка сервера (\(code)): \(body.prefix(100))"
}
}
}